import { useState, useRef, useCallback, useEffect, Fragment } from 'react' import './App.css' import maleBody from './assets/body/malebody.png' import pistolShotMp3 from './assets/pistolshot.mp3' // ───────────────────────────────────────────────────────────────────────────── // WORLD DATA // ───────────────────────────────────────────────────────────────────────────── const locations = [ { id: 'A', name: 'Alpha', description: 'A quiet village at the edge of the forest.', x: 200, y: 300 }, { id: 'B', name: 'Beta', description: 'A bustling trade port by the sea.', x: 600, y: 300 }, { id: 'C', name: 'Gamma', description: 'A hidden monastery in the mountains.', x: 400, y: 130 }, { id: 'D', name: 'Delta', description: 'An ancient ruined city swallowed by desert.', x: 100, y: 130 }, { id: 'E', name: 'Epsilon', description: 'A floating island held aloft by magic.', x: 700, y: 130 }, ] const connections = [ { from: 'A', to: 'B' }, { from: 'A', to: 'C' }, { from: 'A', to: 'D' }, { from: 'B', to: 'C' }, { from: 'B', to: 'E' }, { from: 'C', to: 'D' }, { from: 'C', to: 'E' }, ] const characters = [ { id: 'player', name: 'John', location: 'A', size: 1 }, { id: '1', name: 'Mara', location: 'A', money: 120, inventory: ['pistol_ammo'], size: 1 }, { id: '2', name: 'Rook', location: 'A', money: 0, inventory: ['pistol_ammo'], size: 1 }, { id: '3', name: 'Vex', location: 'B', money: 500, inventory: ['pistol_ammo'], size: 1 }, { id: 'd1', name: 'Dust Gutter', location: 'D', money: 15, inventory: ['pistol_ammo'], size: 1 }, { id: 'd2', name: 'Bone Collector', location: 'D', money: 30, inventory: ['pistol_ammo'], size: 1 }, { id: 'd3', name: 'Sand Viper', location: 'D', money: 8, inventory: ['pistol_ammo'], size: 1 }, { id: 'd4', name: 'Rust-Eye', location: 'D', money: 45, inventory: ['pistol_ammo'], size: 1 }, { id: 'd5', name: 'Cinder', location: 'D', money: 22, inventory: ['pistol_ammo'], size: 1 }, ] const weapons = [ { id: 'fists', name: 'Fists', damage: 35, condition: 100, maxCondition: 100, passives: ['Brawler'], weight: 0, speed: 14, skills: [{ name: 'Jab', mult: 1.0, damageType: 'blunt', range: 12 }, { name: 'Hook', mult: 1.3, damageType: 'blunt', range: 12 }, { name: 'Kick', mult: 1.5, damageType: 'blunt', range: 14 }, { name: 'Shove', mult: 0, damageType: 'shove', range: 10 }] }, { id: 'pistol', name: 'Pistol', damage: 70, condition: 100, maxCondition: 100, passives: ['Ranged'], weight: 3, speed: 12, skills: [{ name: 'Shoot', mult: 1.0, damageType: 'pierce', range: 200 }, { name: 'Load', mult: 0, damageType: 'reload', range: 0 }, { name: 'Unload', mult: 0, damageType: 'unload', range: 0 }] }, { id: 'rusty_sword', name: 'Rusty Sword', damage: 60, condition: 65, maxCondition: 100, passives: ['Bleed'], weight: 4, speed: 10, skills: [{ name: 'Slash', mult: 1.0, damageType: 'slash', range: 15 }, { name: 'Stab', mult: 1.2, damageType: 'pierce', range: 18 }] }, { id: 'steel_sword', name: 'Steel Sword', damage: 85, condition: 100, maxCondition: 100, passives: ['Bleed', 'Pierce'], weight: 5, speed: 9, skills: [{ name: 'Slash', mult: 1.0, damageType: 'slash', range: 15 }, { name: 'Stab', mult: 1.2, damageType: 'pierce', range: 18 }, { name: 'Overhead', mult: 1.4, damageType: 'slash', range: 14 }] }, { id: 'iron_shield', name: 'Iron Shield', damage: 40, condition: 100, maxCondition: 100, passives: ['Block', 'Sturdy'], weight: 6, speed: 7, skills: [{ name: 'Bash', mult: 1.0, damageType: 'blunt', range: 12 }, { name: 'Shield Slam', mult: 1.3, damageType: 'blunt', range: 13 }] }, { id: 'dagger', name: 'Dagger', damage: 50, condition: 100, maxCondition: 100, passives: ['Quick', 'Bleed'], weight: 2, speed: 13, skills: [{ name: 'Slash', mult: 1.0, damageType: 'slash', range: 12 }, { name: 'Stab', mult: 1.3, damageType: 'pierce', range: 15 }] }, { id: 'warhammer', name: 'Warhammer', damage: 100, condition: 100, maxCondition: 100, passives: ['Crush', 'Stagger'], weight: 8, speed: 6, skills: [{ name: 'Pound', mult: 1.0, damageType: 'blunt', range: 14 }, { name: 'Slam', mult: 1.4, damageType: 'blunt', range: 13 }] }, ] const items = [ { id: 'pistol_ammo', name: 'Pistol Ammo', description: 'Standard pistol ammunition.', weight: 0.5 }, { id: 'bouncing_ammo', name: 'Bouncing Ammo', description: 'Experimental ammo that bounces off walls.', weight: 0.5 }, ] const itemMap = {} items.forEach(i => { itemMap[i.id] = i }) const weaponMap = {} weapons.forEach(w => { weaponMap[w.id] = w }) // Virtual weapon entries for bare arms const ARM_WEAPON = { damage: 35, condition: 100, maxCondition: 100, passives: ['Brawler'], weight: 0, speed: 14, skills: [{ name: 'Jab', mult: 1.0, damageType: 'blunt', range: 12 }, { name: 'Hook', mult: 1.3, damageType: 'blunt', range: 12 }, { name: 'Kick', mult: 1.5, damageType: 'blunt', range: 14 }, { name: 'Shove', mult: 0, damageType: 'shove', range: 10 }] } weaponMap['left_arm'] = { id: 'left_arm', name: 'Left Arm', ...ARM_WEAPON } weaponMap['right_arm'] = { id: 'right_arm', name: 'Right Arm', ...ARM_WEAPON } // ───────────────────────────────────────────────────────────────────────────── // CONSTANTS // ───────────────────────────────────────────────────────────────────────────── const MAX_BODY = { head: 100, torso: 100, leftArm: 100, rightArm: 100, leftLeg: 100, rightLeg: 100 } const MAX_HP = Object.values(MAX_BODY).reduce((a, b) => a + b, 0) const MAX_WEIGHT = 50 function generateLocationBlocks(locationId) { let seed = 0 for (let i = 0; i < locationId.length; i++) { seed = ((seed << 5) - seed) + locationId.charCodeAt(i) seed = seed & seed } let s = seed function rng() { s = (s * 1664525 + 1013904223) & 0xFFFFFFFF; return (s >>> 0) / 0xFFFFFFFF } const blocks = [] const num = 3 + Math.floor(rng() * 3) for (let i = 0; i < num; i++) { const w = 15 + Math.floor(rng() * 25); const h = 15 + Math.floor(rng() * 35) const x = 5 + Math.floor(rng() * (150 - w - 5)); const y = 5 + Math.floor(rng() * (150 - h - 5)) blocks.push({ x, y, w, h }) } return blocks } const ITEM_VALUES = { pistol_ammo: 5, bouncing_ammo: 8, steel_sword: 80, iron_shield: 50, dagger: 30, rusty_sword: 40, pistol: 100, warhammer: 90, } function getItemValue(id) { if (ITEM_VALUES[id] !== undefined) return ITEM_VALUES[id] if (weaponMap[id]) return weaponMap[id].damage return 1 } const INJURY_DECAY = { cut: 0.3, laceration: 0.6, stab: 0.8 } const TURN_SPEED = 6 const PISTOL_MAG = 2 const bodyPartLabels = { head: 'Head', torso: 'Torso', leftArm: 'Left Arm', rightArm: 'Right Arm', leftLeg: 'Left Leg', rightLeg: 'Right Leg', } // ───────────────────────────────────────────────────────────────────────────── // PURE HELPERS (no React state) // ───────────────────────────────────────────────────────────────────────────── function rectCollides(px, py, block) { return px >= block.x && px <= block.x + block.w && py >= block.y && py <= block.y + block.h } function partColor(hp, max) { const r = hp / max if (r > 0.6) return '#22c55e' if (r > 0.3) return '#eab308' return '#ef4444' } function freshBody() { return { head: 100, torso: 100, leftArm: 100, rightArm: 100, leftLeg: 100, rightLeg: 100 } } function freshInjuries(){ return { head: [], torso: [], leftArm: [], rightArm: [], leftLeg: [], rightLeg: [] } } function totalHp(body) { return Object.values(body).reduce((a, b) => a + b, 0) } function isDead(integrity) { return !integrity || integrity.head <= 0 || integrity.torso <= 0 } function createCharacter(data) { const weaponId = data.weapon ?? 'fists' const wpn = weaponMap[weaponId] const integrity = data.integrity ?? freshBody() const injuries = data.injuries ?? freshInjuries() return { id: data.id ?? 'unknown', name: data.name ?? 'Unknown', age: data.age ?? Math.floor(Math.random() * 30) + 18, location: data.location ?? 'Unknown', money: data.money ?? 0, inventory: [...(data.inventory ?? [])], equipped: data.equipped ? { left: data.equipped.left, right: data.equipped.right } : { left: 'fists', right: 'fists' }, weaponConditions: data.weaponConditions ? { ...data.weaponConditions } : {}, integrity: { ...integrity }, injuries: Object.keys(injuries).reduce((acc, k) => { acc[k] = [...injuries[k]]; return acc }, {}), size: data.size ?? 1, fillColor: data.fillColor ?? `hsl(${Math.random() * 360}, 70%, 55%)`, weapon: weaponId, pos: data.pos ? { x: data.pos.x, y: data.pos.y } : undefined, speed: (data.speed ?? wpn?.speed ?? 10) / 2, maxSpeed: data.maxSpeed ?? 2, attack: data.attack ?? wpn?.damage ?? 30, weaponMagazines: data.weaponMagazines ? { ...data.weaponMagazines } : {}, itemCounts: data.itemCounts ? { ...data.itemCounts } : {}, } } function randomPart() { const parts = ['head', 'torso', 'leftArm', 'rightArm', 'leftLeg', 'rightLeg'] return parts[Math.floor(Math.random() * parts.length)] } function injuryType(damageType) { if (damageType === 'blunt') return 'laceration' if (damageType === 'pierce') return 'stab' return 'cut' } function applyInjury(injuries, part, type, severity) { return { ...injuries, [part]: [...(injuries[part] ?? []), { type, severity }] } } function calcIntegrity(integrity, injuries, deltaTime = 1) { let drain = 0 for (const part of Object.keys(injuries)) { for (const inj of injuries[part]) drain += (INJURY_DECAY[inj.type] ?? 0.3) * inj.severity } const out = {} for (const part of Object.keys(integrity)) out[part] = Math.max(0, integrity[part] - drain * deltaTime) return out } // ───────────────────────────────────────────────────────────────────────────── // MODULAR COMBAT ENGINE // These functions are used by BOTH player and enemy logic. // ───────────────────────────────────────────────────────────────────────────── /** * Move an actor toward a target, respecting the given blocks. * Returns the new {x, y} position. * @param {{x:number,y:number}} from * @param {{x:number,y:number}} to * @param {number} speed – units per step * @returns {{x:number,y:number}} */ function computeMove(from, to, speed, blocks) { const dx = to.x - from.x const dy = to.y - from.y const dist = Math.sqrt(dx * dx + dy * dy) if (dist <= speed) return { x: to.x, y: to.y } const step = 0.5 const totalSteps = Math.ceil(speed / step) let cx = from.x, cy = from.y; const blk = blocks ?? [] for (let i = 0; i < totalSteps; i++) { const remaining = speed - i * step const thisStep = Math.min(step, remaining) const nx = cx + (dx / dist) * thisStep const ny = cy + (dy / dist) * thisStep if (blk.some(b => rectCollides(nx, ny, b))) break cx = nx; cy = ny } return { x: cx, y: cy } } /** * Compute a shove effect, returning the pushed position. */ function computeShove(attackerPos, targetPos, blocks) { const pushDist = 30 + Math.floor(Math.random() * 20) const dx = targetPos.x - attackerPos.x const dy = targetPos.y - attackerPos.y const dist = Math.sqrt(dx * dx + dy * dy) || 1 const nx = dx / dist, ny = dy / dist let ex = targetPos.x, ey = targetPos.y; const blk = blocks ?? [] const steps = Math.ceil(pushDist / 3) for (let i = 1; i <= steps; i++) { const sx = targetPos.x + nx * (pushDist * i / steps) const sy = targetPos.y + ny * (pushDist * i / steps) if (blk.some(b => rectCollides(sx, sy, b))) break ex = sx; ey = sy } const actualPush = Math.sqrt((ex - targetPos.x) ** 2 + (ey - targetPos.y) ** 2) const clamped = { x: Math.max(5, Math.min(145, ex)), y: Math.max(5, Math.min(145, ey)) } return { pos: clamped, actualPush } } /** * Resolve damage for a single hit. * Works for any attacker — pass in weapon, skill, condition, and an optional bothHandsMult. * Returns { dmg, severity, injType, injLabel } */ function computeHit({ weaponId, skill, weaponConditions, bothMult = 1.0 }) { if (skill.damageType === 'shove') return { dmg: 0, severity: 0, injType: 'shove', injLabel: 'shove' } const base = weaponMap[weaponId] if (!base) return { dmg: 1, severity: 1, injType: 'cut', injLabel: 'cut' } const cond = weaponConditions?.[weaponId] ?? base.maxCondition const conditionPenalty = cond < 30 ? 0.5 : cond < 60 ? 0.25 : 0 const adjusted = Math.round(base.damage * skill.mult * bothMult * (1 - conditionPenalty)) const variance = Math.floor(Math.random() * (Math.max(2, adjusted / 2) + 1)) - Math.floor(Math.max(2, adjusted / 4)) const dmg = Math.max(1, adjusted + variance) const severity = Math.round(dmg / 5) + 1 const injType = injuryType(skill.damageType) const injLabel = injType === 'laceration' ? 'laceration' : injType === 'stab' ? 'stab wound' : 'cut' return { dmg, severity, injType, injLabel } } /** * Get range for a weapon + skill combo. */ function getSkillRange(weaponId, skillName) { if (weaponId === 'left_arm' || weaponId === 'right_arm') return 12 const base = weaponMap[weaponId] if (!base) return 15 const skill = (base.skills ?? []).find(s => s.name === skillName) return skill?.range ?? 15 } /** * Check whether an attacker at `attackerPos` can reach target at `targetPos` * with the given weapon + skill. */ function canReach(attackerPos, targetPos, weaponId, skillName) { const dx = attackerPos.x - targetPos.x const dy = attackerPos.y - targetPos.y return Math.sqrt(dx * dx + dy * dy) <= getSkillRange(weaponId, skillName) } /** * Build a player attack action for the action queue. * Returns an action object that actionHandlers.attack can process. */ function buildPlayerAttack({ weaponId, skillName, targetPart, enemyIndex, equippedWeapons, weaponConditions, bodyParts, bodyInjuries, combat, actor = 'player', charId = 'player' }) { const base = weaponMap[weaponId] const isArm = weaponId === 'left_arm' || weaponId === 'right_arm' const skill = isArm ? { name: 'Punch', mult: 0.3, damageType: 'blunt', range: 12 } : (base?.skills ?? []).find(s => s.name === skillName) ?? { name: 'Attack', mult: 1.0, damageType: 'blunt' } if (skill.damageType === 'shove') { return { type: 'shove_pending', actor, weaponId, skillName, enemyIndex, charId } } const bothMult = (!isArm && equippedWeapons.left === weaponId && equippedWeapons.right === weaponId) ? 1.5 : 1.0 const { dmg, severity, injType, injLabel } = computeHit({ weaponId, skill, weaponConditions, bothMult }) const enemyId = combat.enemyIds[enemyIndex] const enemy = enemyId ? combat.characters[enemyId] : null return { type: 'attack', actor, charId, enemyCharId: enemyId, weaponId, skillName, targetPart, enemyIndex, dmg, severity, injType, injLabel, attackerParts: { ...bodyParts }, attackerInjuries: { ...bodyInjuries }, targetParts: enemy ? { ...enemy.integrity } : {}, targetInjuries: enemy ? { ...enemy.injuries } : {}, } } /** * Run enemy AI. Returns an array of raw action objects. * Uses the same computeMove / computeHit as the player. */ function runEnemyAI(combat, playerParts, playerInjuries, deadThisTick) { const actions = [] const partyIds = combat.partyIds ?? [combat.playerEntityId].filter(Boolean) const aliveParty = partyIds.filter(pid => { const ch = combat.characters[pid] return ch && !isDead(ch.integrity) && !(deadThisTick?.has(pid)) }) if (aliveParty.length === 0) return actions for (const cid of Object.keys(combat.characters)) { if (partyIds.includes(cid) || (combat.npcIds ?? []).includes(cid)) continue const e = combat.characters[cid] if (isDead(e.integrity)) continue // Find nearest alive party member for this enemy let targetId = null, targetPos = null, minDist = Infinity for (const pid of aliveParty) { const pch = combat.characters[pid] const d = Math.sqrt((pch.pos.x - e.pos.x) ** 2 + (pch.pos.y - e.pos.y) ** 2) if (d < minDist) { minDist = d; targetId = pid; targetPos = pch.pos } } if (!targetId) continue const targetChar = combat.characters[targetId] // ── in range → attack const weaponId = e.weapon ?? 'fists' const base = weaponMap[weaponId] 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) { // out of range → move toward target const speed = e.speed > 12 ? 10 : 5 const newPos = computeMove(e.pos, targetPos, speed, combat.blocks) if (newPos.x !== e.pos.x || newPos.y !== e.pos.y) { actions.push({ type: 'move', charId: cid, fromX: e.pos.x, fromY: e.pos.y, toX: newPos.x, toY: newPos.y, speed }) } continue } const skill = skills[Math.floor(Math.random() * skills.length)] const { dmg, severity, injType, injLabel } = computeHit({ weaponId, skill, weaponConditions: {} }) const targetPart = randomPart() actions.push({ type: 'attack', actor: 'enemy', charId: cid, targetCharId: targetId, targetPart, weaponId, skillName: skill.name, dmg, severity, injType, injLabel, attackerParts: { ...e.integrity }, attackerInjuries: { ...e.injuries }, targetParts: { ...targetChar.integrity }, targetInjuries: { ...targetChar.injuries }, }) } return actions } // ───────────────────────────────────────────────────────────────────────────── // BODY IMAGE COMPONENT // ───────────────────────────────────────────────────────────────────────────── const bodyOverlays = [ { part: 'head', left: '22%', top: '1%', width: '56%', height: '20%' }, { part: 'torso', left: '18%', top: '22%', width: '64%', height: '35%' }, { part: 'leftArm', left: '2%', top: '22%', width: '20%', height: '34%' }, { part: 'rightArm', left: '78%', top: '22%', width: '20%', height: '34%' }, { part: 'leftLeg', left: '20%', top: '58%', width: '30%', height: '36%' }, { part: 'rightLeg', left: '50%', top: '58%', width: '30%', height: '36%' }, ] function BodyImage({ body }) { return (
{bodyOverlays.map(o => (
))}
) } // ───────────────────────────────────────────────────────────────────────────── // CHARACTER CREATOR // ───────────────────────────────────────────────────────────────────────────── function CharacterCreator({ onConfirm }) { const [name, setName] = useState('John') const [weaponId, setWeaponId] = useState('rusty_sword') const [fillColor, setFillColor] = useState('#fbbf24') const [locationId, setLocationId] = useState('A') const startWeapons = [ { id: 'rusty_sword', name: 'Rusty Sword' }, { id: 'pistol', name: 'Pistol' }, { id: 'steel_sword', name: 'Steel Sword' }, { id: 'iron_shield', name: 'Iron Shield' }, { id: 'dagger', name: 'Dagger' }, { id: 'fists', name: 'Fists only' }, ] return (

