diff --git a/splat.png b/splat.png new file mode 100644 index 0000000..39cb7aa Binary files /dev/null and b/splat.png differ diff --git a/src/App.jsx b/src/App.jsx index b7453bf..044a7fa 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,6 +1,7 @@ import { useState, useRef, useCallback, useEffect, useLayoutEffect, Fragment } from 'react' import './App.css' import maleBody from './assets/body/malebody.png' +import splatImg from './assets/splat.png' import pistolShotMp3 from './assets/pistolshot.mp3' import chamberBulletMp3 from './assets/chamberbullet.mp3' import names from './names.json' @@ -79,10 +80,30 @@ function generateWorld() { relationships: { b1: 'enemy' }, size: 1, }) + // Arena NPCs for Place (loc6) — 10 left vs 10 right, all enemies cross-team + const placeLeftIds = Array.from({ length: 10 }, (_, i) => `place_l${i}`) + const placeRightIds = Array.from({ length: 10 }, (_, i) => `place_r${i}`) + const placeWeapons = ['rusty_sword', 'dagger', 'steel_sword', 'warhammer', 'iron_shield'] + for (let i = 0; i < 10; i++) { + const wpn = placeWeapons[i % placeWeapons.length] + const relsL = {}; for (const rid of placeRightIds) relsL[rid] = 'enemy' + characters.push({ + id: `place_l${i}`, name: `Attacker ${i + 1}`, location: 'loc6', + inventory: [wpn], equipped: { left: wpn, right: 'fists' }, + relationships: relsL, size: 1, fillColor: `hsl(220, 70%, 50%)`, + }) + const relsR = {}; for (const lid of placeLeftIds) relsR[lid] = 'enemy' + characters.push({ + id: `place_r${i}`, name: `Defender ${i + 1}`, location: 'loc6', + inventory: [wpn], equipped: { left: wpn, right: 'fists' }, + relationships: relsR, size: 1, fillColor: `hsl(0, 70%, 50%)`, + }) + } + // Generic NPCs for the remaining locations let npcId = 0 for (let i = 0; i < 7; i++) { - if (i === zephyrIdx || i === fightIdx) continue + if (i === zephyrIdx || i === fightIdx || i === 6) continue characters.push({ id: `npc${npcId}`, location: `loc${i}`, money: 50 + Math.floor(Math.random() * 200), @@ -717,9 +738,9 @@ export default function App() { const [weather, setWeather] = useState('clear') const [defeatedNpcs, setDefeatedNpcs] = useState([]) const [characterOverrides, setCharacterOverrides] = useState({}) + const characterOverridesRef = useRef({}); characterOverridesRef.current = JSON.parse(JSON.stringify(characterOverrides)) const worldItemsRef = useRef({}) const groundItemsRef = useRef([]) - const savedMapsRef = useRef({}) const liveCharacters = characters.map(c => ({ ...c, ...characterOverrides[c.id] })).filter(c => !defeatedNpcs.includes(c.id)) // ── ui state @@ -745,6 +766,7 @@ export default function App() { const [passTimeHours, setPassTimeHours] = useState(1) const [skipAnim, setSkipAnim] = useState(null) const [zoomAnim, setZoomAnim] = useState(null) + const [minimapZoomAnim, setMinimapZoomAnim] = useState(null) // ── combat state @@ -777,6 +799,11 @@ export default function App() { const [hitShake, setHitShake] = useState(null) const [minimapView, setMinimapView] = useState({ x: 0, y: 0, scale: 1 }) + // ── camera target: which active location to render in the minimap + // null = follow current (player) location, otherwise a background locId + const [viewingLoc, setViewingLoc] = useState(null) + const [hoveredLocName, setHoveredLocName] = useState(null) + // ── ui busy (mirrors ref-based action state for re-renders) const [busy, setBusy] = useState(false) @@ -789,6 +816,9 @@ export default function App() { // ── projectiles (modular projectile framework) const [projectiles, setProjectiles] = useState([]) + // ── blood splats + const [bloodSplats, setBloodSplats] = useState([]) + // ── ground items const [groundItems, setGroundItems] = useState([]) @@ -826,17 +856,19 @@ export default function App() { const [speedMult, setSpeedMult] = useState(1) const playerRef = useRef(null); playerRef.current = player const magazineRef = useRef({ pistol: [{ ammoType: 'pistol_ammo' }, { ammoType: 'pistol_ammo' }] }); magazineRef.current = player.weaponMagazines - const deadThisTickRef = useRef(new Set()) const debugAbilitiesRef = useRef([]) const pocketReturnRef = useRef(null) - const pocketNPCsRef = useRef({}) // { npcId: originalLocation } - const pocketNPCDataRef = useRef({}) // { npcId: snapshotCharacterData } - const pocketDimIdRef = useRef(null) // stable pocket dimension ID + + // ── Multi-location system ── + // Track all known locations and which are actively processing each frame. + // The pocket dimension is a regular entry in the active locations system. + const activeLocationsRef = useRef([]) // [locId, ...] actively processed each frame + const backgroundDataRef = useRef({}) // { [locId]: { blocks, worldSize, groundItems, projectiles } } + const backgroundRefsRef = useRef({}) // { [locId]: { actions: {}, dirs: {}, positions: {}, velocities: {}, deadThisTick: Set } } // ── Universal character action/direction/position refs ── - // Every character (player, enemies) uses the same ref structure. - // The player entity ID is combat.playerEntityId. - // Character IDs: 'player', 'enemy_0', 'enemy_1', ... + // These hold the CURRENT/VISIBLE location's data (player's location). + // Background active locations use backgroundRefsRef instead. const characterActionsRef = useRef({}) // { [charId]: action } const characterDirsRef = useRef({}) // { [charId]: {dx,dy} } const characterPositionsRef = useRef({}) // { [charId]: {x,y} } @@ -861,6 +893,432 @@ export default function App() { const pannedKeysRef = useRef(new Set()) const panRafRef = useRef(null) + // ── Pre-create pocket dimension as a non-active background location ── + const POCKET_DIM_ID = 'pocket_dim' + backgroundDataRef.current[POCKET_DIM_ID] = { + blocks: [{ x: 130, y: 130, w: 60, h: 60 }], + worldSize: 320, + groundItems: [], + projectiles: [], + splats: [], + } + const pocketDimBgRefs = ensureBgRefs(POCKET_DIM_ID) + + // ── Background location helpers ── + // Ensure a background location has its ref namespace initialised. + function ensureBgRefs(locId) { + if (!backgroundRefsRef.current[locId]) backgroundRefsRef.current[locId] = { actions: {}, dirs: {}, positions: {}, velocities: {}, deadThisTick: new Set() } + return backgroundRefsRef.current[locId] + } + + // ── Background hit/damage helpers (mutate backgroundData directly) ── + function applyBackgroundHit(anim, chars, data, refs) { + const attackerId = anim.charId + const targetId = anim.targetCharId + if (!targetId) return + const attackerPos = refs.positions[attackerId] ?? chars[attackerId]?.pos + const targetPos = refs.positions[targetId] ?? chars[targetId]?.pos + if (!attackerPos || !targetPos) return + const inRange = canReach(attackerPos, targetPos, anim.weaponId, anim.skillName) + if (!inRange) return + const target = chars[targetId] + if (!target) return + const decayedTarget = calcIntegrity(target.integrity, target.injuries, ANIM_CFG[anim.type]?.hitAt ?? 0.5) + const prevHp = decayedTarget[anim.targetPart] + const hitTarget = propagateDestruction({ ...decayedTarget, [anim.targetPart]: Math.max(0, prevHp - (anim.dmg ?? 0)) }) + const newInjuries = prevHp > 0 && (anim.severity ?? 0) > 0 + ? applyInjury(target.injuries, anim.targetPart, anim.injType ?? 'blunt', anim.severity ?? 0) + : target.injuries + chars[targetId] = { ...target, integrity: hitTarget, injuries: newInjuries } + if (isDead(hitTarget)) { + refs.deadThisTick.add(targetId) + } + const dx = targetPos.x - attackerPos.x, dy = targetPos.y - attackerPos.y + const d = Math.sqrt(dx * dx + dy * dy) || 1 + refs.velocities[targetId] = { x: (dx / d) * 80, y: (dy / d) * 80 } + if (!data.splats) data.splats = [] + data.splats.push({ x: targetPos.x, y: targetPos.y, rot: Math.random() * 360 }) + } + + function applyBackgroundProjectileHit(proj, charId, chars, data, refs) { + proj.hitFired = true + if (!charId) return + const target = chars[charId] + if (!target || isDead(target.integrity)) return + const skill = weaponMap.pistol?.skills?.[0] + if (!skill) return + const { dmg, severity, injType } = computeHit({ weaponId: 'pistol', skill, weaponConditions: {}, bothMult: 1.0 }) + const part = ALL_PARTS[Math.floor(Math.random() * ALL_PARTS.length)] + const integrity = { ...target.integrity } + const injuries = { ...(target.injuries ?? freshInjuries()) } + integrity[part] = Math.max(0, (integrity[part] ?? 100) - dmg) + Object.assign(integrity, propagateDestruction(integrity)) + if (integrity[part] > 0 && severity > 0) { + injuries[part] = [...(injuries[part] ?? []), { type: injType, severity }] + } + chars[charId] = { ...target, integrity, injuries } + if (isDead(integrity)) { + refs.deadThisTick.add(charId) + } + refs.velocities[charId] = { x: proj.dir.x * 80, y: proj.dir.y * 80 } + if (!data.splats) data.splats = [] + data.splats.push({ x: proj.pos.x, y: proj.pos.y, rot: Math.random() * 360 }) + } + + // Process one frame of ALL game logic for a background (non-visible) location. + // Mirrors the visible location tick but uses backgroundDataRef + backgroundRefsRef. + function tickBackgroundLocation(locId, dt) { + const data = backgroundDataRef.current[locId] + if (!data) return + const refs = ensureBgRefs(locId) + const positions = refs.positions + const velocities = refs.velocities + const dirs = refs.dirs + const actions = refs.actions + const deadThisTick = refs.deadThisTick; deadThisTick.clear() + + // Build character map from global state (characters store their location) + const chars = {} + const isPlayerLoc = locId === playerRef.current?.location + for (const c of characters) { + const override = characterOverridesRef.current[c.id] + const merged = override ? { ...c, ...override } : c + if (merged.location !== locId) continue + const integrity = merged.integrity ?? freshBody() + const pos = positions[c.id] ?? merged.pos ?? { x: 20, y: 75 } + const inj = merged.injuries ? JSON.parse(JSON.stringify(merged.injuries)) : freshInjuries() + chars[c.id] = { ...merged, integrity: { ...integrity }, injuries: inj, pos: { ...pos } } + } + if (isPlayerLoc) { + if (!positions['player']) { + const visPos = characterPositionsRef.current['player'] + if (visPos) positions['player'] = { ...visPos } + else positions['player'] = { x: 160, y: 160 } + } + const pd = playerRef.current + chars['player'] = { + id: 'player', name: pd.name, + pos: { ...positions['player'] }, + integrity: { ...pd.integrity }, + injuries: JSON.parse(JSON.stringify(pd.injuries ?? freshInjuries())), + equipped: { ...pd.equipped }, + weaponConditions: { ...pd.weaponConditions }, + weaponMagazines: { ...pd.weaponMagazines }, + itemCounts: { ...pd.itemCounts }, + relationships: { ...(pd.relationships ?? {}) }, + owner: 'player', fillColor: pd.fillColor, + } + } + + const blk = data.blocks ?? [] + const wSize = data.worldSize ?? 160 + const wMax = wSize - 15 + + // Build fresh snapshot with live positions + const fresh = (() => { + const fc = {} + for (const [cid, c] of Object.entries(chars)) { + const pos = positions[cid] ?? c.pos + fc[cid] = { ...c, pos: { ...pos } } + } + return { blocks: blk, worldSize: wSize, characters: fc } + })() + + // Tick all character actions + for (const cid of Object.keys(chars)) { + if (isDead(chars[cid]?.integrity) || isUnconscious(chars[cid]?.integrity) || deadThisTick.has(cid)) { + if (actions[cid]) actions[cid] = null + continue + } + const rawAnim = actions[cid] + if (!rawAnim) continue + + const done = rawAnim.step(dt) + if (ANIM_CFG[rawAnim.type] && rawAnim.elapsed >= ANIM_CFG[rawAnim.type].hitAt && !rawAnim.hitFired) { + rawAnim.hitFired = true + if (rawAnim.type === 'shoot') { + const pp = positions[cid] ?? chars[cid]?.pos + const dir = dirs[cid] ?? { dx: 0, dy: 1 } + const offset = 5 + const spawnPos = { x: pp.x + dir.dx * offset, y: pp.y + dir.dy * offset } + const actor = cid === 'player' ? 'player' : 'party' + const charMag = chars[cid]?.weaponMagazines ?? {} + if (charMag.pistol?.length > 0) { + const bullet = charMag.pistol[0] + const newMag = { pistol: charMag.pistol.slice(1) } + chars[cid] = { ...chars[cid], weaponMagazines: { ...(chars[cid]?.weaponMagazines ?? {}), ...newMag } } + const ddx = rawAnim.endPos.x - spawnPos.x + const ddy = rawAnim.endPos.y - spawnPos.y + const d = Math.sqrt(ddx * ddx + ddy * ddy) || 1 + const dir2 = { x: ddx / d, y: ddy / d } + const facing = dirs[cid] ?? { dx: 0, dy: 1 } + const dot = Math.max(-1, Math.min(1, facing.dx * dir2.x + facing.dy * dir2.y)) + const maxDeviation = (1 - dot) / 2 * (Math.PI / 4) + const deviation = (Math.random() * 2 - 1) * maxDeviation + const cos = Math.cos(deviation); const sin = Math.sin(deviation) + const spreadDir = { x: dir2.x * cos - dir2.y * sin, y: dir2.x * sin + dir2.y * cos } + const finalDir = { x: isNaN(spreadDir.x) ? dir2.x : spreadDir.x, y: isNaN(spreadDir.y) ? dir2.y : spreadDir.y } + const ammoType = bullet.ammoType + if (!refs.projectiles) refs.projectiles = [] + refs.projectiles.push(createProjectile(spawnPos, { x: spawnPos.x + finalDir.x * d, y: spawnPos.y + finalDir.y * d }, actor, 'pistol', 'Shoot', ammoType, cid)) + } + } else { + applyBackgroundHit(rawAnim, chars, data, refs) + } + } + if (done) { + if (rawAnim.type === 'move' && cid !== 'player') { + const char = chars[cid] + if (char && char.owner !== 'player') { + const e = fresh.characters[cid] ?? char + const charPos = positions[cid] ?? e?.pos + if (e && !isDead(e.integrity) && !isUnconscious(e.integrity)) { + let targetId = null, minDist = Infinity, targetPos = null + if (e.relationships) { + for (const [pid, rel] of Object.entries(e.relationships)) { + if (rel !== 'enemy') continue + const pch = fresh.characters[pid] + if (!pch || isDead(pch.integrity) || deadThisTick.has(pid)) continue + const d = Math.sqrt((pch.pos.x - charPos.x) ** 2 + (pch.pos.y - charPos.y) ** 2) + if (d < minDist) { minDist = d; targetId = pid; targetPos = pch.pos } + } + } + if (targetId) { + const targetChar = fresh.characters[targetId] + const wpn = e.weapon ?? 'fists' + const base = weaponMap[wpn] + const skills = base?.skills ?? [{ name: 'Attack', mult: 1.0, damageType: 'blunt', range: 15 }] + const maxRange = Math.max(...skills.map(s => s.range ?? 15)) + if (minDist <= maxRange) { + const skill = skills[Math.floor(Math.random() * skills.length)] + const { dmg, severity, injType, injLabel } = computeHit({ weaponId: wpn, skill, weaponConditions: {} }) + const targetPart = randomPart() + const action = { + type: 'attack', actor: 'enemy', charId: cid, + targetCharId: targetId, targetPart, + weaponId: wpn, skillName: skill.name, + dmg, severity, injType, injLabel, + attackerParts: { ...e.integrity }, + attackerInjuries: { ...e.injuries }, + targetParts: { ...targetChar.integrity }, + targetInjuries: { ...targetChar.injuries }, + } + actions[cid] = createWeaponStrikeAnim(action, fresh, velocities) + continue + } + if (targetPos) { + const dx = targetPos.x - charPos.x; const dy = targetPos.y - charPos.y + const d = Math.sqrt(dx * dx + dy * dy) + const dir = d > 0 ? { dx: dx / d, dy: dy / d } : { dx: 0, dy: 1 } + dirs[cid] = dir + if (positions[cid]) { + velocities[cid] = { + x: (dx / d) * (chars[cid]?.maxSpeed ?? 1) * 60 * (getMovement(chars[cid]?.integrity) / 100 || 1), + y: (dy / d) * (chars[cid]?.maxSpeed ?? 1) * 60 * (getMovement(chars[cid]?.integrity) / 100 || 1), + } + } + actions[cid] = null + continue + } + } + } + } + } + actions[cid] = null + } + } + + // Sync velocities written by MoveAction.step (visible refs) → bg refs for physics step + // Only for characters whose action was a MoveAction (player-directed movement) + for (const [cid, action] of Object.entries(actions)) { + if (action instanceof MoveAction) { + const visVel = characterVelocitiesRef.current[cid] + if (visVel) velocities[cid] = { ...visVel } + } + } + + // Physics step (using background refs) + const charIds = Object.keys(positions) + for (let iter = 0; iter < 2; iter++) { + for (const cid of charIds) { + const pos = positions[cid] + if (!pos) continue + const vel = velocities[cid] + if (!vel) continue + const ch = chars[cid] + if (ch && isUnconscious(ch.integrity)) { + if (vel.x !== 0 || vel.y !== 0) { vel.x = 0; vel.y = 0 } + continue + } + let nx = pos.x, ny = pos.y + if (vel.x !== 0 || vel.y !== 0) { + const travel = Math.max(Math.abs(vel.x * dt), Math.abs(vel.y * dt)) + const subSteps = Math.max(1, Math.ceil(travel / 2)) + const subDt = dt / subSteps + for (let i = 0; i < subSteps; i++) { + let sx = nx + vel.x * subDt; let sy = ny + vel.y * subDt + const hitBlock = blk.find(b => rectCollides(sx, sy, b)) + if (hitBlock) { + const leftPen = sx - hitBlock.x; const rightPen = (hitBlock.x + hitBlock.w) - sx + const topPen = sy - hitBlock.y; const bottomPen = (hitBlock.y + hitBlock.h) - sy + if (Math.min(leftPen, rightPen) < Math.min(topPen, bottomPen)) { vel.x = 0; sx = leftPen < rightPen ? hitBlock.x : hitBlock.x + hitBlock.w } + else { vel.y = 0; sy = topPen < bottomPen ? hitBlock.y : hitBlock.y + hitBlock.h } + } + nx = Math.max(5, Math.min(wMax, sx)); ny = Math.max(5, Math.min(wMax, sy)) + } + vel.x /= (1 + FRICTION * dt); vel.y /= (1 + FRICTION * dt) + if (Math.abs(vel.x) < 0.01) vel.x = 0 + if (Math.abs(vel.y) < 0.01) vel.y = 0 + } + for (const b of blk) { + if (!rectCollides(nx, ny, b)) continue + const leftPen = nx - b.x; const rightPen = (b.x + b.w) - nx + const topPen = ny - b.y; const bottomPen = (b.y + b.h) - ny + if (Math.min(leftPen, rightPen) < Math.min(topPen, bottomPen)) { nx = leftPen < rightPen ? b.x : b.x + b.w } + else { ny = topPen < bottomPen ? b.y : b.y + b.h } + } + for (const oid of charIds) { + if (oid === cid) continue + const op = positions[oid]; if (!op) continue + const dx = nx - op.x; const dy = ny - op.y; const dist = Math.sqrt(dx * dx + dy * dy) + if (dist < 10 && dist > 0.001) { + const push = (10 - dist) * 0.5; const px2 = (dx / dist) * push; const py2 = (dy / dist) * push + nx += px2; ny += py2; op.x -= px2; op.y -= py2 + } + } + nx = Math.max(5, Math.min(wMax, nx)); ny = Math.max(5, Math.min(wMax, ny)) + positions[cid] = { x: nx, y: ny } + } + } + + // Run enemy AI on background characters + if (timerActiveRef.current) { + const aiActions = runEnemyAI(fresh, {}, {}, deadThisTick) + for (const act of aiActions) { + const actor = fresh.characters[act.charId] + if (!actor || isDead(actor.integrity) || isUnconscious(actor.integrity)) continue + if (act.type === 'attack') { + const target = fresh.characters[act.targetCharId] + if (!target || isDead(target.integrity)) continue + } + const existing = actions[act.charId] + const isMove = existing?.type === 'move' + if (act.type === 'attack' && (!existing || isMove)) { + actions[act.charId] = createWeaponStrikeAnim(act, fresh, velocities) + } else if (act.type === 'move') { + const dx = act.toX - act.fromX; const dy = act.toY - act.fromY + const d = Math.sqrt(dx * dx + dy * dy) + if (d > 0) { + dirs[act.charId] = { dx: dx / d, dy: dy / d } + if (positions[act.charId]) { + velocities[act.charId] = { + x: (dx / d) * (chars[act.charId]?.maxSpeed ?? 1) * 60 * (getMovement(chars[act.charId]?.integrity) / 100 || 1), + y: (dy / d) * (chars[act.charId]?.maxSpeed ?? 1) * 60 * (getMovement(chars[act.charId]?.integrity) / 100 || 1), + } + } + } + actions[act.charId] = null + } + } + } + + // Step projectiles for background location + if (refs.projectiles && refs.projectiles.length > 0) { + const remaining = [] + for (const p of refs.projectiles) { + if (p.hitFired) continue + const cfg = PROJ_CFG[p.type] + const stepDist = cfg.speed * dt + const remainingDist = p.dist - p.distTraveled + const moveDist = Math.min(stepDist, remainingDist) + const subSteps = Math.max(1, Math.ceil(moveDist)) + let hit = false + let hitCx = null, hitCy = null + for (let s = 1; s <= subSteps; s++) { + const t = s / subSteps + const cx = p.pos.x + p.dir.x * moveDist * t + const cy = p.pos.y + p.dir.y * moveDist * t + const curBlocks = blk + const hitBlock = curBlocks.find(b => rectCollides(cx, cy, b)) + if (hitBlock) { + if (p.ammoType === 'bouncing_ammo') { + const dl = cx - hitBlock.x; const dr = (hitBlock.x + hitBlock.w) - cx + const dt2 = cy - hitBlock.y; const db = (hitBlock.y + hitBlock.h) - cy + if (Math.min(dl, dr) < Math.min(dt2, db)) { p.dir.x = -p.dir.x } else { p.dir.y = -p.dir.y } + p.pos.x = cx - p.dir.x * moveDist * (1 - t) + p.pos.y = cy - p.dir.y * moveDist * (1 - t) + p.distTraveled = 0 + p.dist -= moveDist * t + break + } + applyBackgroundProjectileHit(p, null, chars, data, refs) + hit = true; hitCx = cx; hitCy = cy; break + } + for (const eid of Object.keys(chars)) { + if (eid === p.charId) continue + const e = chars[eid] + if (!e || isDead(e.integrity)) continue + const ePos = positions[eid] ?? e.pos + const r = cfg.hitRadius + 4 * (e.size ?? 1) + if (Math.sqrt((cx - ePos.x) ** 2 + (cy - ePos.y) ** 2) < r) { + applyBackgroundProjectileHit(p, eid, chars, data, refs) + hit = true; hitCx = cx; hitCy = cy; break + } + } + if (hit) break + } + if (hit) { + if (hitCx != null) { p.pos.x = hitCx; p.pos.y = hitCy } + remaining.push(p) + continue + } + if (moveDist < stepDist) continue + p.distTraveled += moveDist + p.pos.x += p.dir.x * moveDist + p.pos.y += p.dir.y * moveDist + remaining.push(p) + } + refs.projectiles = remaining + } + + // Sync local character mutations back to the ref (persistence across renders) + for (const [cid, ch] of Object.entries(chars)) { + if (cid === 'player') continue + const refEntry = characterOverridesRef.current[cid] ?? {} + if (ch.integrity) refEntry.integrity = { ...ch.integrity } + if (ch.injuries) refEntry.injuries = JSON.parse(JSON.stringify(ch.injuries)) + if (ch.weaponMagazines) refEntry.weaponMagazines = { ...ch.weaponMagazines } + if (ch.itemCounts) refEntry.itemCounts = { ...ch.itemCounts } + characterOverridesRef.current[cid] = refEntry + } + } + + // Helper: activate a background location (add to active set, store its data). + function activateBgLocation(locId, data, charIds) { + if (!locId) return + if (!activeLocationsRef.current.includes(locId)) { + activeLocationsRef.current = [...activeLocationsRef.current, locId] + } + backgroundDataRef.current[locId] = { blocks: data.blocks, worldSize: data.worldSize, groundItems: data.groundItems ?? [], projectiles: data.projectiles ?? [], splats: data.splats ?? [] } + const bg = ensureBgRefs(locId) + for (const cid of charIds) { + if (cid === 'player') continue + const ch = data.characters[cid] + if (ch?.pos) bg.positions[cid] = { ...ch.pos } + bg.velocities[cid] = { x: 0, y: 0 } + bg.dirs[cid] = { dx: 0, dy: 1 } + bg.actions[cid] = null + } + } + + // Helper: deactivate a background location + function deactivateBgLocation(locId) { + if (!locId) return + activeLocationsRef.current = activeLocationsRef.current.filter(id => id !== locId) + delete backgroundDataRef.current[locId] + delete backgroundRefsRef.current[locId] + } + // ───────────────────────────────────────────────────────────────────────── // DERIVED // ───────────────────────────────────────────────────────────────────────── @@ -951,91 +1409,12 @@ export default function App() { } } - /** - * Step all active projectiles. Returns the filtered array of still-active projectiles. - * Applies hit damage / logging as side effects. - */ - function stepProjectiles(projs, dt, cc) { - if (!cc) return [] - const out = [] - for (const p of projs) { - if (p.hitFired) continue - - const cfg = PROJ_CFG[p.type] - const stepDist = cfg.speed * dt - const remaining = p.dist - p.distTraveled - const moveDist = Math.min(stepDist, remaining) - - const subSteps = Math.max(1, Math.ceil(moveDist)) - let hit = false - let hitCx = null, hitCy = null - - for (let s = 1; s <= subSteps; s++) { - const t = s / subSteps - const cx = p.pos.x + p.dir.x * moveDist * t - const cy = p.pos.y + p.dir.y * moveDist * t - - const curBlocks = cc.blocks ?? [] - const hitBlock = curBlocks.find(b => rectCollides(cx, cy, b)) - if (hitBlock) { - if (p.ammoType === 'bouncing_ammo') { - const dl = cx - hitBlock.x - const dr = (hitBlock.x + hitBlock.w) - cx - const dt = cy - hitBlock.y - const db = (hitBlock.y + hitBlock.h) - cy - if (Math.min(dl, dr) < Math.min(dt, db)) { - p.dir.x = -p.dir.x - } else { - p.dir.y = -p.dir.y - } - p.pos.x = cx - p.dir.x * moveDist * (1 - t) - p.pos.y = cy - p.dir.y * moveDist * (1 - t) - p.distTraveled = 0 - p.dist -= moveDist * t - break - } - applyProjectileHit(p, null) - hit = true; hitCx = cx; hitCy = cy; break - } - - for (const cid of Object.keys(cc.characters)) { - if (cid === p.charId) continue - const e = cc.characters[cid] - if (!e || isDead(e.integrity)) continue - const ePos = characterPositionsRef.current[cid] ?? e.pos - const r = cfg.hitRadius + 4 * (e.size ?? 1) - if (Math.sqrt((cx - ePos.x) ** 2 + (cy - ePos.y) ** 2) < r) { - applyProjectileHit(p, cid) - hit = true; hitCx = cx; hitCy = cy; break - } - } - if (hit) break - } - if (hit) { - if (hitCx != null) { p.pos.x = hitCx; p.pos.y = hitCy } - out.push(p) - continue - } - - if (moveDist < stepDist) continue - - p.distTraveled += moveDist - p.pos.x += p.dir.x * moveDist - p.pos.y += p.dir.y * moveDist - out.push(p) - } - return out - } - - /** - * Factory: create a weapon animation based on the skill's damage type. - * slash → arcing motion, pierce → straight thrust, blunt → straight strike. - */ - function createWeaponStrikeAnim(action, cc) { + function createWeaponStrikeAnim(action, cc, velRef) { if (!cc) return null const attackerId = action.charId - if (characterVelocitiesRef.current[attackerId]) { - characterVelocitiesRef.current[attackerId] = { x: 0, y: 0 } + const targetVel = velRef ?? characterVelocitiesRef.current + if (targetVel[attackerId]) { + targetVel[attackerId] = { x: 0, y: 0 } } const isPlayerSide = action.actor === 'player' || action.actor === 'party' const targetId = action.targetCharId ?? (isPlayerSide ? action.enemyCharId : ( @@ -1201,64 +1580,6 @@ export default function App() { } } - /** - * Apply damage / log when a projectile hits something. - */ - function applyProjectileHit(proj, charId) { - proj.hitFired = true - if (!charId) { - setCombat(c => ({ ...c, log: [...c.log, 'The bullet hits a wall!'] })) - return - } - - const cc = combatRef.current - const target = cc?.characters[charId] - if (!target || isDead(target.integrity)) return - - const skill = weaponMap.pistol?.skills?.[0] - if (!skill) return - - const shooterChar = cc?.characters[proj.charId] - const shooterConditions = proj.charId === 'player' ? playerRef.current.weaponConditions : (shooterChar?.weaponConditions ?? {}) - const shooterName = proj.charId === 'player' ? 'You' : (shooterChar?.name ?? 'Someone') - - const { dmg, severity, injType, injLabel } = computeHit({ - weaponId: 'pistol', - skill, - weaponConditions: shooterConditions, - bothMult: 1.0, - }) - - const part = ALL_PARTS[Math.floor(Math.random() * ALL_PARTS.length)] - - setCombat(c => { - const characters = { ...c.characters } - const e = { ...characters[charId] } - const integrity = { ...e.integrity } - const injuries = { ...(e.injuries ?? freshInjuries()) } - - integrity[part] = Math.max(0, (integrity[part] ?? 100) - dmg) - Object.assign(integrity, propagateDestruction(integrity)) - if (integrity[part] > 0 && severity > 0) { - injuries[part] = [...(injuries[part] ?? []), { type: injType, severity }] - } - e.integrity = integrity - e.injuries = injuries - characters[charId] = e - - const log = dmg > 0 - ? [`${shooterName} shoots ${e.name} in the ${bodyPartLabels[part] ?? part} for ${dmg} damage! ${injLabel} (severity ${severity}).`] - : [`${shooterName}'s shot glances off harmlessly.`] - if (isDead(integrity)) { - if (part === 'head') log.push(`${shooterName} blows ${e.name}'s head off!`) - log.push(`${e.name} is defeated!`) - deadThisTickRef.current.add(charId) - } - return { ...c, characters, log: [...c.log, ...log] } - }) - setHitShake({ actor: 'enemy', charId, start: performance.now() }) - } - /** Queue a shot toward a map coordinate — queues a shoot action. */ const queueShot = (tx, ty) => { if (!combat) return @@ -1419,263 +1740,6 @@ class MoveAction { } } - function applyAnimHit(anim) { - const liveCc = combatRef.current - const attackerId = anim.charId - const isPlayerSide = anim.actor === 'player' || anim.actor === 'party' - const targetId = anim.targetCharId ?? (isPlayerSide ? anim.enemyCharId : ( - liveCc ? Object.keys(liveCc.characters).find(cid => { - return liveCc.characters[cid]?.owner === 'player' - }) ?? liveCc.playerEntityId : liveCc?.playerEntityId - )) - - let inRange = false - if (liveCc) { - // Primary: live positions from characterPositionsRef (updated by move actions) - const attackerPos = characterPositionsRef.current[attackerId] ?? liveCc.characters[attackerId]?.pos - const targetPos = characterPositionsRef.current[targetId] ?? liveCc.characters[targetId]?.pos - if (attackerPos && targetPos) { - inRange = canReach(attackerPos, targetPos, anim.weaponId, anim.skillName) - } - // Fallback: rendered combatRef positions - if (!inRange) { - const a2 = liveCc.characters[attackerId]?.pos - const t2 = liveCc.characters[targetId]?.pos - if (a2 && t2) { - inRange = canReach(a2, t2, anim.weaponId, anim.skillName) - } - } - } - - if (!inRange) { - const aPos = liveCc?.characters[attackerId]?.pos - const tPos = liveCc?.characters[targetId]?.pos - const aPos2 = characterPositionsRef.current[attackerId] - const tPos2 = characterPositionsRef.current[targetId] - const dist = aPos && tPos ? Math.sqrt((aPos.x - tPos.x)**2 + (aPos.y - tPos.y)**2) : -1 - const dist2 = aPos2 && tPos2 ? Math.sqrt((aPos2.x - tPos2.x)**2 + (aPos2.y - tPos2.y)**2) : -1 - const range = getSkillRange(anim.weaponId, anim.skillName) - const attackerName = liveCc?.characters[anim.charId]?.name ?? (anim.actor === 'player' ? 'You' : 'Enemy') - const msg = isPlayerSide - ? `Your attack misses! (distance combatRef=${dist.toFixed(1)} refs=${dist2.toFixed(1)} range=${range})` - : `${attackerName}'s attack misses!` - setCombat(prev => ({ ...prev, log: [...prev.log, msg] })) - return - } - - setHitShake({ actor: anim.actor, charId: anim.charId, start: performance.now() }) - - if (isPlayerSide) { - const wid = anim.weaponId - if (wid !== 'fists' && wid !== 'left_arm' && wid !== 'right_arm') { - const base = weaponMap[wid] - if (anim.actor === 'player') { - setPlayer(p => ({ ...p, weaponConditions: { ...p.weaponConditions, [wid]: Math.max(0, (p.weaponConditions[wid] ?? base.maxCondition) - (1 + Math.floor(Math.random() * 2))) } })) - } else { - setCombat(prev => { - const ch = prev.characters[anim.charId] - if (!ch) return prev - return { ...prev, characters: { ...prev.characters, [anim.charId]: { ...ch, weaponConditions: { ...ch.weaponConditions, [wid]: Math.max(0, (ch.weaponConditions[wid] ?? base.maxCondition) - (1 + Math.floor(Math.random() * 2))) } } } } - }) - } - } - - if (anim.actor === 'player') { - const decayedPlayer = calcIntegrity(playerRef.current.integrity, playerRef.current.injuries, ANIM_CFG[anim.type].hitAt) - setPlayer(p => ({ ...p, integrity: decayedPlayer })) - setPlayer(p => ({ ...p, injuries: playerRef.current.injuries })) - } else { - setCombat(prev => { - const ch = prev.characters[anim.charId] - if (!ch) return prev - const decayed = calcIntegrity(ch.integrity, ch.injuries, ANIM_CFG[anim.type].hitAt) - return { ...prev, characters: { ...prev.characters, [anim.charId]: { ...ch, integrity: decayed } } } - }) - } - - if (liveCc && anim.enemyCharId) { - const enemy = liveCc.characters[anim.enemyCharId] - if (enemy) { - const decayedEnemy = calcIntegrity(enemy.integrity, enemy.injuries, ANIM_CFG[anim.type].hitAt) - const prevHp = decayedEnemy[anim.targetPart] - const hitEnemy = propagateDestruction({ ...decayedEnemy, [anim.targetPart]: Math.max(0, prevHp - anim.dmg) }) - const newInjuries = prevHp > 0 && anim.severity > 0 - ? applyInjury(enemy.injuries, anim.targetPart, anim.injType, anim.severity) - : enemy.injuries - const attackerName = liveCc.characters[anim.charId]?.name ?? 'You' - - const log = anim.dmg > 0 - ? [`${attackerName} ${anim.skillName?.toLowerCase() ?? 'attack'}s ${enemy.name}'s ${bodyPartLabels[anim.targetPart]} — ${anim.injLabel} (severity ${anim.severity}, -${anim.dmg} integrity).`] - : [] - if (isDead(hitEnemy)) { - if (anim.targetPart === 'head') log.push(`${attackerName} crushes ${enemy.name}'s head!`) - log.push(`${enemy.name} is defeated!`) - deadThisTickRef.current.add(anim.enemyCharId) - } - - setCombat(prev => ({ - ...prev, - characters: { ...prev.characters, [anim.enemyCharId]: { ...enemy, integrity: hitEnemy, injuries: newInjuries } }, - log: [...prev.log, ...log], - })) - } - } - } else { - const currentEnemy = liveCc?.characters[anim.charId] - const decayedEnemy = currentEnemy - ? calcIntegrity(currentEnemy.integrity, currentEnemy.injuries, ANIM_CFG[anim.type].hitAt) - : calcIntegrity(anim.attackerParts, anim.attackerInjuries, ANIM_CFG[anim.type].hitAt) - - // Find target party member - const targetCharId = anim.targetCharId ?? ( - combat ? Object.keys(combat.characters).find(cid => { - return combat.characters[cid]?.owner === 'player' - }) ?? 'player' : 'player' - ) - const targetChar = liveCc?.characters[targetCharId] - const targetIntegrity = targetChar?.integrity ?? playerRef.current.integrity - const targetInjuries = targetChar?.injuries ?? playerRef.current.injuries - - const decayedTarget = calcIntegrity(targetIntegrity, targetInjuries, ANIM_CFG[anim.type].hitAt) - const prevHp = decayedTarget[anim.targetPart] - const hitTarget = propagateDestruction({ ...decayedTarget, [anim.targetPart]: Math.max(0, prevHp - anim.dmg) }) - const newInjuries = prevHp > 0 && anim.severity > 0 - ? applyInjury(targetInjuries, anim.targetPart, anim.injType, anim.severity) - : targetInjuries - - if (liveCc) { - const enemy = liveCc.characters[anim.charId] - const enemyName = enemy?.name ?? 'Enemy' - - setCombat(prev => ({ - ...prev, - characters: { - ...prev.characters, - [anim.charId]: { ...enemy, integrity: decayedEnemy }, - [targetCharId]: { ...prev.characters[targetCharId], integrity: hitTarget, injuries: newInjuries }, - }, - log: anim.dmg > 0 - ? [...prev.log, `${enemyName} lands a ${anim.injLabel} on ${targetChar?.name ?? 'your'} ${bodyPartLabels[anim.targetPart]} (severity ${anim.severity}, integrity -${anim.dmg}).`] - : prev.log, - })) - } - - if (isDead(hitTarget)) { - deadThisTickRef.current.add(targetCharId) - } - if (targetCharId === 'player') { - setPlayer(p => ({ ...p, integrity: hitTarget, injuries: newInjuries })) - } - } - } - - // ───────────────────────────────────────────────────────────────────────── - // PHYSICS — integrates velocities, resolves collisions, applies friction - // ───────────────────────────────────────────────────────────────────────── - - function stepPhysics(dt, blocks) { - const blk = blocks ?? [] - const charIds = Object.keys(characterPositionsRef.current) - const updates = {} - - // Multiple iterations for stable constraint resolution - for (let iter = 0; iter < 2; iter++) { - for (const cid of charIds) { - const pos = characterPositionsRef.current[cid] - if (!pos) continue - const vel = characterVelocitiesRef.current[cid] - if (!vel) continue - - // Unconscious characters cannot move - const cc = combatRef.current - const ch = cc?.characters[cid] - if (ch && isUnconscious(ch.integrity)) { - if (vel.x !== 0 || vel.y !== 0) { vel.x = 0; vel.y = 0 } - continue - } - - let nx = pos.x, ny = pos.y - - // 1. Velocity integration (sub-stepped for collision safety) - if (vel.x !== 0 || vel.y !== 0) { - const travel = Math.max(Math.abs(vel.x * dt), Math.abs(vel.y * dt)) - const subSteps = Math.max(1, Math.ceil(travel / 2)) - const subDt = dt / subSteps - for (let i = 0; i < subSteps; i++) { - let sx = nx + vel.x * subDt - let sy = ny + vel.y * subDt - const hitBlock = blk.find(b => rectCollides(sx, sy, b)) - if (hitBlock) { - const leftPen = sx - hitBlock.x - const rightPen = (hitBlock.x + hitBlock.w) - sx - const topPen = sy - hitBlock.y - const bottomPen= (hitBlock.y + hitBlock.h) - sy - const minPenX = Math.min(leftPen, rightPen) - const minPenY = Math.min(topPen, bottomPen) - if (minPenX < minPenY) { vel.x = 0; sx = leftPen < rightPen ? hitBlock.x : hitBlock.x + hitBlock.w } - else { vel.y = 0; sy = topPen < bottomPen ? hitBlock.y : hitBlock.y + hitBlock.h } - } - nx = Math.max(5, Math.min(145, sx)) - ny = Math.max(5, Math.min(145, sy)) - } - vel.x /= (1 + FRICTION * dt) - vel.y /= (1 + FRICTION * dt) - if (Math.abs(vel.x) < 0.01) vel.x = 0 - if (Math.abs(vel.y) < 0.01) vel.y = 0 - } - - // 2. Block containment — push toward nearest edge if inside a block - for (const b of blk) { - if (!rectCollides(nx, ny, b)) continue - const leftPen = nx - b.x - const rightPen = (b.x + b.w) - nx - const topPen = ny - b.y - const bottomPen= (b.y + b.h) - ny - const minPenX = Math.min(leftPen, rightPen) - const minPenY = Math.min(topPen, bottomPen) - if (minPenX < minPenY) { nx = leftPen < rightPen ? b.x : b.x + b.w } - else { ny = topPen < bottomPen ? b.y : b.y + b.h } - } - - // 3. Character-character proximity — push apart when too close - for (const otherCid of charIds) { - if (otherCid === cid) continue - const otherPos = characterPositionsRef.current[otherCid] - if (!otherPos) continue - const dx = nx - otherPos.x - const dy = ny - otherPos.y - const dist = Math.sqrt(dx * dx + dy * dy) - if (dist < 10 && dist > 0.001) { - const push = (10 - dist) * 0.5 - const pushX = (dx / dist) * push - const pushY = (dy / dist) * push - nx += pushX; ny += pushY - otherPos.x -= pushX; otherPos.y -= pushY - updates[otherCid] = { x: otherPos.x, y: otherPos.y } - } - } - - // 4. Clamp to world bounds - nx = Math.max(5, Math.min(145, nx)) - ny = Math.max(5, Math.min(145, ny)) - pos.x = nx; pos.y = ny - updates[cid] = { x: nx, y: ny } - } - } - - const keys = Object.keys(updates) - if (keys.length > 0) { - setCombat(c => { - if (!c) return c - const chars = { ...c.characters } - for (const cid of keys) { - if (chars[cid]) chars[cid] = { ...chars[cid], pos: { ...updates[cid] } } - } - return { ...c, characters: chars } - }) - } - } - // ───────────────────────────────────────────────────────────────────────── // TIMER — ticks while timerActive is true, updates all character actions // ───────────────────────────────────────────────────────────────────────── @@ -1734,21 +1798,19 @@ class MoveAction { const action = characterActionsRef.current[cid] return action != null && !action.cancellable }) - const anyQueued = partyCids.length > 0 && partyCids.every(cid => { - const ch = cc.characters[cid] - if (isDead(ch?.integrity)) return true - return characterActionsRef.current[cid] != null - }) const wasBusy = prevBusyRef.current prevBusyRef.current = allBusy setBusy(allBusy) - if (anyQueued && !timerActiveRef.current && partyCids.some(cid => !isDead(cc.characters[cid]?.integrity))) { + // Auto-start timer when player has a queued action; auto-stop when idle + const playerCid = cc?.playerEntityId ?? 'player' + const playerHasAction = characterActionsRef.current[playerCid] != null + if (playerHasAction && !timerActiveRef.current) { timerActiveRef.current = true manualTimerRef.current = false setTimerRunning(true) } - if (!anyQueued && timerActiveRef.current && !manualTimerRef.current) { + if (!playerHasAction && timerActiveRef.current && !manualTimerRef.current) { timerActiveRef.current = false setTimerRunning(false) } @@ -1787,8 +1849,6 @@ class MoveAction { const dt = rawDt * speedRef.current lastTime = now - deadThisTickRef.current = new Set() - setCombatTime(t => t + dt) setDisplayTime(t => t + dt) setGameTime(gt => { @@ -1801,200 +1861,102 @@ class MoveAction { return { day: d, hour: h, minute } }) - // ── Build fresh combat snapshot with live positions ── + // Bridge player-queued actions and velocities into background refs before the unified tick + const curLoc = playerRef.current.location + const playerBgRefs = backgroundRefsRef.current[curLoc] + if (playerBgRefs) { + for (const [cid, action] of Object.entries(characterActionsRef.current)) { + if (action) playerBgRefs.actions[cid] = action + } + for (const [cid, vel] of Object.entries(characterVelocitiesRef.current)) { + if (vel) playerBgRefs.velocities[cid] = { ...vel } + } + } + + // ── Tick ALL active locations uniformly (player's location included) ── + for (const locId of activeLocationsRef.current) { + tickBackgroundLocation(locId, dt) + } + + // ── Sync visible combat state from player's background location ── + if (playerBgRefs) { + // Build character map from global state (includes dead chars for rendering) + const bgChars = {} + for (const c of characters) { + const override = characterOverridesRef.current[c.id] + const merged = override ? { ...c, ...override } : c + if (merged.location === curLoc) { + const integrity = merged.integrity ?? freshBody() + const pos = playerBgRefs.positions[c.id] ?? merged.pos ?? { x: 20, y: 75 } + const inj = merged.injuries ? JSON.parse(JSON.stringify(merged.injuries)) : freshInjuries() + bgChars[c.id] = { ...merged, integrity: { ...integrity }, injuries: inj, pos: { ...pos } } + } + } + const allCids = new Set([ + ...Object.keys(bgChars), + ...Object.keys(playerBgRefs.positions), + ]) + // Copy bgRefs back into visible refs so the rest of the UI works + for (const cid of allCids) { + characterPositionsRef.current[cid] = playerBgRefs.positions[cid] + ? { ...playerBgRefs.positions[cid] } + : { ...bgChars[cid]?.pos } + characterVelocitiesRef.current[cid] = playerBgRefs.velocities[cid] + ? { ...playerBgRefs.velocities[cid] } + : { x: 0, y: 0 } + characterDirsRef.current[cid] = playerBgRefs.dirs[cid] + ? { ...playerBgRefs.dirs[cid] } + : { dx: 0, dy: 1 } + characterActionsRef.current[cid] = playerBgRefs.actions[cid] ?? null + } + // Sync combat state from global character state + background refs + setCombat(c => { + if (!c) return c + const chars = {} + for (const [cid, ch] of Object.entries(bgChars)) { + chars[cid] = { + ...ch, + pos: playerBgRefs.positions[cid] + ? { ...playerBgRefs.positions[cid] } + : { ...ch.pos }, + } + } + // Preserve player character (not stored in bgChars) + const playerEntry = c.characters['player'] + if (playerEntry) { + chars['player'] = { + ...playerEntry, + pos: characterPositionsRef.current['player'] + ? { ...characterPositionsRef.current['player'] } + : { ...playerEntry.pos }, + } + } + return { ...c, characters: chars } + }) + // Sync projectiles from background + setProjectiles([...(playerBgRefs.projectiles ?? [])]) + // Sync blood splats from background + setBloodSplats([...(backgroundDataRef.current[curLoc]?.splats ?? [])]) + // Persist background tick mutations back to React character overrides + setCharacterOverrides(prev => { + const next = { ...prev } + for (const [cid, ch] of Object.entries(bgChars)) { + if (cid === 'player') continue + next[cid] = { ...(next[cid] ?? {}), integrity: { ...ch.integrity }, injuries: ch.injuries ? JSON.parse(JSON.stringify(ch.injuries)) : undefined } + } + return next + }) + } + + // ── Collect active animations for rendering ── const activeAnims = [] - const cc = combatRef.current - const playerCharId = cc?.playerEntityId - const fresh = cc ? (() => { - const chars = {} - for (const cid of Object.keys(cc.characters)) { - const c = cc.characters[cid] - const pos = characterPositionsRef.current[cid] ?? c.pos - chars[cid] = { ...c, pos: { ...pos } } - } - return { ...cc, characters: chars } - })() : null - - // ── Tick ALL character actions uniformly ── - if (cc) { - for (const cid of Object.keys(cc.characters)) { - // Skip dead or unconscious characters — clear their action and move on - if (isDead(cc.characters[cid]?.integrity) || isUnconscious(cc.characters[cid]?.integrity) || deadThisTickRef.current.has(cid)) { - if (characterActionsRef.current[cid]) { - characterActionsRef.current[cid] = null - } - continue - } - const rawAnim = characterActionsRef.current[cid] - if (!rawAnim) continue - - const done = rawAnim.step(dt) - if (ANIM_CFG[rawAnim.type] && rawAnim.elapsed >= ANIM_CFG[rawAnim.type].hitAt && !rawAnim.hitFired) { - rawAnim.hitFired = true - if (rawAnim.type === 'shoot') { - const pp = characterPositionsRef.current[cid] ?? cc.characters[cid]?.pos - const dir = characterDirsRef.current[cid] ?? { dx: 0, dy: 1 } - const offset = 5 - const spawnPos = { x: pp.x + dir.dx * offset, y: pp.y + dir.dy * offset } - const actor = cid === 'player' ? 'player' : 'party' - const charMag = cid === 'player' ? magazineRef.current : (cc.characters[cid]?.weaponMagazines ?? {}) - if (charMag.pistol?.length > 0) { - const bullet = charMag.pistol[0] - const newMag = { pistol: charMag.pistol.slice(1) } - if (cid === 'player') { - magazineRef.current = newMag - setPlayer(p => ({ ...p, weaponMagazines: { ...p.weaponMagazines, ...newMag } })) - } else { - setCombat(prev => ({ - ...prev, - characters: { - ...prev.characters, - [cid]: { ...prev.characters[cid], weaponMagazines: { ...prev.characters[cid].weaponMagazines, ...newMag } }, - }, - })) - } - const ddx = rawAnim.endPos.x - spawnPos.x - const ddy = rawAnim.endPos.y - spawnPos.y - const d = Math.sqrt(ddx * ddx + ddy * ddy) || 1 - const dir = { x: ddx / d, y: ddy / d } - const facing = characterDirsRef.current[cid] ?? { dx: 0, dy: 1 } - const dot = Math.max(-1, Math.min(1, facing.dx * dir.x + facing.dy * dir.y)) - const maxDeviation = (1 - dot) / 2 * (Math.PI / 4) - const deviation = (Math.random() * 2 - 1) * maxDeviation - const cos = Math.cos(deviation), sin = Math.sin(deviation) - const spreadDir = { x: dir.x * cos - dir.y * sin, y: dir.x * sin + dir.y * cos } - const finalDir = { x: isNaN(spreadDir.x) ? dir.x : spreadDir.x, y: isNaN(spreadDir.y) ? dir.y : spreadDir.y } - const ammoType = bullet.ammoType - setProjectiles(prev => [...prev, createProjectile(spawnPos, { x: spawnPos.x + finalDir.x * d, y: spawnPos.y + finalDir.y * d }, actor, 'pistol', 'Shoot', ammoType, cid)]) - new Audio(pistolShotMp3).play().catch(() => {}) - } else if (cid === 'player') { - setCombat(c => ({ ...c, log: [...c.log, 'Click! The pistol is empty.'] })) - } else { - setCombat(c => ({ ...c, log: [...c.log, `${cc.characters[cid]?.name ?? 'Party member'} clicks — the pistol is empty.`] })) - } - } else { - applyAnimHit(rawAnim) - } - } - if (done) { - // ── Enemy move → attack transition ── - if (rawAnim.type === 'move' && cid !== playerCharId) { - const char = cc.characters[cid] - if (!char || char.owner === 'player') continue - const e = fresh?.characters[cid] ?? char - const charPos = characterPositionsRef.current[cid] ?? e?.pos - if (e && !isDead(e.integrity) && !isUnconscious(e.integrity)) { - let targetId = null, minDist = Infinity, targetPos = null - if (e.relationships) { - for (const [pid, rel] of Object.entries(e.relationships)) { - if (rel !== 'enemy') continue - const pch = (fresh ?? cc).characters[pid] - if (!pch || isDead(pch.integrity) || deadThisTickRef.current.has(pid)) continue - const d = Math.sqrt((pch.pos.x - charPos.x) ** 2 + (pch.pos.y - charPos.y) ** 2) - if (d < minDist) { minDist = d; targetId = pid; targetPos = pch.pos } - } - } - if (targetId) { - const targetChar = (fresh ?? cc).characters[targetId] - const wpn = e.weapon ?? 'fists' - const base = weaponMap[wpn] - const skills = base?.skills ?? [{ name: 'Attack', mult: 1.0, damageType: 'blunt', range: 15 }] - const maxRange = Math.max(...skills.map(s => s.range ?? 15)) - if (minDist <= maxRange) { - const skill = skills[Math.floor(Math.random() * skills.length)] - const { dmg, severity, injType, injLabel } = computeHit({ weaponId: wpn, skill, weaponConditions: {} }) - const targetPart = randomPart() - const action = { - type: 'attack', actor: 'enemy', charId: cid, - targetCharId: targetId, - targetPart, - weaponId: wpn, skillName: skill.name, - dmg, severity, injType, injLabel, - attackerParts: { ...e.integrity }, - attackerInjuries: { ...e.injuries }, - targetParts: { ...targetChar.integrity }, - targetInjuries: { ...targetChar.injuries }, - } - characterActionsRef.current[cid] = createWeaponStrikeAnim(action, fresh ?? cc) - continue - } - // Still out of range after move → keep chasing with fresh target - if (targetPos) { - const dx = targetPos.x - charPos.x, dy = targetPos.y - charPos.y - const d = Math.sqrt(dx * dx + dy * dy) - const dir = d > 0 ? { dx: dx / d, dy: dy / d } : { dx: 0, dy: 1 } - characterActionsRef.current[cid] = new ParallelAction({ - type: 'move', - actions: [ - new TurnAction({ - targetDir: dir, - getDir: () => characterDirsRef.current[cid] ?? { dx: 0, dy: 1 }, - setDir: d2 => { characterDirsRef.current[cid] = d2 } - }), - new MoveAction({ - charId: cid, fromX: charPos.x, fromY: charPos.y, - toX: targetPos.x, toY: targetPos.y, - blocks: cc.blocks, - }) - ] - }) - continue - } - } - } - } - characterActionsRef.current[cid] = null - } else { - if (ANIM_CFG[rawAnim.type]) { - activeAnims.push(rawAnim) - } + if (playerBgRefs) { + for (const rawAnim of Object.values(playerBgRefs.actions)) { + if (rawAnim && ANIM_CFG[rawAnim.type] && !rawAnim.done) { + activeAnims.push(rawAnim) } } } - - // ── Physics step (integrate velocities, resolve collisions) ── - if (cc) { stepPhysics(dt, cc.blocks) } - - // ── Run enemy AI every frame for idle enemies ── - if (fresh && timerActiveRef.current) { - const actions = runEnemyAI(fresh, playerRef.current.integrity, playerRef.current.injuries, deadThisTickRef.current) - for (const act of actions) { - const actor = fresh.characters[act.charId] - if (!actor || isDead(actor.integrity) || isUnconscious(actor.integrity)) continue - if (act.type === 'attack') { - const target = fresh.characters[act.targetCharId] - if (!target || isDead(target.integrity)) continue - } - const existing = characterActionsRef.current[act.charId] - const isMove = existing?.type === 'move' - if (act.type === 'attack' && (!existing || isMove)) { - characterActionsRef.current[act.charId] = createWeaponStrikeAnim(act, fresh) - } else if (act.type === 'move') { - const dx = act.toX - act.fromX, dy = act.toY - act.fromY - const d = Math.sqrt(dx * dx + dy * dy) - const dir = d > 0 ? { dx: dx / d, dy: dy / d } : { dx: 0, dy: 1 } - characterActionsRef.current[act.charId] = new ParallelAction({ - type: 'move', - actions: [ - new TurnAction({ - targetDir: dir, - getDir: () => characterDirsRef.current[act.charId] ?? { dx: 0, dy: 1 }, - setDir: d2 => { characterDirsRef.current[act.charId] = d2 } - }), - new MoveAction({ - ...act, - blocks: fresh.blocks, - }) - ] - }) - } - } - } - - // ── Step projectiles ── - setProjectiles(prev => stepProjectiles(prev, dt, combatRef.current)) - - // ── Update rendering state for active animations ── setAnimations(activeAnims) } @@ -2128,23 +2090,46 @@ class MoveAction { return () => cancelAnimationFrame(frame) }, [zoomAnim]) - // ── minimap wheel zoom (non-passive to allow preventDefault) + // ── minimap zoom animation (smooth scroll-wheel zoom) useEffect(() => { + if (!minimapZoomAnim) return + const frame = requestAnimationFrame(function tick() { + const t = Math.min((performance.now() - minimapZoomAnim.start) / 150, 1) + const ease = 1 - Math.pow(1 - t, 3) + setMinimapView({ + x: minimapZoomAnim.from.x + (minimapZoomAnim.to.x - minimapZoomAnim.from.x) * ease, + y: minimapZoomAnim.from.y + (minimapZoomAnim.to.y - minimapZoomAnim.from.y) * ease, + scale: minimapZoomAnim.from.scale + (minimapZoomAnim.to.scale - minimapZoomAnim.from.scale) * ease, + }) + if (t >= 1) setMinimapZoomAnim(null) + else requestAnimationFrame(tick) + }) + return () => cancelAnimationFrame(frame) + }, [minimapZoomAnim]) + + // ── minimap wheel zoom (layout effect — always runs so handler is never missed) + useLayoutEffect(() => { const el = minimapSvgRef.current if (!el) return const handler = e => { - if (!moveModeRef.current && !aimModeRef.current) return e.preventDefault() - const d = e.deltaY > 0 ? 0.9 : 1.1 + const ws = viewingLoc + ? (backgroundDataRef.current[viewingLoc]?.worldSize ?? (viewingLoc?.startsWith?.('pocket_') ? 320 : 160)) + : (combatRef.current?.worldSize ?? 160) + const d = e.deltaY > 0 ? 0.8 : 1.25 const r = el.getBoundingClientRect() - setMinimapView(v => { - const ns = Math.max(0.5, Math.min(3, v.scale * d)) - return { x: v.x + (e.clientX - r.left) / r.width * (160/v.scale - 160/ns), y: v.y + (e.clientY - r.top) / r.height * (160/v.scale - 160/ns), scale: ns } - }) + const v = minimapViewRef.current + const S = v.scale + const nx = (e.clientX - r.left) / r.width + const ny = (e.clientY - r.top) / r.height + const ns = Math.max(0.25, Math.min(5, S * d)) + const toX = v.x * ns / S + nx * ws * (ns / S - 1) + const toY = v.y * ns / S + ny * ws * (ns / S - 1) + setMinimapZoomAnim({ from: { x: v.x, y: v.y, scale: S }, to: { x: toX, y: toY, scale: ns }, start: performance.now() }) } el.addEventListener('wheel', handler, { passive: false }) return () => el.removeEventListener('wheel', handler) - }, [setMinimapView]) + }) // NOTE: the previous native `click` listener on the minimap SVG (registered // via addEventListener with an empty dependency array) was removed here. @@ -2184,11 +2169,8 @@ const makeEnemy = (npc, index) => { const startFight = (npcList) => { const list = npcList ?? [] if (list.length === 0) return - // Save current map state before fight - if (combat) { - savedMapsRef.current[player.location] = saveCurrentMapState() - worldItemsRef.current[player.location] = groundItemsRef.current - } + // Sync current character state to global before fight + syncCombatToGlobal(player.location) const enemies = list.map((npc, i) => makeEnemy(npc, i)) const characters = {} @@ -2236,7 +2218,6 @@ const makeEnemy = (npc, index) => { playerEntityId: 'player', characters, log: [`You engage ${enemies.map(e => e.name).join(' and ')} in combat!`], - blocks: generateLocationBlocks(), } setCombat(initCombat) const locItems = worldItemsRef.current[player.location] ?? [] @@ -2269,27 +2250,17 @@ const makeEnemy = (npc, index) => { /** Enter a location — creates a combat view with all NPCs present as non-hostile */ const enterLocation = (locationId, playerData) => { const pd = playerData ?? player - - // Restore saved map if one exists for this location - const saved = savedMapsRef.current[locationId] - if (saved) { - restoreMapState(locationId, saved, pd) - const locItems = worldItemsRef.current[locationId] ?? saved.groundItems - setGroundItems(locItems); groundItemsRef.current = locItems - setCombatTime(0); setDisplayTime(0) - recordingRef.current = null; setFightRecord(null); setProjectiles([]); setHitShake(null) - speedRef.current = 1; setSpeedMult(1) - setExpandedWeapon(Object.keys(characters).filter(cid => { - const ch = characters[cid]; return ch && (ch.owner === 'player' || ch.relationships?.player === 'ally') - })); setAimMode(false); setLimbSelect(null); setSelectedSkill(null); setSelectedTarget(null); setAttackEnemy(null); setCurrentActor(null); setSkillDropdownOpen([]) - return - } + const isPlace = locationId === 'loc6' + const isPocket = locationId === POCKET_DIM_ID + const pocketDef = isPocket ? backgroundDataRef.current[POCKET_DIM_ID] : null + const ws = isPocket ? (pocketDef?.worldSize ?? 320) : isPlace ? 400 : 160 + const savedPositions = backgroundRefsRef.current[locationId]?.positions ?? {} const characters = {} characters['player'] = createCharacter({ id: 'player', name: pd.name, - pos: { x: 20, y: 75 }, + pos: savedPositions['player'] ?? (isPocket ? { x: 160, y: 160 } : isPlace ? { x: 200, y: 370 } : { x: 20, y: 75 }), integrity: { ...pd.integrity }, injuries: JSON.parse(JSON.stringify(pd.injuries)), equipped: { ...pd.equipped }, @@ -2303,49 +2274,52 @@ const makeEnemy = (npc, index) => { const liveChars = liveCharacters.filter(c => c.id !== 'player' && c.location === locationId && (!c.integrity || !isDead(c.integrity))) let xOff = 50 + let placeLIdx = 0, placeRIdx = 0 for (const lc of liveChars) { - if (lc.equipped) { - characters[lc.id] = createCharacter({ - id: lc.id, name: lc.name, - pos: { x: xOff, y: 130 }, - integrity: lc.integrity ? { ...lc.integrity } : undefined, - injuries: JSON.parse(JSON.stringify(lc.injuries ?? pd.injuries)), - equipped: { ...lc.equipped }, - weaponConditions: { ...lc.weaponConditions }, - weaponMagazines: { ...lc.weaponMagazines }, - itemCounts: { ...lc.itemCounts }, - inventory: [...(lc.inventory ?? [])], - fillColor: lc.fillColor, - relationships: lc.relationships ? { ...lc.relationships } : undefined, - owner: null, - }) - } else { - characters[lc.id] = createCharacter({ - id: lc.id, name: lc.name, - pos: { x: xOff, y: 130 }, - integrity: freshBody(), - injuries: freshInjuries(), - equipped: { left: 'fists', right: 'fists' }, - weaponConditions: {}, - weaponMagazines: {}, - itemCounts: { ...(lc.itemCounts ?? {}) }, - inventory: [...(lc.inventory ?? [])], - fillColor: lc.fillColor, - relationships: lc.relationships ? { ...lc.relationships } : undefined, - owner: null, - }) + const savedPos = savedPositions[lc.id] + let pos = savedPos ? { x: savedPos.x, y: savedPos.y } : { x: xOff, y: 130 } + if (isPlace && lc.id.startsWith('place_l')) { + pos = { x: 30, y: 30 + placeLIdx * (340 / 9) } + placeLIdx++ + } else if (isPlace && lc.id.startsWith('place_r')) { + pos = { x: ws - 30, y: 30 + placeRIdx * (340 / 9) } + placeRIdx++ + } else if (isPocket) { + pos = { x: 80 + Math.random() * 160, y: 80 + Math.random() * 160 } + } else if (!savedPos) { + xOff += 25 } - xOff += 25 + characters[lc.id] = createCharacter({ + id: lc.id, name: lc.name, + pos, + integrity: lc.integrity ? { ...lc.integrity } : freshBody(), + injuries: lc.injuries ? JSON.parse(JSON.stringify(lc.injuries)) : freshInjuries(), + equipped: lc.equipped ? { left: lc.equipped.left, right: lc.equipped.right } : { left: 'fists', right: 'fists' }, + weaponConditions: lc.weaponConditions ? { ...lc.weaponConditions } : {}, + weaponMagazines: lc.weaponMagazines ? { ...lc.weaponMagazines } : {}, + itemCounts: lc.itemCounts ? { ...lc.itemCounts } : {}, + inventory: [...(lc.inventory ?? [])], + fillColor: lc.fillColor, + relationships: lc.relationships ? { ...lc.relationships } : undefined, + owner: null, + }) } const locName = locMap[locationId]?.name ?? locationId const initCombat = { playerEntityId: 'player', characters, - log: [`You arrive at ${locName}.`], - blocks: generateLocationBlocks(), + log: [isPocket ? `DEBUG: Entered pocket dimension.` : `You arrive at ${locName}.`], } setCombat(initCombat) + if (!isPocket) { + for (const id of [...activeLocationsRef.current]) { + if (id !== locationId && id !== POCKET_DIM_ID) deactivateBgLocation(id) + } + } + const locBlocks = isPocket ? (pocketDef?.blocks ?? [{ x: 130, y: 130, w: 60, h: 60 }]) : isPlace ? [] : generateLocationBlocks() + const bgData = { blocks: locBlocks, worldSize: ws, characters, groundItems: [], projectiles: [], splats: [] } + activateBgLocation(locationId, bgData, Object.keys(characters)) const locItems = worldItemsRef.current[locationId] ?? [] setGroundItems(locItems); groundItemsRef.current = locItems setCombatTime(0); setDisplayTime(0) @@ -2379,89 +2353,47 @@ const makeEnemy = (npc, index) => { }) if (deadIds.length > 0) setDefeatedNpcs(prev => [...new Set([...prev, ...deadIds])]) - // Sync party member combat state back to world - const cc = combatRef.current - if (cc) { - const partyCids = Object.keys(cc.characters).filter(cid => { - const ch = cc.characters[cid] - return ch && (ch.owner === 'player' || ch.relationships?.player === 'ally') - }) - setCharacterOverrides(prev => { - const next = { ...prev } - for (const pid of partyCids) { - if (pid === 'player') continue - const combatChar = cc.characters[pid] - if (combatChar) { - next[pid] = { - weaponConditions: { ...combatChar.weaponConditions }, - itemCounts: { ...combatChar.itemCounts }, - weaponMagazines: { ...combatChar.weaponMagazines }, - inventory: [...(combatChar.inventory ?? [])], - integrity: { ...combatChar.integrity }, - injuries: JSON.parse(JSON.stringify(combatChar.injuries)), - } - } - } - return next - }) - for (const pid of partyCids) { - if (pid === 'player') continue - if (isDead(cc.characters[pid]?.integrity)) { - setDefeatedNpcs(prev => [...new Set([...prev, pid])]) - } + // Sync ALL combat character state back to world + syncCombatToGlobal(player.location) + for (const [cid, ch] of Object.entries(combat.characters)) { + if (cid === 'player') continue + if (isDead(ch.integrity)) { + setDefeatedNpcs(prev => [...new Set([...prev, cid])]) } } } } - /** Save the current map (combat) state for a location */ - const saveCurrentMapState = () => { - if (!combat) return null + /** Sync all combat characters at a location back to global character state */ + const syncCombatToGlobal = (locationId) => { + if (!combat) return const cc = combatRef.current ?? combat - return { - playerEntityId: cc.playerEntityId, - characters: JSON.parse(JSON.stringify(cc.characters)), - log: [...cc.log], - blocks: cc.blocks, - groundItems: [...(groundItemsRef.current ?? [])], + worldItemsRef.current[locationId] = groundItemsRef.current + const updates = {} + for (const [cid, ch] of Object.entries(cc.characters)) { + if (cid === 'player') continue + updates[cid] = { + pos: ch.pos ? { x: ch.pos.x, y: ch.pos.y } : undefined, + integrity: ch.integrity ? { ...ch.integrity } : undefined, + injuries: ch.injuries ? JSON.parse(JSON.stringify(ch.injuries)) : undefined, + equipped: ch.equipped ? { left: ch.equipped.left, right: ch.equipped.right } : undefined, + weaponConditions: ch.weaponConditions ? { ...ch.weaponConditions } : undefined, + weaponMagazines: ch.weaponMagazines ? { ...ch.weaponMagazines } : undefined, + itemCounts: ch.itemCounts ? { ...ch.itemCounts } : undefined, + inventory: ch.inventory ? [...ch.inventory] : undefined, + relationships: ch.relationships ? { ...ch.relationships } : undefined, + money: ch.money ?? undefined, + } } - } - - /** Restore a saved map state, updating player with current data */ - const restoreMapState = (locationId, saved, playerData) => { - const pd = playerData ?? player - const savedPlayer = saved.characters['player'] - saved.characters['player'] = createCharacter({ - id: 'player', name: pd.name, - pos: savedPlayer?.pos ?? { x: 20, y: 75 }, - integrity: { ...pd.integrity }, - injuries: JSON.parse(JSON.stringify(pd.injuries)), - equipped: { ...pd.equipped }, - weaponConditions: { ...pd.weaponConditions }, - weaponMagazines: { ...pd.weaponMagazines }, - itemCounts: { ...pd.itemCounts }, - relationships: { ...(pd.relationships ?? {}) }, - owner: 'player', - fillColor: pd.fillColor, + setCharacterOverrides(prev => { + const next = { ...prev } + for (const [cid, data] of Object.entries(updates)) { + next[cid] = { ...(prev[cid] ?? {}), ...data } + } + return next }) - setCombat({ - playerEntityId: saved.playerEntityId, - characters: saved.characters, - log: [`You arrive at ${locMap[locationId]?.name ?? locationId}.`], - blocks: saved.blocks, - }) - setGroundItems(saved.groundItems) - groundItemsRef.current = saved.groundItems - setCombatTime(0); setDisplayTime(0) - characterPositionsRef.current = {} - characterVelocitiesRef.current = {} - characterDirsRef.current = {} - characterActionsRef.current = {} - for (const cid of Object.keys(saved.characters)) { - characterPositionsRef.current[cid] = { ...saved.characters[cid].pos } - characterVelocitiesRef.current[cid] = { x: 0, y: 0 } - characterDirsRef.current[cid] = { dx: 0, dy: 1 } - characterActionsRef.current[cid] = null + for (const [cid, data] of Object.entries(updates)) { + characterOverridesRef.current[cid] = { ...(characterOverridesRef.current[cid] ?? {}), ...data } } } @@ -2560,131 +2492,60 @@ const makeEnemy = (npc, index) => { } const debugPocketDimension = (targetId) => { - const cc = combatRef.current - if (!cc) return const locId = player.location - if (locId?.startsWith?.('pocket_')) { + const cc = combatRef.current + // ── EXIT pocket dimension ── + if (locId === POCKET_DIM_ID) { const returnLoc = pocketReturnRef.current - if (returnLoc) { - const restored = [] - for (const npcId of Object.keys(pocketNPCsRef.current)) { - const origLoc = pocketNPCsRef.current[npcId] - setCharacterOverrides(prev => { - const next = { ...prev }; const existing = next[npcId] - if (existing) next[npcId] = { ...existing, location: origLoc } - else next[npcId] = { location: origLoc } - return next - }) - restored.push(npcId) - } - pocketNPCsRef.current = {} - pocketNPCDataRef.current = {} - pocketDimIdRef.current = null - enterLocation(returnLoc) - setMinimapView({ x: 0, y: 0, scale: 1 }) - const extra = restored.length ? ` (restored ${restored.map(id => cc.characters[id]?.name ?? id).join(', ')})` : '' - setCombat(c => c ? { ...c, log: [...c.log, `DEBUG: Returned from pocket dimension.${extra}`] } : c) - } + if (!returnLoc) return + pocketReturnRef.current = null + syncCombatToGlobal(locId) + setPlayer(p => ({ ...p, location: returnLoc })) + setMinimapView({ x: 0, y: 0, scale: 1 }); setViewingLoc(null) + enterLocation(returnLoc) return } - if (targetId && targetId !== 'player') { - const npcChar = cc.characters[targetId] - if (npcChar && !isDead(npcChar.integrity)) { - if (!pocketDimIdRef.current) pocketDimIdRef.current = `pocket_${Date.now()}` - const pocketId = pocketDimIdRef.current - pocketNPCDataRef.current[targetId] = { - id: targetId, name: npcChar.name, - integrity: { ...npcChar.integrity }, - injuries: JSON.parse(JSON.stringify(npcChar.injuries ?? playerRef.current.injuries)), - equipped: { ...(npcChar.equipped ?? { left: 'fists', right: 'fists' }) }, - weaponConditions: { ...(npcChar.weaponConditions ?? {}) }, - weaponMagazines: { ...(npcChar.weaponMagazines ?? {}) }, - itemCounts: { ...(npcChar.itemCounts ?? {}) }, - inventory: [...(npcChar.inventory ?? [])], - relationships: { ...(npcChar.relationships ?? {}) }, - size: npcChar.size, - owner: null, - fillColor: npcChar.fillColor, - } - pocketNPCsRef.current[targetId] = locId - setCharacterOverrides(prev => ({ - ...prev, [targetId]: { ...prev[targetId], location: pocketId } - })) - setCombat(c => { - if (!c) return c - const chars = { ...c.characters } - delete chars[targetId] - return { ...c, characters: chars, log: [...c.log, `DEBUG: ${npcChar.name ?? targetId} sent to pocket dimension.`] } - }) - } + // ── MOVE character to pocket dimension ── + const charId = targetId || 'player' + const isPlayerChar = charId === 'player' + if (isPlayerChar) { + if (!cc) return + pocketReturnRef.current = locId + syncCombatToGlobal(locId) + setPlayer(p => ({ ...p, location: POCKET_DIM_ID })) + enterLocation(POCKET_DIM_ID) return } - pocketReturnRef.current = locId - savedMapsRef.current[locId] = saveCurrentMapState() - worldItemsRef.current[locId] = groundItemsRef.current - if (!pocketDimIdRef.current) pocketDimIdRef.current = `pocket_${Date.now()}` - const pocketId = pocketDimIdRef.current - const pos = { x: 80, y: 80 } - const pd = playerRef.current - const pocketChars = {} - pocketChars['player'] = createCharacter({ - id: 'player', name: pd.name, - pos, - integrity: { ...pd.integrity }, - injuries: JSON.parse(JSON.stringify(pd.injuries)), - equipped: { ...pd.equipped }, - weaponConditions: { ...pd.weaponConditions }, - weaponMagazines: { ...pd.weaponMagazines }, - itemCounts: { ...pd.itemCounts }, - relationships: { ...(pd.relationships ?? {}) }, - owner: 'player', - fillColor: pd.fillColor, + // Send NPC to pocket dimension: just change their location + const npcChar = cc?.characters[targetId] + if (!npcChar || isDead(npcChar.integrity)) return + pocketDimBgRefs.positions[targetId] = npcChar.pos ? { ...npcChar.pos } : { x: 160, y: 160 } + pocketDimBgRefs.velocities[targetId] = { x: 0, y: 0 } + pocketDimBgRefs.dirs[targetId] = { dx: 0, dy: 1 } + pocketDimBgRefs.actions[targetId] = null + setCharacterOverrides(prev => ({ ...prev, [targetId]: { ...prev[targetId], location: POCKET_DIM_ID } })) + characterOverridesRef.current[targetId] = { ...(characterOverridesRef.current[targetId] ?? {}), location: POCKET_DIM_ID } + if (!activeLocationsRef.current.includes(POCKET_DIM_ID)) { + activeLocationsRef.current = [...activeLocationsRef.current, POCKET_DIM_ID] + } + // Remove NPC from current location's background refs + const playerBgRefsLoc = backgroundRefsRef.current[locId] + if (playerBgRefsLoc) { + delete playerBgRefsLoc.positions[targetId] + delete playerBgRefsLoc.velocities[targetId] + delete playerBgRefsLoc.dirs[targetId] + delete playerBgRefsLoc.actions[targetId] + } + delete characterPositionsRef.current[targetId] + delete characterVelocitiesRef.current[targetId] + delete characterDirsRef.current[targetId] + delete characterActionsRef.current[targetId] + setCombat(c => { + if (!c) return c + const chars = { ...c.characters } + delete chars[targetId] + return { ...c, characters: chars, log: [...c.log, `DEBUG: ${npcChar.name ?? targetId} sent to pocket dimension.`] } }) - let xOff = 105 - for (const npcId of Object.keys(pocketNPCDataRef.current)) { - const data = pocketNPCDataRef.current[npcId] - pocketChars[npcId] = createCharacter({ - id: data.id, name: data.name, - pos: { x: xOff, y: 80 }, - integrity: data.integrity, - injuries: data.injuries, - equipped: data.equipped, - weaponConditions: data.weaponConditions, - weaponMagazines: data.weaponMagazines, - itemCounts: data.itemCounts, - inventory: data.inventory, - relationships: data.relationships, - size: data.size, - owner: data.owner, - fillColor: data.fillColor, - }) - xOff += 25 - } - const initCombat = { - playerEntityId: 'player', - characters: pocketChars, - log: [`DEBUG: Entered pocket dimension.${Object.keys(pocketNPCDataRef.current).length ? ` (${Object.keys(pocketNPCDataRef.current).length} NPCs present)` : ''}`], - blocks: [], - } - setCombat(initCombat) - setMinimapView({ x: 0, y: 0, scale: 0.6 }) - setPlayer(p => ({ ...p, location: pocketId })) - setGroundItems([]); groundItemsRef.current = [] - setCombatTime(0); setDisplayTime(0) - characterActionsRef.current = {} - characterPositionsRef.current = {} - characterVelocitiesRef.current = {} - characterDirsRef.current = {} - for (const cid of Object.keys(pocketChars)) { - characterActionsRef.current[cid] = null - characterPositionsRef.current[cid] = pocketChars[cid].pos - characterVelocitiesRef.current[cid] = { x: 0, y: 0 } - characterDirsRef.current[cid] = { dx: 0, dy: 1 } - } - recordingRef.current = null; setFightRecord(null) - setProjectiles([]); setHitShake(null) - speedRef.current = 1; setSpeedMult(1) - setAimMode(false); setLimbSelect(null); setSelectedSkill(null); setSelectedTarget(null); setAttackEnemy(null); setCurrentActor(null); setSkillDropdownOpen([]) } const startTimer = () => { @@ -2748,7 +2609,7 @@ const makeEnemy = (npc, index) => { const dist = Math.sqrt((targetX - curPos.x) ** 2 + (targetY - curPos.y) ** 2) if (dist < 1) return const pct = moveMode === 'run' ? 1.0 : 0.5 - const moveAction = new MoveAction({ type: 'move', charId: actorId, fromX: curPos.x, fromY: curPos.y, toX: targetX, toY: targetY, speedMult: pct, blocks: combat.blocks }) + const moveAction = new MoveAction({ type: 'move', charId: actorId, fromX: curPos.x, fromY: curPos.y, toX: targetX, toY: targetY, speedMult: pct, blocks: backgroundDataRef.current[player.location]?.blocks ?? [] }) const dx = targetX - curPos.x, dy = targetY - curPos.y const d = Math.sqrt(dx * dx + dy * dy) const dir = d > 0 ? { dx: dx / d, dy: dy / d } : { dx: 0, dy: 1 } @@ -2950,11 +2811,8 @@ const makeEnemy = (npc, index) => { const travel = (targetId) => { if (travelAnim) return - // Save current map state before leaving - if (combat) { - savedMapsRef.current[player.location] = saveCurrentMapState() - worldItemsRef.current[player.location] = groundItemsRef.current - } + // Sync all character state to global before leaving + syncCombatToGlobal(player.location) const from = locations.find(l => l.id === player.location) const to = locations.find(l => l.id === targetId) if (!from || !to) return @@ -3520,7 +3378,54 @@ const makeEnemy = (npc, index) => {