CREATE CHARACTER

setName(e.target.value)} className="char-create-input" maxLength={20} />
{startWeapons.map(w => ( ))}
setFillColor(e.target.value)} className="char-create-color" />
{locations.map(l => ( ))}
) } // ───────────────────────────────────────────────────────────────────────────── // APP // ───────────────────────────────────────────────────────────────────────────── export default function App() { // ── world state const [player, setPlayer] = useState(createCharacter({ id: 'player', name: 'John', age: 24, location: 'A', money: 50, inventory: ['pistol_ammo', 'bouncing_ammo'], equipped: { left: 'pistol', right: 'rusty_sword' }, weaponConditions: { rusty_sword: 65, pistol: 100 }, size: 1.3, weaponMagazines: { pistol: [{ ammoType: 'pistol_ammo' }, { ammoType: 'pistol_ammo' }] }, itemCounts: { pistol_ammo: 20, bouncing_ammo: 10 }, })) const [gameTime, setGameTime] = useState({ day: 1, hour: 8, minute: 0 }) const [weather, setWeather] = useState('clear') const [defeatedNpcs, setDefeatedNpcs] = useState([]) const [characterOverrides, setCharacterOverrides] = useState({}) const liveCharacters = characters.map(c => ({ ...c, ...characterOverrides[c.id] })).filter(c => !defeatedNpcs.includes(c.id)) // ── ui state const [hovered, setHovered] = useState(null) const [selected, setSelected] = useState(null) const [mousePos, setMousePos] = useState({ x: 0, y: 0 }) const [view, setView] = useState({ x: 0, y: 0, scale: 1 }) const [travelAnim, setTravelAnim] = useState(null) const [hoverInfo, setHoverInfo] = useState(null) const [showInventory, setShowInventory] = useState(false) const [showWorldInfo, setShowWorldInfo] = useState(false) const [showWorldMap, setShowWorldMap] = useState(false) const [showSettings, setShowSettings] = useState(false) const [invTab, setInvTab] = useState('health') const [panelTarget, setPanelTarget] = useState('player') const [contextMenu, setContextMenu] = useState(null) const [equipArmChoice, setEquipArmChoice] = useState(null) const [showTrade, setShowTrade] = useState(null) const [tradeTarget, setTradeTarget] = useState(null) const [tradeGive, setTradeGive] = useState({}) const [tradeTake, setTradeTake] = useState({}) const [passTimeOpen, setPassTimeOpen] = useState(false) const [passTimeHours, setPassTimeHours] = useState(1) const [skipAnim, setSkipAnim] = useState(null) const [zoomAnim, setZoomAnim] = useState(null) // ── combat state const [combat, setCombat] = useState(null) const [combatTime, setCombatTime] = useState(0) const [displayTime, setDisplayTime] = useState(0) // timerActive: drives the timer — kept as a ref only (no re-render needed) // so the rAF loop sees it synchronously on the very next frame. // ── combat ui const [expandedWeapon, setExpandedWeapon] = useState([]) const [weaponsOpen, setWeaponsOpen] = useState([]) const [itemsOpen, setItemsOpen] = useState([]) const [movementOpen, setMovementOpen] = useState([]) const [limbSelect, setLimbSelect] = useState(null) const [selectedTarget, setSelectedTarget] = useState(null) const [selectedSkill, setSelectedSkill] = useState(null) const [hoveredSkill, setHoveredSkill] = useState(null) const [hoveredEnemy, setHoveredEnemy] = useState(null) const [selectedEnemy, setSelectedEnemy] = useState(0) const [attackEnemy, setAttackEnemy] = useState(null) const [moveMode, setMoveMode] = useState(null) const [currentActor, setCurrentActor] = useState(null) // ── animation state const [animations, setAnimations] = useState([]) const [hitShake, setHitShake] = useState(null) const [minimapView, setMinimapView] = useState({ x: 0, y: 0, scale: 1 }) // ── ui busy (mirrors ref-based action state for re-renders) const [busy, setBusy] = useState(false) // ── aiming mode (pistol skill selected, waiting for minimap click) const [aimMode, setAimMode] = useState(false) // ── reload ammo picker removed; uses selectedSkill === 'Load' inline // ── projectiles (modular projectile framework) const [projectiles, setProjectiles] = useState([]) // ── hovered character for arrow glow const [hoveredChar, setHoveredChar] = useState(null) // ── replay const [fightRecord, setFightRecord] = useState(null) const [replayActive, setReplayActive] = useState(false) // ── menu const [showMenu, setShowMenu] = useState(true) const [showCharCreate, setShowCharCreate] = useState(false) // ── refs (live values for rAF callbacks) const combatRef = useRef(null); combatRef.current = combat const combatTimeRef = useRef(0); combatTimeRef.current = combatTime const displayTimeRef = useRef(0); displayTimeRef.current = displayTime const timerActiveRef = useRef(false) const [timerRunning, setTimerRunning] = useState(false) const prevBusyRef = useRef(false) const speedRef = useRef(1) 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()) // ── 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', ... const characterActionsRef = useRef({}) // { [charId]: action } const characterDirsRef = useRef({}) // { [charId]: {dx,dy} } const characterPositionsRef = useRef({}) // { [charId]: {x,y} } const recordingRef = useRef(null) const viewRef = useRef(null); viewRef.current = view const svgRef = useRef(null) const isPanning = useRef(false) const panStart = useRef({ x: 0, y: 0 }) const minimapPanRef = useRef(false) const minimapPanStart = useRef({ x: 0, y: 0 }) const minimapMovedRef = useRef(false) const replayElapsedRef = useRef(0) // ───────────────────────────────────────────────────────────────────────── // DERIVED // ───────────────────────────────────────────────────────────────────────── const locMap = {}; locations.forEach(l => { locMap[l.id] = l }) const weatherLabels = { clear: 'Clear', cloudy: 'Cloudy', rain: 'Rain', storm: 'Storm' } const getDateString = (day) => { const MONTHS = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'] const DAYS = [31,28,31,30,31,30,31,31,30,31,30,31] let d = day, m = 5 // June while (d > DAYS[m]) { d -= DAYS[m]; m++; if (m > 11) m = 0 } return `${MONTHS[m]} ${d}` } const playerHp = totalHp(player.integrity) // liveCharacters defined above with characterOverrides const connectedTo = (locId) => { const result = [] for (const c of connections) { if (c.from === locId) result.push(c.to) if (c.to === locId) result.push(c.from) } return result } // ───────────────────────────────────────────────────────────────────────── // ANIMATION SYSTEM — modular & extensible // ───────────────────────────────────────────────────────────────────────── // To add a new animation type: // 1. Add an entry to ANIM_TYPES // 2. Add a config to ANIM_CFG { duration, hitAt } // 3. Add a factory function that creates the animation object // 4. Add a case to getAnimPos() for rendering // 5. Call the factory when creating the animation // ───────────────────────────────────────────────────────────────────────── const ANIM_TYPES = { SLASH: 'slash', PIERCE_STRIKE: 'pierce_strike', FIST_STRIKE: 'fist_strike', FIST_HOOK: 'fist_hook', SHOOT: 'shoot', } const ANIM_CFG = { [ANIM_TYPES.SLASH]: { duration: 0.3, hitAt: 0.15 }, [ANIM_TYPES.PIERCE_STRIKE]: { duration: 0.3, hitAt: 0.15 }, [ANIM_TYPES.FIST_STRIKE]: { duration: 0.25, hitAt: 0.1 }, [ANIM_TYPES.FIST_HOOK]: { duration: 0.3, hitAt: 0.15 }, [ANIM_TYPES.SHOOT]: { duration: 0.5, hitAt: 0.5 }, } // ───────────────────────────────────────────────────────────────────────── // MODULAR PROJECTILES FRAMEWORK // Types, config, factory, and step function. // ───────────────────────────────────────────────────────────────────────── const PROJ_TYPES = { BULLET: 'bullet', } const PROJ_CFG = { [PROJ_TYPES.BULLET]: { speed: 800, hitRadius: 10 }, } function createProjectile(startPos, endPos, actor, weaponId, skillName, ammoType, charId) { const dx = endPos.x - startPos.x const dy = endPos.y - startPos.y const clickDist = Math.sqrt(dx * dx + dy * dy) || 1 const range = weaponMap[weaponId]?.skills?.find(s => s.name === skillName)?.range ?? 60 return { id: `proj-${Date.now()}`, type: PROJ_TYPES.BULLET, actor, charId: charId ?? 'player', startPos: { ...startPos }, endPos: { ...endPos }, pos: { ...startPos }, distTraveled: 0, dist: Math.max(clickDist, range), dir: { x: dx / clickDist, y: dy / clickDist }, weaponId, skillName, ammoType, hitFired: false, } } /** * 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 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; 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; break } } if (hit) break } if (hit) 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) { if (!cc) return null const isPlayerSide = action.actor === 'player' || action.actor === 'party' const attackerId = action.charId const targetId = action.targetCharId ?? (isPlayerSide ? action.enemyCharId : (cc.partyIds?.[0] ?? cc.playerEntityId)) const attacker = cc.characters[attackerId] const target = cc.characters[targetId] const startPos = attacker ? { ...attacker.pos } : { x: 20, y: 75 } const endPos = target ? { ...target.pos } : { x: 20, y: 75 } const weapon = weaponMap[action.weaponId] const skill = weapon?.skills?.find(s => s.name === action.skillName) const damageType = skill?.damageType ?? 'blunt' const isFist = action.weaponId === 'fists' || action.weaponId === 'left_arm' || action.weaponId === 'right_arm' const type = isFist ? (action.weaponId === 'right_arm' ? ANIM_TYPES.FIST_HOOK : ANIM_TYPES.FIST_STRIKE) : damageType === 'slash' ? ANIM_TYPES.SLASH : ANIM_TYPES.PIERCE_STRIKE return new AnimAction({ id: `${action.actor}-${targetId}-${Date.now()}`, type, actor: action.actor, charId: action.charId, startPos, endPos, elapsed: 0, hitFired: false, targetPart: action.targetPart, weaponId: action.weaponId, skillName: action.skillName, dmg: action.dmg, severity: action.severity, injType: action.injType, injLabel: action.injLabel, attackerParts: action.attackerParts, attackerInjuries: action.attackerInjuries, targetParts: action.targetParts, targetInjuries: action.targetInjuries, enemyCharId: action.enemyCharId, targetCharId: action.targetCharId, }) } /** * Slash: arc toward the target (perpendicular offset). */ function getSlashPos(anim) { const { hitAt, duration } = ANIM_CFG[ANIM_TYPES.SLASH] const t = anim.elapsed < hitAt ? anim.elapsed / hitAt : anim.elapsed < duration ? 1 - (anim.elapsed - hitAt) / (duration - hitAt) : 0 const dx = anim.endPos.x - anim.startPos.x const dy = anim.endPos.y - anim.startPos.y const dist = Math.sqrt(dx * dx + dy * dy) || 1 const nx = -dy / dist const ny = dx / dist const arcHeight = Math.sin(t * Math.PI) * 8 return { x: anim.startPos.x + dx * t + nx * arcHeight, y: anim.startPos.y + dy * t + ny * arcHeight, } } /** * Pierce / blunt: straight thrust toward the target. */ function getPierceStrikePos(anim) { const { hitAt, duration } = ANIM_CFG[ANIM_TYPES.PIERCE_STRIKE] const t = anim.elapsed < hitAt ? anim.elapsed / hitAt : anim.elapsed < duration ? 1 - (anim.elapsed - hitAt) / (duration - hitAt) : 0 return { x: anim.startPos.x + (anim.endPos.x - anim.startPos.x) * t, y: anim.startPos.y + (anim.endPos.y - anim.startPos.y) * t, } } /** * Fist jab: wind back slightly, snap forward to target, return. */ function getFistStrikePos(anim) { const { hitAt, duration } = ANIM_CFG[ANIM_TYPES.FIST_STRIKE] const t = Math.min(anim.elapsed / duration, 1) let progress if (anim.elapsed < hitAt) { const p = anim.elapsed / hitAt progress = -0.15 + 1.15 * p } else { const p = (anim.elapsed - hitAt) / (duration - hitAt) progress = 1 - p * p } const dx = anim.endPos.x - anim.startPos.x const dy = anim.endPos.y - anim.startPos.y return { x: anim.startPos.x + dx * progress, y: anim.startPos.y + dy * progress, } } /** * Fist hook: wide curved arc — sweeps out to the side then into the target. */ function getFistHookPos(anim) { const { hitAt, duration } = ANIM_CFG[ANIM_TYPES.FIST_HOOK] const t = anim.elapsed < hitAt ? anim.elapsed / hitAt : anim.elapsed < duration ? 1 - (anim.elapsed - hitAt) / (duration - hitAt) : 0 const mid = { x: (anim.startPos.x + anim.endPos.x) / 2, y: (anim.startPos.y + anim.endPos.y) / 2, } const dx = anim.endPos.x - anim.startPos.x const dy = anim.endPos.y - anim.startPos.y const dist = Math.sqrt(dx * dx + dy * dy) || 1 const nx = -dy / dist const ny = dx / dist const arcWidth = Math.sin(t * Math.PI) * 12 return { x: anim.startPos.x + dx * t + nx * arcWidth, y: anim.startPos.y + dy * t + ny * arcWidth, } } /** * 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 parts = ['head', 'torso', 'leftArm', 'rightArm', 'leftLeg', 'rightLeg'] const part = parts[Math.floor(Math.random() * parts.length)] setCombat(c => { const characters = { ...c.characters } const e = { ...characters[charId] } const integrity = { ...e.integrity } const injuries = { ...(e.injuries ?? { head: [], torso: [], leftArm: [], rightArm: [], leftLeg: [], rightLeg: [] }) } integrity[part] = Math.max(0, (integrity[part] ?? 100) - dmg) 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 const actorId = currentActor ?? 'player' const startPos = characterPositionsRef.current[actorId] ?? combat.characters[actorId]?.pos ?? combat.characters[combat.playerEntityId].pos characterActionsRef.current[actorId] = new AnimAction({ type: 'shoot', startPos: { x: startPos.x, y: startPos.y }, endPos: { x: tx, y: ty }, elapsed: 0 }) setLimbSelect(null); setMoveMode(null); setAimMode(false); setSelectedSkill(null); setCurrentActor(null) } /** * Dispatch: get the current position of any animation type. * Returns { x, y } or null if the type is unknown. */ function getAnimPos(anim) { switch (anim?.type) { case ANIM_TYPES.SLASH: return getSlashPos(anim) case ANIM_TYPES.PIERCE_STRIKE: return getPierceStrikePos(anim) case ANIM_TYPES.FIST_STRIKE: return getFistStrikePos(anim) case ANIM_TYPES.FIST_HOOK: return getFistHookPos(anim) default: return null } } class AnimAction { constructor(data) { Object.assign(this, data); this.elapsed = this.elapsed ?? 0; this.hitFired = this.hitFired ?? false } step(dt) { const cfg = ANIM_CFG[this.type] if (!cfg) return true this.elapsed += dt return this.elapsed >= cfg.duration } } class TurnAction { constructor(data) { Object.assign(this, data) } step(dt) { const dir = this.getDir() const tgt = this.targetDir if (dir.dx === tgt.dx && dir.dy === tgt.dy) return true const curAngle = Math.atan2(dir.dy, dir.dx) const tgtAngle = Math.atan2(tgt.dy, tgt.dx) let delta = tgtAngle - curAngle while (delta > Math.PI) delta -= 2 * Math.PI while (delta < -Math.PI) delta += 2 * Math.PI const maxStep = TURN_SPEED * dt if (Math.abs(delta) <= maxStep) { this.setDir({ dx: tgt.dx, dy: tgt.dy }) return true } const newAngle = curAngle + Math.sign(delta) * maxStep this.setDir({ dx: Math.cos(newAngle), dy: Math.sin(newAngle) }) return false } } class SeqAction { constructor(data) { Object.assign(this, data); this.index = 0 } step(dt) { while (this.index < this.actions.length) { const done = this.actions[this.index].step(dt) if (!done) return false this.index++ } return true } } class ParallelAction { constructor(data) { Object.assign(this, data) } step(dt) { let moveDone = false for (const a of this.actions) { if (a.step(dt) && a instanceof MoveAction) moveDone = true } return moveDone } } class MoveAction { constructor(data) { Object.assign(this, data); this.cx = this.fromX; this.cy = this.fromY } step(dt) { const steps = Math.max(1, Math.round(dt * 60)); const blk = this.blocks ?? [] for (let i = 0; i < steps; i++) { const next = computeMove({ x: this.cx, y: this.cy }, { x: this.toX, y: this.toY }, this.speed * dt * 60 / steps, blk) if (next.x === this.cx && next.y === this.cy) return true this.cx = next.x; this.cy = next.y this.setPos(next) if (next.x === this.toX && next.y === this.toY) return true } return false } } 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?.partyIds?.[0] ?? 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 = { ...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?.partyIds?.[0] ?? '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 = { ...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 })) } } } // ───────────────────────────────────────────────────────────────────────── // TIMER — ticks while timerActive is true, updates all character actions // ───────────────────────────────────────────────────────────────────────── useEffect(() => { if (!combat) { setDisplayTime(0); return } let lastTime = performance.now() let rafId const tick = () => { rafId = requestAnimationFrame(tick) const now = performance.now() // ── Replay mode ── if (replayActive) { const rawDt = (now - lastTime) / 1000 const dt = rawDt * speedRef.current lastTime = now const rec = fightRecord if (rec) { replayElapsedRef.current += dt const lastFrame = rec.frames[rec.frames.length - 1] const idx = rec.frames.findLastIndex(f => f.time <= replayElapsedRef.current) if (idx >= 0) { const f = rec.frames[idx] setCombat(f.combat) setPlayer(p => ({ ...p, integrity: f.playerIntegrity, injuries: f.playerInjuries, equipped: f.playerEquipped ?? p.equipped, weaponConditions: f.playerWeaponConditions ?? p.weaponConditions })) if (f.playerDir) characterDirsRef.current['player'] = f.playerDir setAnimations(Array.isArray(f.animations) ? f.animations : []) setProjectiles(Array.isArray(f.projectiles) ? f.projectiles : []) setHitShake(f.hitShake ?? null) } setCombatTime(replayElapsedRef.current) setDisplayTime(replayElapsedRef.current) if (replayElapsedRef.current >= lastFrame.time) setReplayActive(false) } return } // ── Effects (run every frame, independent of timer) ── setHitShake(prev => { if (!prev) return prev return (performance.now() - prev.start) / 1000 >= 0.3 ? null : prev }) // ── Auto timer on busy state transitions; manual toggle via spacebar ── { const cc = combatRef.current const partyIds = cc?.partyIds ?? [cc?.playerEntityId].filter(Boolean) const allBusy = partyIds.length > 0 && partyIds.every(cid => { const ch = cc.characters[cid] return isDead(ch?.integrity) || characterActionsRef.current[cid] != null }) const wasBusy = prevBusyRef.current prevBusyRef.current = allBusy setBusy(allBusy) if (allBusy && !wasBusy && !timerActiveRef.current && partyIds.some(cid => !isDead(cc.characters[cid]?.integrity))) { timerActiveRef.current = true setTimerRunning(true) } if (!allBusy && wasBusy && timerActiveRef.current) { timerActiveRef.current = false setTimerRunning(false) } } // ── Stop timer if all party members die ── { const cc = combatRef.current if (cc && timerActiveRef.current) { const partyIds = cc.partyIds ?? [cc.playerEntityId].filter(Boolean) if (partyIds.every(cid => isDead(cc.characters[cid]?.integrity))) { stopTimer() } } } // dt is zero when the timer is off — animations don't play if (!timerActiveRef.current) { lastTime = now setAnimations([]) return } const rawDt = (now - lastTime) / 1000 const dt = rawDt * speedRef.current lastTime = now deadThisTickRef.current = new Set() setCombatTime(t => t + dt) setDisplayTime(t => t + dt) setGameTime(gt => { let minute = (gt.minute ?? 0) + dt / 60 let extraH = Math.floor(minute / 60) minute = minute % 60 let h = gt.hour + extraH let d = gt.day if (h >= 24) { d += Math.floor(h / 24); h = h % 24 } return { day: d, hour: h, minute } }) // ── Build fresh combat snapshot with live positions ── 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 characters — clear their action and move on if (isDead(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 // Sync move action start position from live ref if (rawAnim.type === 'move') { const cp = characterPositionsRef.current[cid] if (cp) { rawAnim.cx = cp.x; rawAnim.cy = cp.y } } 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 ── // Enemy move → attack transition (skip party members) if (rawAnim.type === 'move' && cid !== playerCharId && !(cc.partyIds ?? []).includes(cid)) { const e = fresh?.characters[cid] ?? cc.characters[cid] const moveSub = rawAnim.actions?.find(a => a instanceof MoveAction) const finalPos = moveSub ? { x: moveSub.toX, y: moveSub.toY } : { x: rawAnim.toX, y: rawAnim.toY } if (e && !isDead(e.integrity)) { const partyIds = cc.partyIds ?? [cc.playerEntityId].filter(Boolean) let targetId = null, minDist = Infinity for (const pid of partyIds) { const pch = (fresh ?? cc).characters[pid] if (!pch || isDead(pch.integrity) || deadThisTickRef.current.has(pid)) continue const d = Math.sqrt((pch.pos.x - finalPos.x) ** 2 + (pch.pos.y - finalPos.y) ** 2) if (d < minDist) { minDist = d; targetId = pid } } 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 } } } } characterActionsRef.current[cid] = null } else { if (ANIM_CFG[rawAnim.type]) { activeAnims.push(rawAnim) } } } } // ── Run enemy AI every frame for idle enemies ── if (fresh) { const actions = runEnemyAI(fresh, playerRef.current.integrity, playerRef.current.injuries, deadThisTickRef.current) for (const act of actions) { if (act.type === 'attack' && !characterActionsRef.current[act.charId]) { characterActionsRef.current[act.charId] = createWeaponStrikeAnim(act, fresh) } else if (act.type === 'move' && !characterActionsRef.current[act.charId]) { 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, setPos: p => { setCombat(c => ({ ...c, characters: { ...c.characters, [act.charId]: { ...c.characters[act.charId], pos: { ...p } } } })) characterPositionsRef.current[act.charId] = p } }) ] }) } } } // ── Step projectiles ── setProjectiles(prev => stepProjectiles(prev, dt, combatRef.current)) // ── Update rendering state for active animations ── setAnimations(activeAnims) } rafId = requestAnimationFrame(tick) return () => cancelAnimationFrame(rafId) }, [combat, replayActive]) // ───────────────────────────────────────────────────────────────────────── // TRAVEL ANIMATION (world map) // ───────────────────────────────────────────────────────────────────────── useEffect(() => { if (!travelAnim) return if (travelAnim.startTs === null) { setTravelAnim(a => ({ ...a, startTs: performance.now() })); return } const frame = requestAnimationFrame(() => { const progress = Math.min(1, (performance.now() - travelAnim.startTs) / 1000) setTravelAnim(a => ({ ...a, progress })) if (progress >= 1) { setPlayer(p => ({ ...p, location: travelAnim.toId })); setTravelAnim(null); enterLocation(travelAnim.toId) } }) return () => cancelAnimationFrame(frame) }, [travelAnim]) // ───────────────────────────────────────────────────────────────────────── // COMBAT OVER // ───────────────────────────────────────────────────────────────────────── useEffect(() => { if (!combat || replayActive) return const enemyIds = combat.enemyIds ?? [] if (enemyIds.length === 0) return const partyIds = combat.partyIds ?? [combat.playerEntityId].filter(Boolean) const allPartyDead = partyIds.every(cid => isDead(combat.characters[cid]?.integrity)) const allEnemiesDead = !enemyIds.some(eid => !isDead(combat.characters[eid].integrity)) if (allPartyDead || allEnemiesDead) endFight() }, [combat, player.integrity, playerHp]) // ───────────────────────────────────────────────────────────────────────── // KEYBINDS // ───────────────────────────────────────────────────────────────────────── // ───────────────────────────────────────────────────────────────────────── // SNAPSHOT (recording) // ───────────────────────────────────────────────────────────────────────── const pushSnapshot = () => { if (!recordingRef.current || !combat) return recordingRef.current.frames.push({ time: combatTime, combat: JSON.parse(JSON.stringify(combat)), playerIntegrity: JSON.parse(JSON.stringify(player.integrity)), playerInjuries: JSON.parse(JSON.stringify(player.injuries)), playerEquipped: { ...player.equipped }, playerWeaponConditions: { ...player.weaponConditions }, playerDir: { ...(characterDirsRef.current['player'] ?? { dx: 0, dy: 1 }) }, animations: JSON.parse(JSON.stringify(animations)), projectiles: JSON.parse(JSON.stringify(projectiles)), hitShake: hitShake ? { ...hitShake } : null, }) } useEffect(() => { if (!recordingRef.current || !combat || replayActive || !timerActiveRef.current) return const prev = recordingRef.current.frames[recordingRef.current.frames.length - 1] if (JSON.stringify(prev.combat) !== JSON.stringify(combat) || JSON.stringify(prev.playerIntegrity) !== JSON.stringify(player.integrity)) { pushSnapshot() } }, [combat, player.integrity, player.injuries]) // ───────────────────────────────────────────────────────────────────────── // WORLD CLOCK (outside combat) // ───────────────────────────────────────────────────────────────────────── useEffect(() => { if (combat) return const id = setInterval(() => setGameTime(gt => { let h = gt.hour + 1, d = gt.day; if (h >= 24) { h = 0; d++ } return { day: d, hour: h, minute: 0 } }), 5000) return () => clearInterval(id) }, [combat]) useEffect(() => { if (combat) return const id = setInterval(() => { const types = ['clear', 'clear', 'cloudy', 'cloudy', 'rain', 'storm'] setWeather(types[Math.floor(Math.random() * types.length)]) }, 30000) return () => clearInterval(id) }, [combat]) // ───────────────────────────────────────────────────────────────────────── // SKIP TIME ANIMATION // ───────────────────────────────────────────────────────────────────────── useEffect(() => { if (!skipAnim) return const { current: start, target, start: startTime } = skipAnim const duration = (target - start) * 2000 const frame = requestAnimationFrame(function tick() { const elapsed = performance.now() - startTime const t = Math.min(elapsed / duration, 1) setSkipAnim(s => ({ ...s, current: start + (target - start) * t })) if (t >= 1) setSkipAnim(null) else requestAnimationFrame(tick) }) return () => cancelAnimationFrame(frame) }, [skipAnim]) // ───────────────────────────────────────────────────────────────────────── // ZOOM ANIMATION // ───────────────────────────────────────────────────────────────────────── useEffect(() => { if (!zoomAnim) return const frame = requestAnimationFrame(function tick() { const t = Math.min((performance.now() - zoomAnim.start) / 300, 1) const ease = 1 - Math.pow(1 - t, 3) setView({ x: zoomAnim.from.x + (zoomAnim.to.x - zoomAnim.from.x) * ease, y: zoomAnim.from.y + (zoomAnim.to.y - zoomAnim.from.y) * ease, scale: zoomAnim.from.scale + (zoomAnim.to.scale - zoomAnim.from.scale) * ease, }) if (t >= 1) setZoomAnim(null) else requestAnimationFrame(tick) }) return () => cancelAnimationFrame(frame) }, [zoomAnim]) // ───────────────────────────────────────────────────────────────────────── // REPLAY // ───────────────────────────────────────────────────────────────────────── const startReplay = () => { setReplayActive(true) const rec = fightRecord const f0 = rec.frames[0] setCombat(f0.combat); setPlayer(p => ({ ...p, integrity: f0.playerIntegrity, injuries: f0.playerInjuries, equipped: f0.playerEquipped ?? p.equipped, weaponConditions: f0.playerWeaponConditions ?? p.weaponConditions })) characterDirsRef.current['player'] = f0.playerDir ?? { dx: 0, dy: 1 } setAnimations(Array.isArray(f0.animations) ? f0.animations : []); setProjectiles(Array.isArray(f0.projectiles) ? f0.projectiles : []); setHitShake(f0.hitShake ?? null) setCombatTime(0); setDisplayTime(0); characterActionsRef.current = {} } // ───────────────────────────────────────────────────────────────────────── // COMBAT ACTIONS // ───────────────────────────────────────────────────────────────────────── const makeEnemy = (npc, index) => { return createCharacter({ ...npc, id: npc.id ?? `enemy_${index}`, pos: { x: 120 + index * 20, y: 60 + index * 20 }, }) } const startFight = (npcList) => { const list = npcList ?? [] if (list.length === 0) return const enemies = list.map((npc, i) => makeEnemy(npc, i)) const characters = {} // Find party members (player + allies at same location) const partyCharIds = ['player'] const livePartyChars = liveCharacters.filter(c => c.id !== 'player' && c.location === player.location && !isDead(c.integrity) && c.equipped) for (const pc of livePartyChars) { partyCharIds.push(pc.id) } characters['player'] = createCharacter({ id: 'player', name: player.name, pos: { x: 20, y: 75 }, integrity: { ...player.integrity }, injuries: JSON.parse(JSON.stringify(player.injuries)), equipped: { ...player.equipped }, weaponConditions: { ...player.weaponConditions }, weaponMagazines: { ...player.weaponMagazines }, itemCounts: { ...player.itemCounts }, }) for (const pc of livePartyChars) { characters[pc.id] = createCharacter({ id: pc.id, name: pc.name, pos: { x: 30 + partyCharIds.indexOf(pc.id) * 20, y: 80 }, integrity: freshBody(), injuries: freshInjuries(), equipped: { ...(pc.equipped ?? { left: 'fists', right: 'fists' }) }, weaponConditions: { ...(pc.weaponConditions ?? {}) }, weaponMagazines: { ...(pc.weaponMagazines ?? {}) }, itemCounts: { ...(pc.itemCounts ?? {}) }, inventory: [...(pc.inventory ?? [])], size: pc.size ?? 1, fillColor: pc.fillColor, }) } for (const e of enemies) { const cid = e.id characters[cid] = { ...e, pos: { ...e.pos } } } const enemyIds = enemies.map(e => e.id) const initCombat = { playerEntityId: 'player', partyIds: partyCharIds, characters, enemyIds, log: [`You engage ${enemies.map(e => e.name).join(' and ')} in combat!`], blocks: generateLocationBlocks(player.location), } setCombat(initCombat) stopTimer(); setBusy(false); prevBusyRef.current = false setCombatTime(0); setDisplayTime(0) characterActionsRef.current = {} characterPositionsRef.current = {} characterDirsRef.current = {} for (const cid of Object.keys(characters)) { characterActionsRef.current[cid] = null characterPositionsRef.current[cid] = { ...characters[cid].pos } characterDirsRef.current[cid] = { dx: 0, dy: 1 } } setSelectedEnemy(0); setReplayActive(false); setCurrentActor(null) setExpandedWeapon(['player']); setAnimations([]); setHitShake(null); setMoveMode(null) recordingRef.current = { initialCombat: JSON.parse(JSON.stringify(initCombat)), frames: [{ time: 0, combat: JSON.parse(JSON.stringify(initCombat)), playerIntegrity: freshBody(), playerInjuries: freshInjuries(), playerEquipped: player.equipped, playerWeaponConditions: player.weaponConditions, playerDir: { dx: 0, dy: 1 }, animations: [], projectiles: [], hitShake: null }], } } /** Enter a location — creates a combat view with all NPCs present as non-hostile */ const enterLocation = (locationId) => { const characters = {} characters['player'] = createCharacter({ id: 'player', name: player.name, pos: { x: 20, y: 75 }, integrity: { ...player.integrity }, injuries: JSON.parse(JSON.stringify(player.injuries)), equipped: { ...player.equipped }, weaponConditions: { ...player.weaponConditions }, weaponMagazines: { ...player.weaponMagazines }, itemCounts: { ...player.itemCounts }, }) const partyCharIds = ['player'] const npcIds = [] const liveChars = liveCharacters.filter(c => c.id !== 'player' && c.location === locationId && (!c.integrity || !isDead(c.integrity))) let xOff = 50 for (const lc of liveChars) { if (lc.equipped) { partyCharIds.push(lc.id) characters[lc.id] = createCharacter({ id: lc.id, name: lc.name, pos: { x: xOff, y: 130 }, integrity: { ...lc.integrity }, injuries: JSON.parse(JSON.stringify(lc.injuries ?? player.injuries)), equipped: { ...lc.equipped }, weaponConditions: { ...lc.weaponConditions }, weaponMagazines: { ...lc.weaponMagazines }, itemCounts: { ...lc.itemCounts }, inventory: [...(lc.inventory ?? [])], fillColor: lc.fillColor, }) } else { npcIds.push(lc.id) 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: {}, inventory: [...(lc.inventory ?? [])], fillColor: lc.fillColor, }) } xOff += 25 } const locName = locMap[locationId]?.name ?? locationId const initCombat = { playerEntityId: 'player', partyIds: partyCharIds, npcIds, characters, enemyIds: [], log: [`You arrive at ${locName}.`], blocks: generateLocationBlocks(locationId), } setCombat(initCombat) setCombatTime(0); setDisplayTime(0) characterActionsRef.current = {} characterPositionsRef.current = {} for (const cid of Object.keys(characters)) { characterPositionsRef.current[cid] = { ...characters[cid].pos } } 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) } const endFight = () => { if (recordingRef.current) { pushSnapshot(); setFightRecord(recordingRef.current); recordingRef.current = null } stopTimer() setCombatTime(0); setDisplayTime(0) characterActionsRef.current = {} setProjectiles([]) if (combat) { const deadIds = combat.enemyIds.map(id => combat.characters[id]).filter(e => isDead(e.integrity)).map(e => e.id) if (deadIds.length > 0) setDefeatedNpcs(prev => [...new Set([...prev, ...deadIds])]) // Sync party member combat state back to world const cc = combatRef.current if (cc) { setCharacterOverrides(prev => { const next = { ...prev } for (const pid of cc.partyIds ?? []) { 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 cc.partyIds ?? []) { if (pid === 'player') continue if (isDead(cc.characters[pid]?.integrity)) { setDefeatedNpcs(prev => [...new Set([...prev, pid])]) } } } } } /** Execute a player attack; queue it for execution. */ const executeAttack = () => { if (!limbSelect || !selectedTarget) { setCombat(c => ({ ...c, log: [...c.log, `DBG: early return limbSelect=${limbSelect} target=${selectedTarget}`] })) return } const actorId = currentActor ?? 'player' const cc = combatRef.current if (!cc) { setCombat(c => ({ ...c, log: [...c.log, 'DBG: no combat'] })); return } const actorChar = cc.characters[actorId] if (!actorChar || isDead(actorChar.integrity)) { setCombat(c => ({ ...c, log: [...c.log, 'DBG: actor dead'] })); return } const ei = attackEnemy ?? selectedEnemy const enemyId = cc.enemyIds[ei] const enemy = cc.characters[enemyId] if (!enemy || isDead(enemy.integrity)) { setCombat(c => ({ ...c, log: [...c.log, `DBG: bad enemy ei=${ei} id=${enemyId} enemy=${!!enemy} dead=${enemy ? isDead(enemy.integrity) : '?'}`] })) return } const weaponId = limbSelect const actorPos = characterPositionsRef.current[actorId] ?? actorChar.pos if (!canReach(actorPos, enemy.pos, weaponId, selectedSkill)) { const d = Math.sqrt( (actorPos.x - enemy.pos.x)**2 + (actorPos.y - enemy.pos.y)**2 ) setCombat(c => ({ ...c, log: [...c.log, `Too far (${d.toFixed(0)} > ${getSkillRange(weaponId, selectedSkill)})`] })) return } const action = buildPlayerAttack({ weaponId, skillName: selectedSkill, targetPart: selectedTarget, enemyIndex: ei, equippedWeapons: actorChar.equipped ?? { left: 'fists', right: 'fists' }, weaponConditions: actorChar.weaponConditions ?? {}, bodyParts: actorChar.integrity, bodyInjuries: actorChar.injuries, combat: cc, actor: actorId === 'player' ? 'player' : 'party', charId: actorId, }) if (action.type === 'shove_pending') { const enemy2 = cc.characters[action.enemyCharId] if (!enemy2 || isDead(enemy2.integrity)) return const { pos, actualPush } = computeShove(actorPos, enemy2.pos, fresh.blocks) setCombat(c => ({ ...c, characters: { ...c.characters, [action.enemyCharId]: { ...enemy2, pos } }, log: [...c.log, `${actorChar.name} shoves ${enemy2.name} back ${Math.round(actualPush)} units!`], })) setLimbSelect(null); setSelectedSkill(null); setSelectedTarget(null); setAttackEnemy(null); setCurrentActor(null) return } const anim = createWeaponStrikeAnim(action, combatRef.current) characterActionsRef.current[actorId] = anim setLimbSelect(null); setSelectedSkill(null); setSelectedTarget(null); setAttackEnemy(null); setCurrentActor(null) } const cancelAttack = () => { setLimbSelect(null); setSelectedSkill(null); setSelectedTarget(null); setAttackEnemy(null); setCurrentActor(null) setAimMode(false) } const startTimer = () => { if (!timerActiveRef.current) { timerActiveRef.current = true setTimerRunning(true) } } const stopTimer = () => { timerActiveRef.current = false setTimerRunning(false) } const toggleTimer = () => { if (timerActiveRef.current) { stopTimer() } else { startTimer() } } // Spacebar toggles timer, Escape cancels aim mode useEffect(() => { const onKey = (e) => { if (e.code === 'Space' && combat) { e.preventDefault(); toggleTimer() } if (e.key === 'Escape' && aimMode) { setAimMode(false); setSelectedSkill(null) } } window.addEventListener('keydown', onKey) return () => window.removeEventListener('keydown', onKey) }, [aimMode, combat]) /** Execute a player move; queue it for execution. */ const startMove = (targetX, targetY) => { if (!combat) return const actorId = currentActor ?? 'player' const ch = combat.characters[actorId] if (!ch) return const curPos = characterPositionsRef.current[actorId] ?? ch.pos const dist = Math.sqrt((targetX - curPos.x) ** 2 + (targetY - curPos.y) ** 2) if (dist < 1) return const maxSpd = ch.maxSpeed ?? 10 const pct = moveMode === 'run' ? 1.0 : 0.5 const speed = maxSpd * pct const moveAction = new MoveAction({ type: 'move', fromX: curPos.x, fromY: curPos.y, toX: targetX, toY: targetY, speed, blocks: combat.blocks, setPos: (p, cid) => { const id = cid || actorId setCombat(c => ({ ...c, characters: { ...c.characters, [id]: { ...c.characters[id], pos: { ...p } } } })) characterPositionsRef.current[id] = p } }) 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 } characterActionsRef.current[actorId] = new ParallelAction({ actions: [ new TurnAction({ targetDir: dir, getDir: () => characterDirsRef.current[actorId] ?? { dx: 0, dy: 1 }, setDir: d => { characterDirsRef.current[actorId] = d } }), moveAction ] }) setMoveMode(null); setCurrentActor(null) } /** Show ammo selection UI based on what's in inventory */ const startReload = () => { if (!combat) return const actorId = currentActor ?? 'player' const cc = combatRef.current const actorChar = cc?.characters[actorId] const mag = actorId === 'player' ? magazineRef.current : (actorChar?.weaponMagazines ?? {}) const cur = mag.pistol?.length ?? 0 if (cur >= PISTOL_MAG) { setCombat(c => ({ ...c, log: [...c.log, 'The pistol is already fully loaded.'] })) return } const itemCounts = actorChar?.itemCounts ?? (actorId === 'player' ? playerRef.current.itemCounts : {}) const available = Object.entries(itemCounts) .filter(([id, qty]) => qty > 0 && (id === 'pistol_ammo' || id === 'bouncing_ammo')) if (available.length === 0) { setCombat(c => ({ ...c, log: [...c.log, 'No ammo in inventory!'] })) return } setSelectedSkill('Load'); setAttackEnemy(null); setSelectedTarget(null) } /** Load one round of the given ammo type into the weapon */ const loadAmmoType = (ammoId) => { const actorId = currentActor ?? 'player' const cc = combatRef.current const actorChar = cc?.characters[actorId] const mag = actorId === 'player' ? magazineRef.current : (actorChar?.weaponMagazines ?? { pistol: [] }) const itemCounts = actorChar?.itemCounts ?? (actorId === 'player' ? playerRef.current.itemCounts : {}) const cur = mag.pistol?.length ?? 0 const reserve = itemCounts[ammoId] ?? 0 if (reserve <= 0) return const ammoName = itemMap[ammoId]?.name ?? ammoId if (actorId === 'player') { const newMag = { pistol: [...magazineRef.current.pistol, { ammoType: ammoId }] } magazineRef.current = newMag setPlayer(p => { const newCount = (p.itemCounts[ammoId] ?? 0) - 1 const counts = { ...p.itemCounts, [ammoId]: newCount } if (newCount <= 0) delete counts[ammoId] return { ...p, weaponMagazines: { ...p.weaponMagazines, ...newMag }, itemCounts: counts, } }) setCombat(prev => { const ch = prev.characters['player'] const newCount = (ch?.itemCounts?.[ammoId] ?? 0) - 1 const counts = { ...(ch?.itemCounts ?? {}), [ammoId]: newCount } if (newCount <= 0) delete counts[ammoId] return { ...prev, characters: { ...prev.characters, player: { ...ch, itemCounts: counts }, }, log: [...prev.log, `Loaded 1 round of ${ammoName} into the pistol.`], } }) } else { setCombat(prev => { const ch = prev.characters[actorId] if (!ch) return prev const chMag = ch.weaponMagazines ?? { pistol: [] } const newMag = { pistol: [...(chMag.pistol ?? []), { ammoType: ammoId }] } const newCounts = { ...ch.itemCounts, [ammoId]: (ch.itemCounts?.[ammoId] ?? 0) - 1 } if (newCounts[ammoId] <= 0) delete newCounts[ammoId] return { ...prev, characters: { ...prev.characters, [actorId]: { ...ch, weaponMagazines: { ...ch.weaponMagazines, ...newMag }, itemCounts: newCounts }, }, log: [...prev.log, `${ch.name ?? 'Party member'} loaded 1 round of ${ammoName} into the pistol.`], } }) } setLimbSelect(null); setSelectedSkill(null); setSelectedTarget(null); setAttackEnemy(null) } const unloadPistol = () => { if (!combat) return const actorId = currentActor ?? 'player' const cc = combatRef.current const actorChar = cc?.characters[actorId] const mag = actorId === 'player' ? magazineRef.current : (actorChar?.weaponMagazines ?? { pistol: [] }) const cur = mag.pistol?.length ?? 0 if (cur === 0) { setCombat(c => ({ ...c, log: [...c.log, 'The pistol is already empty.'] })) return } const top = mag.pistol[cur - 1] const ammoId = top.ammoType const ammoName = itemMap[ammoId]?.name ?? ammoId if (actorId === 'player') { const newMag = { pistol: mag.pistol.slice(0, -1) } magazineRef.current = newMag setPlayer(p => ({ ...p, weaponMagazines: { ...p.weaponMagazines, ...newMag }, itemCounts: { ...p.itemCounts, [ammoId]: (p.itemCounts[ammoId] ?? 0) + 1 }, })) setCombat(c => ({ ...c, log: [...c.log, `Unloaded 1 round of ${ammoName} from the pistol.`] })) } else { setCombat(prev => { const ch = prev.characters[actorId] if (!ch) return prev const chMag = ch.weaponMagazines ?? { pistol: [] } const newMag = { pistol: (chMag.pistol ?? []).slice(0, -1) } return { ...prev, characters: { ...prev.characters, [actorId]: { ...ch, weaponMagazines: { ...ch.weaponMagazines, ...newMag }, itemCounts: { ...ch.itemCounts, [ammoId]: (ch.itemCounts?.[ammoId] ?? 0) + 1 }, }, }, log: [...prev.log, `${ch.name ?? 'Party member'} unloaded 1 round of ${ammoName} from the pistol.`], } }) } setLimbSelect(null); setSelectedSkill(null); setSelectedTarget(null); setAttackEnemy(null) } // ───────────────────────────────────────────────────────────────────────── // MAP / ZOOM HELPERS // ───────────────────────────────────────────────────────────────────────── const zoomTo = (factor, mx, my) => { const v = viewRef.current const rect = svgRef.current.getBoundingClientRect() const s = Math.min(rect.width / 800, rect.height / 600) const ox = (rect.width - 800 * s) / 2, oy = (rect.height - 600 * s) / 2 const vbX = (mx - ox) / s, vbY = (my - oy) / s const ns = Math.max(0.3, Math.min(3, v.scale * factor)) const f = ns / v.scale setZoomAnim({ from: { x: v.x, y: v.y, scale: v.scale }, to: { x: vbX * (1 - f) + v.x * f, y: vbY * (1 - f) + v.y * f, scale: ns }, start: performance.now() }) } const zoomIn = () => { const r = svgRef.current.getBoundingClientRect(); zoomTo(1.3, r.width / 2, r.height / 2) } const zoomOut = () => { const r = svgRef.current.getBoundingClientRect(); zoomTo(1/1.3, r.width / 2, r.height / 2) } const handleWheel = (e) => { e.preventDefault(); const r = svgRef.current.getBoundingClientRect(); zoomTo(e.deltaY > 0 ? 1/1.3 : 1.3, e.clientX - r.left, e.clientY - r.top) } const handleBgPointerDown = (e) => { if (e.button !== 0) return const v = viewRef.current, r = svgRef.current.getBoundingClientRect() const s = Math.min(r.width / 800, r.height / 600) const ox = (r.width - 800*s)/2, oy = (r.height - 600*s)/2 isPanning.current = true panStart.current = { x: e.clientX - ox, y: e.clientY - oy, vx: v.x, vy: v.y, s } e.currentTarget.setPointerCapture(e.pointerId) } const handleBgPointerMove = (e) => { if (!isPanning.current) return const v = viewRef.current, r = svgRef.current.getBoundingClientRect() const s = Math.min(r.width / 800, r.height / 600) const ox = (r.width - 800*s)/2, oy = (r.height - 600*s)/2 const dx = (e.clientX - ox - panStart.current.x) / panStart.current.s / v.scale const dy = (e.clientY - oy - panStart.current.y) / panStart.current.s / v.scale setView({ ...v, x: panStart.current.vx + dx, y: panStart.current.vy + dy }) } const handleBgPointerUp = () => { isPanning.current = false } // ───────────────────────────────────────────────────────────────────────── // SHOP // ───────────────────────────────────────────────────────────────────────── const travel = (targetId) => { if (travelAnim) return const from = locations.find(l => l.id === player.location) const to = locations.find(l => l.id === targetId) if (!from || !to) return setTravelAnim({ from, to, fromId: player.location, toId: targetId, startTs: null, progress: 0 }) } const unequipWeapon = (arm) => { const wid = player.equipped[arm] if (wid === 'fists' || wid === 'left_arm' || wid === 'right_arm') return setPlayer(p => ({ ...p, inventory: [...p.inventory, wid], equipped: { ...p.equipped, [arm]: 'fists' } })) } const unequipBoth = () => { const wid = player.equipped.left if (wid === 'fists' || wid === 'left_arm' || wid === 'right_arm') return setPlayer(p => ({ ...p, inventory: [...p.inventory, wid], equipped: { left: 'fists', right: 'fists' } })) } const equipFromInventory = (weaponId, arm) => { setPlayer(p => ({ ...p, inventory: p.inventory.filter(id => id !== weaponId), equipped: arm === 'both' ? { left: weaponId, right: weaponId } : { ...p.equipped, [arm]: weaponId }, weaponConditions: p.weaponConditions[weaponId] !== undefined ? p.weaponConditions : { ...p.weaponConditions, [weaponId]: weaponMap[weaponId].maxCondition }, })) setEquipArmChoice(null) } // ───────────────────────────────────────────────────────────────────────── // DISPLAY HELPERS // ───────────────────────────────────────────────────────────────────────── const partMap = { larm: 'leftArm', rarm: 'rightArm', lleg: 'leftLeg', rleg: 'rightLeg' } const selectLimbTarget = (part) => setSelectedTarget(partMap[part] || part) const skipTotalHours = skipAnim !== null ? skipAnim.current : null const displayDay = skipTotalHours !== null ? Math.floor(skipTotalHours / 24) : gameTime.day const displayHour = skipTotalHours !== null ? Math.floor(skipTotalHours % 24) : gameTime.hour const displayMinute = skipTotalHours !== null ? 0 : (gameTime.minute ?? 0) const dateString = getDateString(displayDay) // busy is React state, updated in the timer loop to reflect refs // ───────────────────────────────────────────────────────────────────────── // RENDER // ───────────────────────────────────────────────────────────────────────── const handleNewGame = () => { setShowMenu(false); setShowCharCreate(true) } const canTrade = (charId) => { if (!combat || charId === 'player') return true const src = combat.characters?.player const tgt = combat.characters?.[charId] if (!src || !tgt) return true const dx = src.x - tgt.x const dy = src.y - tgt.y return Math.sqrt(dx * dx + dy * dy) <= 35 } const startTrade = (targetId) => { setHoverInfo(null); setHoveredChar(null); setContextMenu(null) setShowTrade(true) setTradeTarget(targetId) setTradeGive({}) setTradeTake({}) } if (showMenu) { return (

CITYGAMIN

A game of survival and conquest

) } if (showCharCreate) { return { const { name, weaponId, fillColor, locationId } = opts setPlayer(createCharacter({ id: 'player', name, age: 24, location: locationId, money: 50, inventory: weaponId === 'pistol' ? ['pistol_ammo', 'bouncing_ammo'] : [], equipped: { left: weaponId === 'pistol' ? 'pistol' : 'fists', right: weaponId === 'pistol' ? 'fists' : weaponId }, weaponConditions: weaponId !== 'fists' ? { [weaponId]: weaponMap[weaponId]?.maxCondition ?? 100 } : {}, size: 1.3, weaponMagazines: weaponId === 'pistol' ? { pistol: [{ ammoType: 'pistol_ammo' }, { ammoType: 'pistol_ammo' }] } : {}, itemCounts: weaponId === 'pistol' ? { pistol_ammo: 20, bouncing_ammo: 10 } : {}, fillColor, })) setShowCharCreate(false) setTimeout(() => enterLocation(locationId), 50) }} /> } return (
setMousePos({ x: e.clientX, y: e.clientY })} onContextMenu={e => { e.preventDefault(); setContextMenu(null) }}>
{combat && ( )}
{(!combat || showWorldMap) && (
{(() => { const d = displayDay; const h = displayHour; const min = displayMinute const m = Math.floor(min); const s = Math.floor((min % 1) * 60) return `${getDateString(d)} ${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}` })()}
)} {combat && !showWorldMap && (
{(() => { const min = gameTime.minute ?? 0 const h = gameTime.hour const m = Math.floor(min) const s = Math.floor((min % 1) * 60) const cs = Math.floor((((min % 1) * 60) % 1) * 100) return `${getDateString(gameTime.day)} ${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}.${String(cs).padStart(2,'0')}` })()}
{[1, 0.5, 0.25, 0.1].map(s => ( ))}
)}
{/* ═══════════════════════════════════════════════════════ COMBAT VIEW */} {combat && !showWorldMap && ( <>
{/* ── left: action tree (per party member) */}
{combat && (() => { const partyIds = combat.partyIds ?? [combat.playerEntityId].filter(Boolean) const anyActionPossible = partyIds.some(cid => !isDead(combat.characters[cid]?.integrity)) return anyActionPossible && (
{partyIds.map(cid => { const ch = combat.characters[cid] if (!ch) return null const isDeadChar = isDead(ch.integrity) const charEquipped = ch.equipped ?? { left: 'fists', right: 'fists' } const lw = charEquipped.left, rw = charEquipped.right const charName = ch.name ?? cid return (
setExpandedWeapon(prev => prev.includes(cid) ? prev.filter(x => x !== cid) : [...prev, cid])}> {expandedWeapon.includes(cid) ? '▼' : '▶'} { e.stopPropagation(); setShowInventory(true); setPanelTarget(cid) }}>{charName}{isDeadChar ? ' (Dead)' : ''}
{expandedWeapon.includes(cid) && (
{/* Weapons */}
setWeaponsOpen(prev => prev.includes(cid) ? prev.filter(x => x !== cid) : [...prev, cid])}> {weaponsOpen.includes(cid) ? '▼' : '▶'} Weapons
{weaponsOpen.includes(cid) && (
{(() => { const entries = [] if (lw === 'fists') { entries.push(
setHoverInfo({ type: 'weapon', id: 'left_arm' })} onMouseLeave={() => setHoverInfo(null)} onClick={() => { setCurrentActor(cid); setLimbSelect('left_arm'); setSelectedSkill(null); setSelectedTarget(null); setMoveMode(null); setAimMode(false) }}> Left Arm
) } else if (lw !== rw) { entries.push(
setHoverInfo({ type: 'weapon', id: lw })} onMouseLeave={() => setHoverInfo(null)} onClick={() => { setCurrentActor(cid); setLimbSelect(lw); setSelectedSkill(null); setSelectedTarget(null); setMoveMode(null); setAimMode(false) }}> Left: {weaponMap[lw]?.name ?? lw}
) } if (rw === 'fists') { entries.push(
setHoverInfo({ type: 'weapon', id: 'right_arm' })} onMouseLeave={() => setHoverInfo(null)} onClick={() => { setCurrentActor(cid); setLimbSelect('right_arm'); setSelectedSkill(null); setSelectedTarget(null); setMoveMode(null); setAimMode(false) }}> Right Arm
) } else if (lw !== rw) { entries.push(
setHoverInfo({ type: 'weapon', id: rw })} onMouseLeave={() => setHoverInfo(null)} onClick={() => { setCurrentActor(cid); setLimbSelect(rw); setSelectedSkill(null); setSelectedTarget(null); setMoveMode(null); setAimMode(false) }}> Right: {weaponMap[rw]?.name ?? rw}
) } if (lw === rw && lw !== 'fists' && lw !== 'left_arm' && lw !== 'right_arm') { entries.push(
setHoverInfo({ type: 'weapon', id: lw })} onMouseLeave={() => setHoverInfo(null)} onClick={() => { setCurrentActor(cid); setLimbSelect(lw); setSelectedSkill(null); setSelectedTarget(null); setMoveMode(null); setAimMode(false) }}> Both: {weaponMap[lw]?.name ?? lw}
) } if (lw === 'fists' && rw === 'fists') { entries.push(
setHoverInfo({ type: 'weapon', id: 'fists' })} onMouseLeave={() => setHoverInfo(null)} onClick={() => { setCurrentActor(cid); setLimbSelect('fists'); setSelectedSkill(null); setSelectedTarget(null); setMoveMode(null); setAimMode(false) }}> Fists
) } return entries })()}
)} {/* Items */}
setItemsOpen(prev => prev.includes(cid) ? prev.filter(x => x !== cid) : [...prev, cid])}> {itemsOpen.includes(cid) ? '▼' : '▶'} Items
{itemsOpen.includes(cid) && (
{(ch.inventory ?? []).length === 0 ?
No items
: ch.inventory.map((itemId, i) => { const qty = ch.itemCounts?.[itemId] if (qty !== undefined && qty <= 0) return null return (
{ setShowInventory(true); setPanelTarget(cid) }}> {itemMap[itemId]?.name ?? itemId} {qty !== undefined && ×{qty}}
) }) }
)} {/* Movement */}
setMovementOpen(prev => prev.includes(cid) ? prev.filter(x => x !== cid) : [...prev, cid])}> {movementOpen.includes(cid) ? '▼' : '▶'} Movement
{movementOpen.includes(cid) && (
{ if (!busy) { setCurrentActor(cid); setMoveMode(m => m === 'walk' ? null : 'walk'); setAimMode(false) } }}> Walk
{ if (!busy) { setCurrentActor(cid); setMoveMode(m => m === 'run' ? null : 'run'); setAimMode(false) } }}> Run
{ if (!busy) { setCurrentActor(cid); setMoveMode(m => m === 'turn' ? null : 'turn'); setAimMode(false) } }}> Turn
{moveMode && moveMode !== 'turn' &&
Click on the mini-map to move
} {moveMode === 'turn' &&
Click on the mini-map to face a direction
}
)}
)}
) })}
) })()}
{/* ── center: minimap + log */}
{ if (limbSelect) return minimapPanRef.current = true; minimapMovedRef.current = false minimapPanStart.current = { x: e.clientX, y: e.clientY, vx: minimapView.x, vy: minimapView.y } const svg = e.currentTarget const onMove = ev => { if (!minimapPanRef.current) return const dx = ev.clientX - minimapPanStart.current.x, dy = ev.clientY - minimapPanStart.current.y if (Math.abs(dx) > 2 || Math.abs(dy) > 2) minimapMovedRef.current = true const r = svg.getBoundingClientRect() setMinimapView(v => ({ ...v, x: minimapPanStart.current.vx - (dx / r.width) * 160 / v.scale, y: minimapPanStart.current.vy - (dy / r.height) * 160 / v.scale })) } const onUp = () => { minimapPanRef.current = false; window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp) } window.addEventListener('mousemove', onMove); window.addEventListener('mouseup', onUp) }} onWheel={e => { if (limbSelect) return; e.preventDefault() const d = e.deltaY > 0 ? 0.9 : 1.1 const r = e.currentTarget.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 } }) }} onClick={e => { if (minimapMovedRef.current) { minimapMovedRef.current = false; return } if (limbSelect || (!moveMode && !aimMode)) return const r = e.currentTarget.getBoundingClientRect() const tx = ((e.clientX - r.left) / r.width) * 160 / minimapView.scale + minimapView.x const ty = ((e.clientY - r.top) / r.height) * 160 / minimapView.scale + minimapView.y if (aimMode) { queueShot(tx, ty) return } if (moveMode === 'turn') { const turnActor = currentActor ?? 'player' const pp = combat.characters[turnActor]?.pos ?? combat.characters['player'].pos const dx = tx - pp.x const dy = ty - pp.y const d = Math.sqrt(dx * dx + dy * dy) if (d > 0) { characterActionsRef.current[turnActor] = new TurnAction({ targetDir: { dx: dx / d, dy: dy / d }, getDir: () => characterDirsRef.current[turnActor] ?? { dx: 0, dy: 1 }, setDir: d2 => { characterDirsRef.current[turnActor] = d2 } }) setMoveMode(null); setCurrentActor(null) } return } startMove(tx, ty) }}> {(combat?.blocks ?? []).map((block, i) => )} {(combat?.enemyIds ?? []).map(eid => { const enemy = combat.characters[eid] const dead = isDead(enemy.integrity) const isShaking = hitShake && hitShake.charId === eid && hitShake.actor === 'enemy' const eSize = enemy.size ?? 1 const ex = isShaking ? enemy.pos.x + (Math.random() - 0.5) * 1.5 : enemy.pos.x const ey = isShaking ? enemy.pos.y + (Math.random() - 0.5) * 1.5 : enemy.pos.y return ( { e.stopPropagation(); setHoverInfo({ type: 'enemy', name: enemy.name, attack: enemy.attack, speed: enemy.speed, defeated: isDead(enemy.integrity) }); setHoveredChar(`enemy-${eid}`) }} onMouseLeave={() => { setHoverInfo(null); setHoveredChar(null) }} onClick={e => { e.stopPropagation(); setShowInventory(true); setPanelTarget(eid) }} onContextMenu={e => { e.preventDefault(); e.stopPropagation(); setHoverInfo(null); setHoveredChar(null); setContextMenu({ x: e.clientX, y: e.clientY, charId: eid }) }} /> ) })} {combat && selectedSkill && limbSelect && (() => { const rangeActor = currentActor ?? 'player' const rp = combat.characters[rangeActor]?.pos ?? combat.characters['player'].pos return })()} {/* Party member circles */} {combat && (() => { const partyCircles = combat.partyIds ?? [combat.playerEntityId].filter(Boolean) return partyCircles.map(cid => { const ch = combat.characters[cid] if (!ch) return null const chDead = isDead(ch.integrity) const color = cid === 'player' ? player.fillColor : ch.fillColor return ( setHoveredChar(cid)} onMouseLeave={() => setHoveredChar(null)} onClick={e => { e.stopPropagation(); setShowInventory(true); setPanelTarget(cid) }} onContextMenu={e => { e.preventDefault(); e.stopPropagation(); setHoverInfo(null); setHoveredChar(null); setContextMenu({ x: e.clientX, y: e.clientY, charId: cid }) }} /> ) }) })()} {/* Non-hostile NPC circles */} {combat?.npcIds?.map(nid => { const ch = combat.characters[nid] if (!ch) return null return ( setHoveredChar(nid)} onMouseLeave={() => setHoveredChar(null)} onClick={e => { e.stopPropagation(); setShowInventory(true); setPanelTarget(nid) }} onContextMenu={e => { e.preventDefault(); e.stopPropagation(); setHoverInfo(null); setHoveredChar(null); setContextMenu({ x: e.clientX, y: e.clientY, charId: nid }) }} /> ) })} {/* Weapon circles for party members */} {combat && (() => { const wpnOff = (dir, side, off) => { if (side === 'front') return { x: dir.dx * off, y: dir.dy * off } if (side === 'left') return { x: dir.dy * off, y: -dir.dx * off } return { x: -dir.dy * off, y: dir.dx * off } } const wpnDots = (slots, bx, by) => slots.map(s => ) const isFist = w => w === 'fists' || w === 'left_arm' || w === 'right_arm' const isAtk = t => [ANIM_TYPES.SLASH, ANIM_TYPES.PIERCE_STRIKE, ANIM_TYPES.FIST_STRIKE, ANIM_TYPES.FIST_HOOK].includes(t) const allSlots = [] const partyIds = combat.partyIds ?? [combat.playerEntityId].filter(Boolean) partyIds.forEach(cid => { const ch = combat.characters[cid] if (!ch || isDead(ch.integrity)) return const equipped = ch.equipped ?? { left: 'fists', right: 'fists' } const lw = equipped.left, rw = equipped.right const dir = characterDirsRef.current[cid] ?? { dx: 0, dy: 1 } const off = 7 const shake = hitShake?.charId === cid const bx = shake ? ch.pos.x + (Math.random() - 0.5) * 1.5 : ch.pos.x const by = shake ? ch.pos.y + (Math.random() - 0.5) * 1.5 : ch.pos.y const bothWpn = lw === rw && !isFist(lw) const chPos = ch.pos if (bothWpn) { const anim = animations.find(a => a.charId === cid && isAtk(a.type) && a.weaponId === lw) const p = anim ? { x: getAnimPos(anim).x - chPos.x, y: getAnimPos(anim).y - chPos.y } : wpnOff(dir, 'front', off) allSlots.push({ key: `pw-${cid}-both`, ox: p.x, oy: p.y, fill: '#fbbf24', bx, by }) } else { const lAnim = animations.find(a => a.charId === cid && isAtk(a.type) && (a.weaponId === lw || a.weaponId === 'left_arm')) const rAnim = animations.find(a => a.charId === cid && isAtk(a.type) && (a.weaponId === rw || a.weaponId === 'right_arm')) const bothAnim = animations.find(a => a.charId === cid && isAtk(a.type) && a.weaponId === 'fists') if (!isFist(lw) || lAnim || bothAnim) { const ap = (isFist(lw) && bothAnim) ? bothAnim : lAnim const p = ap ? { x: getAnimPos(ap).x - chPos.x, y: getAnimPos(ap).y - chPos.y } : wpnOff(dir, 'left', off) allSlots.push({ key: `pw-${cid}-left`, ox: p.x, oy: p.y, fill: isFist(lw) ? ch.fillColor : '#fbbf24', bx, by }) } if (!isFist(rw) || rAnim || bothAnim) { const ap = (isFist(rw) && bothAnim) ? bothAnim : rAnim const p = ap ? { x: getAnimPos(ap).x - chPos.x, y: getAnimPos(ap).y - chPos.y } : wpnOff(dir, 'right', off) allSlots.push({ key: `pw-${cid}-right`, ox: p.x, oy: p.y, fill: isFist(rw) ? ch.fillColor : '#fbbf24', bx, by }) } } }) const flat = allSlots.map(s => ) return flat })()} {combat && combat.enemyIds.map(eid => { const enemy = combat.characters[eid] const dead = isDead(enemy.integrity) if (dead) return null const wpn = enemy.weapon if (!wpn) return null const dir = characterDirsRef.current[eid] ?? { dx: 0, dy: 1 } const shake = hitShake && hitShake.charId === eid && hitShake.actor === 'enemy' const bx = shake ? enemy.pos.x + (Math.random() - 0.5) * 1.5 : enemy.pos.x const by = shake ? enemy.pos.y + (Math.random() - 0.5) * 1.5 : enemy.pos.y const isFist = wpn === 'fists' || wpn === 'left_arm' || wpn === 'right_arm' const enemyAnim = animations.find(a => a.charId === eid && (a.type === ANIM_TYPES.SLASH || a.type === ANIM_TYPES.PIERCE_STRIKE || a.type === ANIM_TYPES.FIST_STRIKE || a.type === ANIM_TYPES.FIST_HOOK)) if (isFist && !enemyAnim) return null const p = enemyAnim ? { x: getAnimPos(enemyAnim).x - enemy.pos.x, y: getAnimPos(enemyAnim).y - enemy.pos.y } : { x: -dir.dy * 7, y: dir.dx * 7 } return })} {/* Projectile dots */} {projectiles.map(p => ( ))} {/* Direction arrows (on top, in front of character) */} {combat && (() => { const partyIds = combat.partyIds ?? [combat.playerEntityId].filter(Boolean) const anyAlive = partyIds.some(cid => !isDead(combat.characters[cid]?.integrity)) if (!anyAlive) return null return partyIds.map(cid => { const ch = combat.characters[cid] if (!ch || isDead(ch.integrity)) return null const d = characterDirsRef.current[cid] ?? { dx: 0, dy: 1 } const isShaking = hitShake?.charId === cid const cx = isShaking ? ch.pos.x + (Math.random() - 0.5) * 1.5 : ch.pos.x const cy = isShaking ? ch.pos.y + (Math.random() - 0.5) * 1.5 : ch.pos.y const isHovered = hoveredChar === cid const px = -d.dy, py = d.dx return ( ) }) })()} {combat && combat.enemyIds.map(eid => { const enemy = combat.characters[eid] const dead = isDead(enemy.integrity) if (dead) return null const isShaking = hitShake && hitShake.charId === eid && hitShake.actor === 'enemy' const ex = isShaking ? enemy.pos.x + (Math.random() - 0.5) * 1.5 : enemy.pos.x const ey = isShaking ? enemy.pos.y + (Math.random() - 0.5) * 1.5 : enemy.pos.y const d = characterDirsRef.current[eid] ?? { dx: 0, dy: 1 } const isHovered = hoveredChar === `enemy-${eid}` const px = -d.dy, py = d.dx return ( ) })}
Combat Log
{(combat?.log || []).map((entry, i) =>
{entry}
)}
{/* ── right: enemy list */}
{(combat?.enemyIds ?? []).map(eid => { const enemy = combat.characters[eid] const idx = combat.enemyIds.indexOf(eid) return (
{ setSelectedEnemy(idx); setShowInventory(true); setPanelTarget(eid) }} onContextMenu={e => { e.preventDefault(); e.stopPropagation(); setContextMenu({ x: e.clientX, y: e.clientY, charId: eid }) }}> {enemy.name}
) })}
{/* ── attack modal */} {limbSelect && (() => { const actorId = currentActor ?? 'player' const actorChar = combat?.characters[actorId] // If the current actor is dead, find the next alive party member const liveActorId = (actorChar && isDead(actorChar.integrity)) ? (combat.partyIds ?? []).find(pid => !isDead(combat.characters[pid]?.integrity)) ?? 'player' : actorId const resolvedActorChar = combat?.characters[liveActorId] const actorPos = resolvedActorChar ? (characterPositionsRef.current[liveActorId] ?? resolvedActorChar.pos) : { x: 20, y: 75 } const actorEquipped = resolvedActorChar?.equipped ?? { left: 'fists', right: 'fists' } return (
e.stopPropagation()}> {(() => { const weapon = weaponMap[limbSelect] const weaponName = weapon?.name ?? limbSelect const skills = weapon?.skills ?? [] return (
{weaponName} — {actorChar?.name ?? actorId} {limbSelect === 'pistol' && Ammo: {actorChar?.weaponMagazines?.pistol?.length ?? 0}/{PISTOL_MAG} ({itemMap[actorChar?.weaponMagazines?.pistol?.[0]?.ammoType]?.name ?? 'none'})}
{/* skill list */} {skills.length > 0 && (
Select Skill
{skills.map((skill, i) => ( ))}
)} {/* reload: ammo selection */} {selectedSkill === 'Load' && (
Select Ammo Type
{Object.entries(actorChar?.itemCounts ?? {}) .filter(([id, qty]) => qty > 0 && (id === 'pistol_ammo' || id === 'bouncing_ammo')) .length === 0 ?
No ammo available
: Object.entries(actorChar?.itemCounts ?? {}) .filter(([id, qty]) => qty > 0 && (id === 'pistol_ammo' || id === 'bouncing_ammo')) .map(([id, qty]) => (
e.currentTarget.style.background = '#1e1e30'} onMouseLeave={e => e.currentTarget.style.background = 'none'} onClick={() => loadAmmoType(id)}> {itemMap[id]?.name ?? id} ×{qty}
)) }
)} {/* enemy list (hidden during reload) */} {selectedSkill && selectedSkill !== 'Load' && (
Select Enemy
{(combat?.enemyIds ?? []).map((eid, i) => { const enemy = combat.characters[eid] const inRange = canReach(actorPos, enemy.pos, limbSelect, selectedSkill) return ( ) })}
)} {/* limb selector (hidden during reload) */} {selectedSkill !== 'Load' && attackEnemy !== null && (
Limb
{[{ key:'torso',label:'Torso'},{ key:'head',label:'Head'},{ key:'larm',label:'Left Arm'},{ key:'rarm',label:'Right Arm'},{ key:'lleg',label:'Left Leg'},{ key:'rleg',label:'Right Leg'}].map(({key,label}) => ( ))}
)}
{/* side info panel */} {skills.length > 0 && (
{(() => { if (!selectedSkill) { const s = skills.find(x => x.name === hoveredSkill) if (!s) return null const descs = { blunt: 'Blunt force trauma', slash: 'Slashing cut', pierce: 'Piercing strike', shove: 'Knocks the target back' } return (<>
{s.name}
{descs[s.damageType] || s.damageType}
Damage: {Math.round((weapon?.damage ?? 0) * s.mult)}
Multiplier: {s.mult}x
Type: {s.damageType}
Range: {s.range}
) } if (attackEnemy === null) { const e = hoveredEnemy !== null && combat ? combat.characters[combat.enemyIds[hoveredEnemy]] : null if (!e) return null return (<>
{e.name}
HP: {Object.values(e.integrity).reduce((a,b)=>a+b,0)}
{Object.entries(e.integrity).map(([p,v]) =>
{bodyPartLabels[p]}: {v}
)}
) } const e = combat ? combat.characters[combat.enemyIds[attackEnemy]] : null const limbKey = selectedTarget const hp = limbKey && e ? e.integrity[limbKey] : null return (<>
{e?.name || ''}
Target: {selectedTarget ? bodyPartLabels[selectedTarget] : 'None'}
Limb HP: {hp !== null ? hp : '-'}
) })()}
)}
) })()}
{limbSelect !== 'pistol' && }
) })()} )} {/* ═══════════════════════════════════════════════════════ WORLD MAP */} {(!combat || showWorldMap) && ( <> {connections.map((c, i) => { const from = locMap[c.from], to = locMap[c.to] return })} {locations.map(l => ( setSelected(selected === l.id ? null : l.id)} onMouseEnter={() => setHovered(l.id)} onMouseLeave={() => setHovered(null)} /> {l.id} {!travelAnim && player.location === l.id && ( <> P )} ))} {travelAnim && ( )} {passTimeOpen && (
Pass Time
setPassTimeHours(Number(e.target.value))} className="pass-time-slider" /> {passTimeHours}h
)}
{selected && (
{locMap[selected].name}
{locMap[selected].description}
{player.location !== selected && connectedTo(player.location).includes(selected) && (
Travel here
)}
Characters here
{liveCharacters.filter(c => c.location === selected).length === 0 ?
None
: liveCharacters.filter(c => c.location === selected).map(c => (
{ e.preventDefault(); e.stopPropagation(); setHoverInfo(null); setHoveredChar(null); setContextMenu({ x: e.clientX, y: e.clientY, charId: c.id }) }}>
{ setShowInventory(true); setPanelTarget(c.id) }}>{c.name}
{c.money !== undefined && {c.money}g} {c.inventory?.length > 0 && {c.inventory.map(id => itemMap[id]?.name ?? id).join(', ')}}
)) }
)} )} {/* ═══════════════════════════════════════════════════════ INVENTORY PANEL */} {showInventory && (() => { const character = panelTarget === 'player' ? player : combat && combat.characters[panelTarget] ? combat.characters[panelTarget] : (() => { const npc = characters.find(c => c.id === panelTarget); return npc ? createCharacter(npc) : null })() const isPlayer = character?.id === 'player' const charWeight = character?.inventory?.reduce((sum, id) => sum + (itemMap[id]?.weight ?? 0) * ((character.itemCounts?.[id] ?? 0) || 1), 0) ?? 0 const charHp = character ? totalHp(character.integrity) : 0 return (
{ setShowInventory(false); setPanelTarget('player'); setHoverInfo(null); setContextMenu(null) }} onContextMenu={e => { e.preventDefault(); setHoverInfo(null); setHoveredChar(null); setContextMenu(null) }} >
e.stopPropagation()}>
{character && }
{character && invTab === 'health' && (
Body Parts
{Object.keys(MAX_BODY).map(part => { const hp = character.integrity[part], max = MAX_BODY[part] const injuries = character.injuries[part] ?? [] return (
{bodyPartLabels[part]}
{hp}/{max}
{injuries.length > 0 &&
{injuries.map((inj,i) => {inj.type} ({inj.severity}))}
}
) })}
Integrity{charHp}/{MAX_HP}
)} {character && invTab === 'inventory' && (
{isPlayer && ( <>
Weapons
{(() => { const lw = character.equipped.left, rw = character.equipped.right if (lw === rw) return (
setHoverInfo({ type:'weapon', id:lw })} onMouseLeave={() => setHoverInfo(null)}> {lw === 'fists' ? 'Fists' : `Both: ${weaponMap[lw].name}`} {lw !== 'fists' && <>DMG: {weaponMap[lw].damage}COND: {character.weaponConditions[lw] ?? weaponMap[lw].maxCondition}/{weaponMap[lw].maxCondition}{weaponMap[lw].passives.length > 0 && {weaponMap[lw].passives.join(', ')}}}
) return ['left','right'].map(side => { const wid = character.equipped[side] if (wid === 'fists') return null return (
setHoverInfo({ type:'weapon', id:wid })} onMouseLeave={() => setHoverInfo(null)}> {side === 'left' ? 'Left' : 'Right'}: {weaponMap[wid].name} DMG: {weaponMap[wid].damage} COND: {character.weaponConditions[wid] ?? weaponMap[wid].maxCondition}/{weaponMap[wid].maxCondition} {weaponMap[wid].passives.length > 0 && {weaponMap[wid].passives.join(', ')}}
) }) })()}
)} {!isPlayer && character.weapon && character.weapon !== 'fists' && (
Equipped Weapon
{(() => { const wep = weaponMap[character.weapon] return (
setHoverInfo({ type:'weapon', id:character.weapon })} onMouseLeave={() => setHoverInfo(null)}> {wep?.name ?? character.weapon} {wep && <>DMG: {character.attack}SPD: {character.speed}{wep.passives?.length > 0 && {wep.passives.join(', ')}}}
) })()}
)} {!isPlayer && character.weapon && character.weapon !== 'fists' &&
}
Inventory ({character.inventory.length})
{character.inventory.length === 0 ?
Empty
: character.inventory.map((itemId, i) => { const qty = character.itemCounts?.[itemId] if (qty !== undefined && qty <= 0) return null return (
setHoverInfo({ type:'item', id:itemId })} onMouseLeave={() => setHoverInfo(null)}> {itemMap[itemId]?.name ?? itemId} {qty !== undefined ? ×{qty} : itemMap[itemId] && ×1} {isPlayer && weaponMap[itemId] && }
) }) } {isPlayer &&
Weight: {charWeight}/{MAX_WEIGHT}
}
{character.money || 0}g
)} {character && invTab === 'stats' && (
{isPlayer ? 'Character' : character.name}
Name{isPlayer ? 'You' : character.name}
Age{character.age ?? '??'}
{isPlayer &&
Size{character.size ?? 1}x
}
Location{locMap[character.location]?.name ?? character.location}
Integrity{charHp}/{MAX_HP}
Items{character.inventory.length}
{isPlayer &&
Weight{charWeight}/{MAX_WEIGHT}
}
Money{character.money || 0}g
)}
)})()} {/* ═══════════════════════════════════════════════════════ SETTINGS */} {showSettings && (
Settings
Display
Zoom
{Math.round(view.scale*100)}%
Game
Date{getDateString(gameTime.day)}
Time{String(gameTime.hour).padStart(2,'0')}:{String(Math.floor(gameTime.minute ?? 0)).padStart(2,'0')}
Weather{weatherLabels[weather]}
Player
Location{locMap[player.location]?.name ?? player.location}
Money{player.money}g
)} {/* ═══════════════════════════════════════════════════════ WORLD INFO */} {showWorldInfo && (
setShowWorldInfo(false)}>
e.stopPropagation()}>
World
Date{getDateString(gameTime.day)}
Time{String(gameTime.hour).padStart(2,'0')}:{String(Math.floor(gameTime.minute ?? 0)).padStart(2,'0')}
Weather{weatherLabels[weather]}
Location{locMap[player.location]?.name ?? player.location}
)} {/* ═══════════════════════════════════════════════════════ TOOLTIPS */} {hovered && (
{locMap[hovered].name}
{locMap[hovered].description}
)} {hoverInfo?.type === 'item' && itemMap[hoverInfo.id] && (
{itemMap[hoverInfo.id].name}
{itemMap[hoverInfo.id].description}
)} {hoverInfo?.type === 'weapon' && weaponMap[hoverInfo.id] && (
{hoverInfo.id === 'left_arm' || hoverInfo.id === 'right_arm' ? <>
{weaponMap[hoverInfo.id].name}
Unarmed strike (DMG: 10)
: <>
{weaponMap[hoverInfo.id].name}
DMG: {weaponMap[hoverInfo.id].damage} SPD: {weaponMap[hoverInfo.id].speed}
Condition: {player.weaponConditions[hoverInfo.id] ?? weaponMap[hoverInfo.id].maxCondition}/{weaponMap[hoverInfo.id].maxCondition}
Skills: {weaponMap[hoverInfo.id].skills.map(s=>`${s.name} (${s.damageType})`).join(', ')}
{weaponMap[hoverInfo.id].passives.length > 0 &&
Passives: {weaponMap[hoverInfo.id].passives.join(', ')}
} }
)} {/* ═══════════════════════════════════════════════════════ TRADE WINDOW */} {showTrade && (() => { const srcChar = player const tgtChar = (() => { if (tradeTarget === 'player') return player if (combat && combat.characters[tradeTarget]) return combat.characters[tradeTarget] const npc = characters.find(c => c.id === tradeTarget) return npc ? createCharacter(npc) : null })() if (!srcChar || !tgtChar) return null function getCharItems(ch) { const seen = new Set() const items = [] for (const id of (ch.inventory ?? [])) { if (seen.has(id)) continue; seen.add(id) items.push({ id, name: weaponMap[id]?.name ?? itemMap[id]?.name ?? id, qty: 1, value: getItemValue(id), cond: ch.weaponConditions?.[id] ?? weaponMap[id]?.maxCondition ?? null, maxCond: weaponMap[id]?.maxCondition ?? null }) } for (const [id, qty] of Object.entries(ch.itemCounts ?? {})) { if (qty > 0) { if (seen.has(id)) continue; seen.add(id) items.push({ id, name: itemMap[id]?.name ?? id, qty, value: getItemValue(id) }) } } return items } const srcItems = getCharItems(srcChar) const tgtItems = getCharItems(tgtChar) const giveVal = Object.entries(tradeGive).reduce((s, [id, q]) => s + getItemValue(id) * q, 0) const takeVal = Object.entries(tradeTake).reduce((s, [id, q]) => s + getItemValue(id) * q, 0) const proposeTrade = () => { const giveEntries = Object.entries(tradeGive).filter(([id, q]) => q > 0) const takeEntries = Object.entries(tradeTake).filter(([id, q]) => q > 0) if (giveEntries.length === 0 && takeEntries.length === 0) return const newPlayer = { ...player } const tgtWorld = characters.find(c => c.id === tradeTarget) if (tgtWorld) { tgtWorld.inventory = [...(tgtWorld.inventory ?? [])] tgtWorld.itemCounts = { ...(tgtWorld.itemCounts ?? {}) } tgtWorld.weaponConditions = { ...(tgtWorld.weaponConditions ?? {}) } } for (const [id, qty] of giveEntries) { if (weaponMap[id]) { // weapon: remove from player inventory, give to target newPlayer.inventory = newPlayer.inventory.filter(i => i !== id) if (tgtWorld) { tgtWorld.inventory = tgtWorld.inventory ?? [] tgtWorld.inventory.push(id) if (newPlayer.weaponConditions?.[id] != null) { tgtWorld.weaponConditions[id] = newPlayer.weaponConditions[id] delete newPlayer.weaponConditions[id] } } } else if (itemMap[id]) { // ammo/item: reduce player count, increase target count newPlayer.itemCounts = { ...(newPlayer.itemCounts ?? {}) } newPlayer.itemCounts[id] = (newPlayer.itemCounts[id] ?? 0) - qty if (newPlayer.itemCounts[id] <= 0) delete newPlayer.itemCounts[id] if (tgtWorld) { const tgt = tgtWorld tgt.inventory = [...(tgt.inventory ?? []), id] tgt.itemCounts = { ...(tgt.itemCounts ?? {}) } tgt.itemCounts[id] = (tgt.itemCounts[id] ?? 0) + qty } } } for (const [id, qty] of takeEntries) { if (weaponMap[id]) { // weapon: remove from target inventory, give to player if (tgtWorld) { tgtWorld.inventory = tgtWorld.inventory.filter(i => i !== id) if (tgtWorld.weaponConditions?.[id] != null) { newPlayer.weaponConditions = { ...(newPlayer.weaponConditions ?? {}) } newPlayer.weaponConditions[id] = tgtWorld.weaponConditions[id] delete tgtWorld.weaponConditions[id] } } newPlayer.inventory = [...(newPlayer.inventory ?? []), id] } else if (itemMap[id]) { // ammo/item: reduce target count (from itemCounts and/or inventory) if (tgtWorld) { const tgt = tgtWorld tgt.inventory = (tgt.inventory ?? []).filter(i => i !== id) tgt.itemCounts = { ...(tgt.itemCounts ?? {}) } tgt.itemCounts[id] = (tgt.itemCounts[id] ?? 0) - qty if (tgt.itemCounts[id] <= 0) delete tgt.itemCounts[id] } newPlayer.itemCounts = { ...(newPlayer.itemCounts ?? {}) } newPlayer.itemCounts[id] = (newPlayer.itemCounts[id] ?? 0) + qty } } setPlayer(newPlayer) setShowTrade(false) setTradeTarget(null) setTradeGive({}) setTradeTake({}) } return (
{ setShowTrade(false); setTradeTarget(null) }}>
e.stopPropagation()}>
Trade with {tgtChar.name ?? tradeTarget}
{srcChar.name} Offers
{srcItems.map(item => (
{item.name}{item.qty > 1 ? ` (${item.qty})` : ''}{item.cond != null ? ` [${item.cond}/${item.maxCond}]` : ''}
{tradeGive[item.id] ?? 0}
))}
{tgtChar.name} Offers
{tgtItems.map(item => (
{item.name}{item.qty > 1 ? ` (${item.qty})` : ''}{item.cond != null ? ` [${item.cond}/${item.maxCond}]` : ''}
{tradeTake[item.id] ?? 0}
))}
Give: {giveVal}g   Take: {takeVal}g
) })()} {hoverInfo?.type === 'enemy' && (
{hoverInfo.name}
{hoverInfo.defeated ? 'Defeated' : 'Hostile'}
)} {hoveredChar && !hoveredChar.startsWith('enemy-') && combat?.characters[hoveredChar] && (
{combat.characters[hoveredChar].name ?? hoveredChar}
)} {/* ═══════════════════════════════════════════════════════ EQUIP ARM CHOICE */} {equipArmChoice && (
setEquipArmChoice(null)}>
e.stopPropagation()}>
Equip {weaponMap[equipArmChoice].name}
)} {/* ═══════════════════════════════════════════════════════ CONTEXT MENU */} {contextMenu && (
{ setHoverInfo(null); setHoveredChar(null); setContextMenu(null) }}>
)}
) }