Files
citygamin/src/App.jsx
T
2026-06-15 10:18:07 -04:00

3296 lines
177 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 (
<div className="body-image-wrap">
<img src={maleBody} alt="" className="male-body" />
{bodyOverlays.map(o => (
<div key={o.part} className="body-part-overlay"
style={{ left: o.left, top: o.top, width: o.width, height: o.height, background: partColor(body[o.part], MAX_BODY[o.part]) }} />
))}
</div>
)
}
// ─────────────────────────────────────────────────────────────────────────────
// 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 (
<div className="menu-screen">
<div className="char-create-panel">
<h1 className="char-create-title">CREATE CHARACTER</h1>
<div className="char-create-field">
<label>Name</label>
<input type="text" value={name} onChange={e => setName(e.target.value)} className="char-create-input" maxLength={20} />
</div>
<div className="char-create-field">
<label>Starting Weapon</label>
<div className="char-create-options">
{startWeapons.map(w => (
<button key={w.id} className={'char-create-option' + (weaponId === w.id ? ' selected' : '')}
onClick={() => setWeaponId(w.id)}>{w.name}</button>
))}
</div>
</div>
<div className="char-create-field">
<label>Fill Color</label>
<input type="color" value={fillColor} onChange={e => setFillColor(e.target.value)} className="char-create-color" />
</div>
<div className="char-create-field">
<label>Starting Location</label>
<div className="char-create-options">
{locations.map(l => (
<button key={l.id} className={'char-create-option' + (locationId === l.id ? ' selected' : '')}
onClick={() => setLocationId(l.id)}>{l.id} {l.name}</button>
))}
</div>
</div>
<button className="menu-btn" onClick={() => onConfirm({ name, weaponId, fillColor, locationId })}>Start Game</button>
</div>
</div>
)
}
// ─────────────────────────────────────────────────────────────────────────────
// 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 (
<div className="menu-screen">
<div className="menu-content">
<h1 className="menu-title">CITYGAMIN</h1>
<p className="menu-subtitle">A game of survival and conquest</p>
<button className="menu-btn" onClick={handleNewGame}>New Game</button>
</div>
</div>
)
}
if (showCharCreate) {
return <CharacterCreator onConfirm={(opts) => {
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 (
<div className="game-map" onMouseMove={e => setMousePos({ x: e.clientX, y: e.clientY })} onContextMenu={e => { e.preventDefault(); setContextMenu(null) }}>
<div className="top-bar">
<div className="top-bar-left">
{combat && (
<button className="icon-btn" onClick={() => setShowWorldMap(s => !s)} title={showWorldMap ? 'Combat View' : 'World Map'}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="7" height="7" /><rect x="14" y="3" width="7" height="7" /><rect x="3" y="14" width="7" height="7" /><rect x="14" y="14" width="7" height="7" /></svg>
</button>
)}
<button className="icon-btn" onClick={() => { setShowInventory(true); setPanelTarget('player') }} title="Character">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="8" r="4" /><path d="M4 22a8 8 0 0 1 16 0" /></svg>
</button>
<button className="icon-btn" onClick={() => setShowWorldInfo(s => !s)} title="World">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10" /><ellipse cx="12" cy="12" rx="4" ry="10" /><path d="M2 12h20" /></svg>
</button>
<button className="icon-btn" onClick={() => { setPassTimeHours(1); setPassTimeOpen(s => !s) }} title="Pass Time">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10" /><polyline points="12 6 12 12 16 14" /></svg>
</button>
<button className="icon-btn" onClick={() => setShowSettings(s => !s)} title="Settings">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" /></svg>
</button>
</div>
<div className="top-bar-right">
{(!combat || showWorldMap) && (
<div className="game-time-display">
{(() => {
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')}`
})()}
</div>
)}
{combat && !showWorldMap && (
<div className="combat-timer">
<button className="timer-toggle" onClick={toggleTimer} title={timerRunning ? 'Pause' : 'Play'} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#9ca3af', padding: 0, display: 'flex', alignItems: 'center' }}>
{timerRunning ? (
<svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor">
<rect x="3" y="2" width="4" height="12" rx="1" /><rect x="9" y="2" width="4" height="12" rx="1" />
</svg>
) : (
<svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor">
<polygon points="3,2 14,8 3,14" />
</svg>
)}
</button>
{(() => {
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')}`
})()}
<div style={{ display: 'flex', gap: 4, marginLeft: 12 }}>
{[1, 0.5, 0.25, 0.1].map(s => (
<button key={s} onClick={() => { speedRef.current = s; setSpeedMult(s) }}
style={{ background: speedMult === s ? '#2d2d44' : 'none', border: '1px solid #2d2d44', color: '#9ca3af', cursor: 'pointer', padding: '2px 6px', borderRadius: 3, fontSize: 11, fontWeight: speedMult === s ? 'bold' : 'normal' }}>
{s}x
</button>
))}
</div>
</div>
)}
</div>
</div>
{/* ═══════════════════════════════════════════════════════ COMBAT VIEW */}
{combat && !showWorldMap && (
<>
<div className="combat-layout">
{/* ── left: action tree (per party member) */}
<div className="combat-left">
{combat && (() => {
const partyIds = combat.partyIds ?? [combat.playerEntityId].filter(Boolean)
const anyActionPossible = partyIds.some(cid => !isDead(combat.characters[cid]?.integrity))
return anyActionPossible && (
<div className={`combat-actions${busy ? ' combat-busy' : ''}${aimMode ? ' combat-aiming' : ''}`} style={aimMode ? { pointerEvents: 'none', opacity: 0.5 } : undefined}>
{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 (
<div key={cid} className="tree-root" style={{ opacity: isDeadChar ? 0.4 : 1, pointerEvents: isDeadChar ? 'none' : undefined }}>
<div className="tree-node folder" onClick={() => setExpandedWeapon(prev => prev.includes(cid) ? prev.filter(x => x !== cid) : [...prev, cid])}>
<span className="tree-toggle">{expandedWeapon.includes(cid) ? '▼' : '▶'}</span>
<span className="tree-label" style={{ color: '#22c55e', cursor: 'pointer' }}
onClick={e => { e.stopPropagation(); setShowInventory(true); setPanelTarget(cid) }}>{charName}{isDeadChar ? ' (Dead)' : ''}</span>
</div>
{expandedWeapon.includes(cid) && (
<div className="tree-children">
{/* Weapons */}
<div className="tree-node folder" onClick={() => setWeaponsOpen(prev => prev.includes(cid) ? prev.filter(x => x !== cid) : [...prev, cid])}>
<span className="tree-toggle">{weaponsOpen.includes(cid) ? '▼' : '▶'}</span>
<span className="tree-label">Weapons</span>
</div>
{weaponsOpen.includes(cid) && (
<div className="tree-children">
{(() => {
const entries = []
if (lw === 'fists') {
entries.push(<div key="left-arm" className="tree-node leaf"
onMouseEnter={() => setHoverInfo({ type: 'weapon', id: 'left_arm' })} onMouseLeave={() => setHoverInfo(null)}
onClick={() => { setCurrentActor(cid); setLimbSelect('left_arm'); setSelectedSkill(null); setSelectedTarget(null); setMoveMode(null); setAimMode(false) }}>
<span className="tree-bullet"></span><span className="tree-label">Left Arm</span></div>)
} else if (lw !== rw) {
entries.push(<div key="left-weap" className="tree-node leaf"
onMouseEnter={() => setHoverInfo({ type: 'weapon', id: lw })} onMouseLeave={() => setHoverInfo(null)}
onClick={() => { setCurrentActor(cid); setLimbSelect(lw); setSelectedSkill(null); setSelectedTarget(null); setMoveMode(null); setAimMode(false) }}>
<span className="tree-bullet"></span><span className="tree-label">Left: {weaponMap[lw]?.name ?? lw}</span></div>)
}
if (rw === 'fists') {
entries.push(<div key="right-arm" className="tree-node leaf"
onMouseEnter={() => setHoverInfo({ type: 'weapon', id: 'right_arm' })} onMouseLeave={() => setHoverInfo(null)}
onClick={() => { setCurrentActor(cid); setLimbSelect('right_arm'); setSelectedSkill(null); setSelectedTarget(null); setMoveMode(null); setAimMode(false) }}>
<span className="tree-bullet"></span><span className="tree-label">Right Arm</span></div>)
} else if (lw !== rw) {
entries.push(<div key="right-weap" className="tree-node leaf"
onMouseEnter={() => setHoverInfo({ type: 'weapon', id: rw })} onMouseLeave={() => setHoverInfo(null)}
onClick={() => { setCurrentActor(cid); setLimbSelect(rw); setSelectedSkill(null); setSelectedTarget(null); setMoveMode(null); setAimMode(false) }}>
<span className="tree-bullet"></span><span className="tree-label">Right: {weaponMap[rw]?.name ?? rw}</span></div>)
}
if (lw === rw && lw !== 'fists' && lw !== 'left_arm' && lw !== 'right_arm') {
entries.push(<div key="both-weap" className="tree-node leaf"
onMouseEnter={() => setHoverInfo({ type: 'weapon', id: lw })} onMouseLeave={() => setHoverInfo(null)}
onClick={() => { setCurrentActor(cid); setLimbSelect(lw); setSelectedSkill(null); setSelectedTarget(null); setMoveMode(null); setAimMode(false) }}>
<span className="tree-bullet"></span><span className="tree-label">Both: {weaponMap[lw]?.name ?? lw}</span></div>)
}
if (lw === 'fists' && rw === 'fists') {
entries.push(<div key="fists" className="tree-node leaf"
onMouseEnter={() => setHoverInfo({ type: 'weapon', id: 'fists' })} onMouseLeave={() => setHoverInfo(null)}
onClick={() => { setCurrentActor(cid); setLimbSelect('fists'); setSelectedSkill(null); setSelectedTarget(null); setMoveMode(null); setAimMode(false) }}>
<span className="tree-bullet"></span><span className="tree-label">Fists</span></div>)
}
return entries
})()}
</div>
)}
{/* Items */}
<div className="tree-node folder" onClick={() => setItemsOpen(prev => prev.includes(cid) ? prev.filter(x => x !== cid) : [...prev, cid])}>
<span className="tree-toggle">{itemsOpen.includes(cid) ? '▼' : '▶'}</span>
<span className="tree-label">Items</span>
</div>
{itemsOpen.includes(cid) && (
<div className="tree-children">
{(ch.inventory ?? []).length === 0
? <div className="move-hint">No items</div>
: ch.inventory.map((itemId, i) => {
const qty = ch.itemCounts?.[itemId]
if (qty !== undefined && qty <= 0) return null
return (
<div key={i} className="tree-node leaf" onClick={() => { setShowInventory(true); setPanelTarget(cid) }}>
<span className="tree-bullet"></span>
<span className="tree-label">{itemMap[itemId]?.name ?? itemId}</span>
{qty !== undefined && <span style={{ marginLeft: 6, color: '#9ca3af', fontSize: 12 }}>×{qty}</span>}
</div>
)
})
}
</div>
)}
{/* Movement */}
<div className="tree-node folder" onClick={() => setMovementOpen(prev => prev.includes(cid) ? prev.filter(x => x !== cid) : [...prev, cid])}>
<span className="tree-toggle">{movementOpen.includes(cid) ? '▼' : '▶'}</span>
<span className="tree-label">Movement</span>
</div>
{movementOpen.includes(cid) && (
<div className="tree-children">
<div className="tree-node leaf" onClick={() => { if (!busy) { setCurrentActor(cid); setMoveMode(m => m === 'walk' ? null : 'walk'); setAimMode(false) } }}>
<span className="tree-bullet"></span><span className="tree-label">Walk</span>
</div>
<div className="tree-node leaf" onClick={() => { if (!busy) { setCurrentActor(cid); setMoveMode(m => m === 'run' ? null : 'run'); setAimMode(false) } }}>
<span className="tree-bullet"></span><span className="tree-label">Run</span>
</div>
<div className="tree-node leaf" onClick={() => { if (!busy) { setCurrentActor(cid); setMoveMode(m => m === 'turn' ? null : 'turn'); setAimMode(false) } }}>
<span className="tree-bullet"></span><span className="tree-label">Turn</span>
</div>
{moveMode && moveMode !== 'turn' && <div className="tree-children" style={{ paddingLeft: 0 }}><div className="move-hint">Click on the mini-map to move</div></div>}
{moveMode === 'turn' && <div className="tree-children" style={{ paddingLeft: 0 }}><div className="move-hint">Click on the mini-map to face a direction</div></div>}
</div>
)}
</div>
)}
</div>
)
})}
</div>
)
})()}
</div>
{/* ── center: minimap + log */}
<div className="combat-center">
<div className="minimap-container" style={{ position: 'relative', borderColor: hitShake ? '#ef4444' : undefined, cursor: aimMode ? 'crosshair' : undefined }}>
<div className="minimap-toolbar">
<button className="zoom-btn" onClick={() => setMinimapView(v => { const ns = Math.min(3, v.scale*1.5); return { x: v.x + 0.5*(160/v.scale - 160/ns), y: v.y + 0.5*(160/v.scale - 160/ns), scale: ns } })}>+</button>
<button className="zoom-btn" onClick={() => setMinimapView(v => { const ns = Math.max(0.5, v.scale/1.5); return { x: v.x + 0.5*(160/v.scale - 160/ns), y: v.y + 0.5*(160/v.scale - 160/ns), scale: ns } })}></button>
<button className="zoom-btn" onClick={() => setMinimapView({ x: 0, y: 0, scale: 1 })}></button>
</div>
<svg viewBox="0 0 160 160" className="minimap"
style={{ cursor: limbSelect ? 'default' : (minimapPanRef.current ? 'grabbing' : 'grab') }}
onMouseDown={e => {
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)
}}>
<defs>
<filter id="arrowGlow">
<feGaussianBlur stdDeviation="1.5" result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</defs>
<g style={{
transform: `translate(${-minimapView.x * minimapView.scale + (hitShake ? (Math.random() - 0.5) * 2 : 0)}px, ${-minimapView.y * minimapView.scale + (hitShake ? (Math.random() - 0.5) * 2 : 0)}px) scale(${minimapView.scale})`,
transformOrigin: '0 0',
transition: hitShake ? 'none' : undefined,
}}>
<rect x="0" y="0" width="160" height="160" fill="#171721" />
{(combat?.blocks ?? []).map((block, i) => <rect key={i} x={block.x} y={block.y} width={block.w} height={block.h} fill="#2e303a" stroke="#1f2028" strokeWidth="1" />)}
{(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 (
<Fragment key={eid}>
<circle cx={ex} cy={ey} r={5 * eSize}
fill={dead ? '#6b7280' : enemy.fillColor} stroke={dead ? '#6b7280' : '#e74c3c'} strokeWidth="1" opacity={dead ? 0.5 : 1}
onMouseEnter={e => { 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 }) }} />
</Fragment>
)
})}
{combat && selectedSkill && limbSelect && (() => {
const rangeActor = currentActor ?? 'player'
const rp = combat.characters[rangeActor]?.pos ?? combat.characters['player'].pos
return <circle cx={rp.x} cy={rp.y} r={getSkillRange(limbSelect, selectedSkill)} fill="none" stroke="rgba(52,152,219,0.5)" strokeWidth="1" strokeDasharray="4 4" />
})()}
{/* 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 (
<circle key={`party-${cid}`}
cx={hitShake && hitShake.charId === cid ? ch.pos.x + (Math.random() - 0.5) * 1.5 : ch.pos.x}
cy={hitShake && hitShake.charId === cid ? ch.pos.y + (Math.random() - 0.5) * 1.5 : ch.pos.y}
r={4 * (ch.size ?? 1)} fill={chDead ? '#6b7280' : color} stroke={chDead ? '#6b7280' : '#3498db'} strokeWidth="1" opacity={chDead ? 0.5 : 1}
onMouseEnter={() => 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 (
<circle key={`npc-${nid}`}
cx={ch.pos.x} cy={ch.pos.y}
r={4} fill={ch.fillColor} stroke="#22c55e" strokeWidth="1" opacity={0.7}
onMouseEnter={() => 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 =>
<circle key={s.key} cx={bx + s.ox} cy={by + s.oy} r="3" fill={s.fill} stroke="#000" strokeWidth="0.5" />
)
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 => <circle key={s.key} cx={s.bx + s.ox} cy={s.by + s.oy} r="3" fill={s.fill} stroke="#000" strokeWidth="0.5" />)
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 <circle key={`ew-${eid}`} cx={bx + p.x} cy={by + p.y} r="3" fill={isFist ? enemy.fillColor : '#f97316'} stroke="#000" strokeWidth="0.5" />
})}
{/* Projectile dots */}
{projectiles.map(p => (
<circle key={p.id} cx={p.pos.x} cy={p.pos.y} r="1.5" fill="#fbbf24" stroke="#ff6b35" strokeWidth="1" />
))}
{/* 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 (
<polygon key={`arrow-${cid}`} points={[
`${cx + d.dx * 10},${cy + d.dy * 10}`,
`${cx + d.dx * 7 + px * 2},${cy + d.dy * 7 + py * 2}`,
`${cx + d.dx * 7 - px * 2},${cy + d.dy * 7 - py * 2}`,
].join(' ')}
fill={isHovered ? '#fff' : '#3498db'} opacity="0.8" filter={isHovered ? 'url(#arrowGlow)' : undefined} />
)
})
})()}
{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 (
<polygon key={`arrow-${eid}`} points={[
`${ex + d.dx * 10},${ey + d.dy * 10}`,
`${ex + d.dx * 7 + px * 2},${ey + d.dy * 7 + py * 2}`,
`${ex + d.dx * 7 - px * 2},${ey + d.dy * 7 - py * 2}`,
].join(' ')}
fill={isHovered ? '#fff' : '#e74c3c'} opacity="0.8" filter={isHovered ? 'url(#arrowGlow)' : undefined} />
)
})}
</g>
</svg>
</div>
<div className="combat-log">
<div className="combat-log-title">Combat Log</div>
<div className="combat-log-entries">
{(combat?.log || []).map((entry, i) => <div key={i} className="combat-log-entry">{entry}</div>)}
</div>
</div>
</div>
{/* ── right: enemy list */}
<div className="combat-right">
<div className="enemy-list">
{(combat?.enemyIds ?? []).map(eid => {
const enemy = combat.characters[eid]
const idx = combat.enemyIds.indexOf(eid)
return (
<div key={eid} className="enemy-entry" onClick={() => { setSelectedEnemy(idx); setShowInventory(true); setPanelTarget(eid) }} onContextMenu={e => { e.preventDefault(); e.stopPropagation(); setContextMenu({ x: e.clientX, y: e.clientY, charId: eid }) }}>
<span className="enemy-name">{enemy.name}</span>
</div>
)
})}
</div>
</div>
</div>
{/* ── 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 (
<div className="inventory-overlay" onClick={cancelAttack}
style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 8 }}>
<div className="inventory-panel limb-select-panel" onClick={e => e.stopPropagation()}>
{(() => {
const weapon = weaponMap[limbSelect]
const weaponName = weapon?.name ?? limbSelect
const skills = weapon?.skills ?? []
return (
<div style={{ position: 'relative', minHeight: 520 }}>
<div className="inv-parts-title limb-select-title" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span>{weaponName} {actorChar?.name ?? actorId}</span>
{limbSelect === 'pistol' && <span style={{ fontSize: 13, color: '#9ca3af', fontWeight: 'normal' }}>Ammo: {actorChar?.weaponMagazines?.pistol?.length ?? 0}/{PISTOL_MAG} <span style={{ color: '#fbbf24' }}>({itemMap[actorChar?.weaponMagazines?.pistol?.[0]?.ammoType]?.name ?? 'none'})</span></span>}
</div>
<div style={{ display: 'flex', gap: 16, alignItems: 'flex-start', paddingRight: 250 }}>
{/* skill list */}
{skills.length > 0 && (
<div>
<div className="inv-parts-title" style={{ marginBottom: 8 }}>Select Skill</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{skills.map((skill, i) => (
<button key={i} className={'limb-target-btn' + (selectedSkill === skill.name ? ' limb-target-selected' : '')}
onClick={() => {
if (skill.name === 'Load') { startReload(); return }
if (skill.name === 'Unload') { unloadPistol(); return }
setSelectedSkill(skill.name); setAttackEnemy(null)
if (limbSelect === 'pistol') { setAimMode(true); setLimbSelect(null) }
}}
onMouseEnter={() => setHoveredSkill(skill.name)} onMouseLeave={() => setHoveredSkill(null)}>
<div style={{ fontWeight: 'bold' }}>{skill.name}</div>
</button>
))}
</div>
</div>
)}
{/* reload: ammo selection */}
{selectedSkill === 'Load' && (
<div>
<div className="inv-parts-title" style={{ marginBottom: 8 }}>Select Ammo Type</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{Object.entries(actorChar?.itemCounts ?? {})
.filter(([id, qty]) => qty > 0 && (id === 'pistol_ammo' || id === 'bouncing_ammo'))
.length === 0
? <div style={{ color: '#6b7280', padding: 12 }}>No ammo available</div>
: Object.entries(actorChar?.itemCounts ?? {})
.filter(([id, qty]) => qty > 0 && (id === 'pistol_ammo' || id === 'bouncing_ammo'))
.map(([id, qty]) => (
<div key={id} className="tree-node leaf" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '10px 12px', cursor: 'pointer', borderRadius: 3, marginBottom: 4 }}
onMouseEnter={e => e.currentTarget.style.background = '#1e1e30'}
onMouseLeave={e => e.currentTarget.style.background = 'none'}
onClick={() => loadAmmoType(id)}>
<span style={{ fontWeight: 'bold' }}>{itemMap[id]?.name ?? id}</span>
<span style={{ color: '#9ca3af' }}>×{qty}</span>
</div>
))
}
</div>
</div>
)}
{/* enemy list (hidden during reload) */}
{selectedSkill && selectedSkill !== 'Load' && (
<div>
<div className="inv-parts-title" style={{ marginBottom: 8 }}>Select Enemy</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{(combat?.enemyIds ?? []).map((eid, i) => {
const enemy = combat.characters[eid]
const inRange = canReach(actorPos, enemy.pos, limbSelect, selectedSkill)
return (
<button key={eid} className={'limb-target-btn' + (attackEnemy === i ? ' limb-target-selected' : '')}
onClick={() => setAttackEnemy(i)} style={{ opacity: inRange ? 1 : 0.4 }} disabled={!inRange}
onMouseEnter={() => setHoveredEnemy(i)} onMouseLeave={() => setHoveredEnemy(null)}>
<div style={{ fontWeight: 'bold' }}>{enemy.name}</div>
<div style={{ fontSize: 11, color: '#6b7280' }}>HP: {Object.values(enemy.integrity).reduce((a,b)=>a+b,0)}</div>
</button>
)
})}
</div>
</div>
)}
{/* limb selector (hidden during reload) */}
{selectedSkill !== 'Load' && attackEnemy !== null && (
<div>
<div className="inv-parts-title" style={{ marginBottom: 8 }}>Limb</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{[{ 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}) => (
<button key={key} className={'limb-target-btn' + (selectedTarget === (partMap[key]||key) ? ' limb-target-selected' : '')}
onClick={() => selectLimbTarget(key)} style={{ whiteSpace: 'nowrap', padding: '6px 16px' }}>
{label}
</button>
))}
</div>
</div>
)}
</div>
{/* side info panel */}
{skills.length > 0 && (
<div style={{ position: 'absolute', right: 0, top: 0, bottom: 0, width: 220, background: '#1a1a2e', border: '1px solid #2d2d44', borderRadius: 6, padding: 14 }}>
{(() => {
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 (<>
<div style={{ background: '#16162a', border: '1px solid #2d2d44', borderRadius: 4, padding: '8px 12px', marginBottom: 12 }}>
<div style={{ color: '#fbbf24', fontWeight: 'bold', fontSize: 14 }}>{s.name}</div>
<div style={{ color: '#9ca3af', fontSize: 12, marginTop: 4 }}>{descs[s.damageType] || s.damageType}</div>
</div>
<div style={{ fontSize: 13, color: '#d1d5db', lineHeight: 2 }}>
<div><span style={{ color: '#6b7280' }}>Damage: </span><span style={{ color: '#ef4444' }}>{Math.round((weapon?.damage ?? 0) * s.mult)}</span></div>
<div><span style={{ color: '#6b7280' }}>Multiplier: </span><span>{s.mult}x</span></div>
<div><span style={{ color: '#6b7280' }}>Type: </span><span style={{ color: '#a78bfa' }}>{s.damageType}</span></div>
<div><span style={{ color: '#6b7280' }}>Range: </span><span>{s.range}</span></div>
</div>
</>)
}
if (attackEnemy === null) {
const e = hoveredEnemy !== null && combat ? combat.characters[combat.enemyIds[hoveredEnemy]] : null
if (!e) return null
return (<>
<div style={{ background: '#16162a', border: '1px solid #2d2d44', borderRadius: 4, padding: '8px 12px', marginBottom: 12 }}>
<div style={{ color: '#22c55e', fontWeight: 'bold', fontSize: 14 }}>{e.name}</div>
</div>
<div style={{ fontSize: 13, color: '#d1d5db', lineHeight: 2 }}>
<div><span style={{ color: '#6b7280' }}>HP: </span><span style={{ color: '#ef4444' }}>{Object.values(e.integrity).reduce((a,b)=>a+b,0)}</span></div>
{Object.entries(e.integrity).map(([p,v]) => <div key={p}><span style={{ color: '#6b7280' }}>{bodyPartLabels[p]}: </span><span>{v}</span></div>)}
</div>
</>)
}
const e = combat ? combat.characters[combat.enemyIds[attackEnemy]] : null
const limbKey = selectedTarget
const hp = limbKey && e ? e.integrity[limbKey] : null
return (<>
<div style={{ background: '#16162a', border: '1px solid #2d2d44', borderRadius: 4, padding: '8px 12px', marginBottom: 12 }}>
<div style={{ color: '#fbbf24', fontWeight: 'bold', fontSize: 14 }}>{e?.name || ''}</div>
<div style={{ color: '#9ca3af', fontSize: 12, marginTop: 4 }}>Target: {selectedTarget ? bodyPartLabels[selectedTarget] : 'None'}</div>
</div>
<div style={{ fontSize: 13, color: '#d1d5db', lineHeight: 2 }}>
<div><span style={{ color: '#6b7280' }}>Limb HP: </span><span style={{ color: '#ef4444' }}>{hp !== null ? hp : '-'}</span></div>
</div>
</>)
})()}
</div>
)}
</div>
)
})()}
</div>
<div style={{ display: 'flex', gap: 8 }}>
{limbSelect !== 'pistol' && <button className="limb-confirm-btn" onClick={executeAttack}
disabled={!selectedSkill || attackEnemy === null || !selectedTarget || busy || (resolvedActorChar ? isDead(resolvedActorChar.integrity) : false)}
style={{ opacity: (!selectedSkill || attackEnemy === null || !selectedTarget || busy || (resolvedActorChar ? isDead(resolvedActorChar.integrity) : false)) ? 0.4 : 1 }}>Confirm</button>}
<button className="limb-cancel-btn" onClick={cancelAttack} style={{ width: 'auto', padding: '10px 24px' }}>Cancel</button>
</div>
</div>
)
})()}
</>
)}
{/* ═══════════════════════════════════════════════════════ WORLD MAP */}
{(!combat || showWorldMap) && (
<>
<svg ref={svgRef} viewBox="0 0 800 600" className="map-svg"
onWheel={handleWheel}
>
<rect width="800" height="600" fill="transparent"
onPointerDown={handleBgPointerDown}
onPointerMove={handleBgPointerMove}
onPointerUp={handleBgPointerUp}
style={{ cursor: isPanning.current ? 'grabbing' : 'grab' }} />
<g style={{ transform: `translate(${view.x}px, ${view.y}px) scale(${view.scale})`, transformOrigin: '0 0', willChange: 'transform' }}>
{connections.map((c, i) => {
const from = locMap[c.from], to = locMap[c.to]
return <line key={i} x1={from.x} y1={from.y} x2={to.x} y2={to.y} stroke={selected ? '#6b7280' : '#5F71C5'} strokeWidth={selected ? 2 : 3} />
})}
{locations.map(l => (
<g key={l.id}>
<circle cx={l.x} cy={l.y} r={20}
fill={selected === l.id ? '#1e2642' : '#1a1a2e'}
stroke="#5F71C5" strokeWidth={selected === l.id ? 4 : 3}
style={{ cursor: 'pointer', transition: 'all 0.2s' }}
onClick={() => setSelected(selected === l.id ? null : l.id)}
onMouseEnter={() => setHovered(l.id)}
onMouseLeave={() => setHovered(null)} />
<text x={l.x} y={l.y + 6} textAnchor="middle" fill="#5F71C5" fontSize={16} fontWeight="bold" pointerEvents="none">{l.id}</text>
{!travelAnim && player.location === l.id && (
<><circle cx={l.x} cy={l.y - 28} r={6} fill="#22c55e" stroke="#22c55e" strokeWidth={2} />
<text x={l.x} y={l.y - 25} textAnchor="middle" fill="#22c55e" fontSize={9} fontWeight="bold" pointerEvents="none">P</text></>
)}
</g>
))}
{travelAnim && (
<circle cx={travelAnim.from.x + (travelAnim.to.x - travelAnim.from.x) * travelAnim.progress}
cy={travelAnim.from.y + (travelAnim.to.y - travelAnim.from.y) * travelAnim.progress - 28}
r={6} fill="#22c55e" stroke="#22c55e" strokeWidth={2} />
)}
</g>
</svg>
{passTimeOpen && (
<div className="pass-time-panel">
<div className="pass-time-header">Pass Time</div>
<div className="pass-time-slider-row">
<input type="range" min="1" max="24" value={passTimeHours} onChange={e => setPassTimeHours(Number(e.target.value))} className="pass-time-slider" />
<span className="pass-time-label">{passTimeHours}h</span>
</div>
<div className="pass-time-btns">
<button className="pass-time-confirm" disabled={skipAnim !== null} onClick={() => {
const totalH = gameTime.day * 24 + gameTime.hour
const target = totalH + passTimeHours
setGameTime(gt => { let h = gt.hour + passTimeHours, d = gt.day; if (h >= 24) { d += Math.floor(h/24); h = h%24 } return { day: d, hour: h, minute: 0 } })
setSkipAnim({ current: totalH, target, start: performance.now() })
setPassTimeOpen(false)
}}>Skip</button>
<button className="pass-time-cancel" onClick={() => setPassTimeOpen(false)}>Cancel</button>
</div>
</div>
)}
<div className="bottom-left-btns">
<button className="fight-rand-btn" onClick={() => {
const shuffled = [...liveCharacters.filter(c => c.id !== 'player')].sort(() => Math.random() - 0.5)
const picked = shuffled.slice(0, 2).map((c, i) => i === 0 ? { ...c, weapon: 'rusty_sword' } : c)
setSelected(null); startFight(picked)
}}>Fight 2 random enemies</button>
</div>
{selected && (
<div className="info-panel">
<div className="info-name">{locMap[selected].name}</div>
<div className="info-desc">{locMap[selected].description}</div>
{player.location !== selected && connectedTo(player.location).includes(selected) && (
<div className="travel-section">
<div className="travel-label">Travel here</div>
<button className="travel-btn go-btn" onClick={() => travel(selected)}>Go to {locMap[selected].name}</button>
</div>
)}
<div className="characters-section">
<div className="characters-label">Characters here</div>
{liveCharacters.filter(c => c.location === selected).length === 0
? <div className="characters-empty">None</div>
: liveCharacters.filter(c => c.location === selected).map(c => (
<div key={c.id} className="character-entry" onContextMenu={e => { e.preventDefault(); e.stopPropagation(); setHoverInfo(null); setHoveredChar(null); setContextMenu({ x: e.clientX, y: e.clientY, charId: c.id }) }}>
<div className="character-name" style={{ cursor: 'pointer' }} onClick={() => { setShowInventory(true); setPanelTarget(c.id) }}>{c.name}</div>
<div className="character-details">
{c.money !== undefined && <span className="char-money">{c.money}g</span>}
{c.inventory?.length > 0 && <span className="char-inv">{c.inventory.map(id => itemMap[id]?.name ?? id).join(', ')}</span>}
</div>
</div>
))
}
</div>
<button className="close-btn" onClick={() => setSelected(null)}>Close</button>
</div>
)}
</>
)}
{/* ═══════════════════════════════════════════════════════ 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 (
<div className="inventory-overlay"
onClick={() => { setShowInventory(false); setPanelTarget('player'); setHoverInfo(null); setContextMenu(null) }}
onContextMenu={e => { e.preventDefault(); setHoverInfo(null); setHoveredChar(null); setContextMenu(null) }}
>
<div className="inventory-panel" onClick={e => e.stopPropagation()}>
<button className="inv-close" onClick={() => { setShowInventory(false); setPanelTarget('player'); setHoverInfo(null) }}>X</button>
<div className="inv-layout">
<div className="inv-body-col">
{character && <BodyImage body={character.integrity} />}
</div>
<div className="inv-right-col">
<div className="inv-tabs">
<button className={`inv-tab ${invTab === 'health' ? 'active' : ''}`} onClick={() => setInvTab('health')}>Health</button>
<button className={`inv-tab ${invTab === 'inventory' ? 'active' : ''}`} onClick={() => setInvTab('inventory')}>Inventory</button>
<button className={`inv-tab ${invTab === 'stats' ? 'active' : ''}`} onClick={() => setInvTab('stats')}>Statistics</button>
</div>
{character && invTab === 'health' && (
<div className="inv-parts-col">
<div className="inv-parts-title">Body Parts</div>
{Object.keys(MAX_BODY).map(part => {
const hp = character.integrity[part], max = MAX_BODY[part]
const injuries = character.injuries[part] ?? []
return (
<div key={part}>
<div className="part-row">
<div className="part-color" style={{ background: partColor(hp, max) }} />
<div className="part-label">{bodyPartLabels[part]}</div>
<div className="part-bar-track"><div className="part-bar-fill" style={{ width: `${(hp/max)*100}%`, background: partColor(hp, max) }} /></div>
<div className="part-hp">{hp}/{max}</div>
</div>
{injuries.length > 0 && <div className="part-injuries">{injuries.map((inj,i) => <span key={i} className={`part-injury part-injury--${inj.type}`}>{inj.type} ({inj.severity})</span>)}</div>}
</div>
)
})}
<div className="inv-total-row"><span>Integrity</span><span>{charHp}/{MAX_HP}</span></div>
</div>
)}
{character && invTab === 'inventory' && (
<div className="inv-inv-content">
{isPlayer && (
<>
<div className="inv-equip-section">
<div className="inv-parts-title">Weapons</div>
{(() => {
const lw = character.equipped.left, rw = character.equipped.right
if (lw === rw) return (
<div className="equip-row" onMouseEnter={() => setHoverInfo({ type:'weapon', id:lw })} onMouseLeave={() => setHoverInfo(null)}>
<span className="equip-name">{lw === 'fists' ? 'Fists' : `Both: ${weaponMap[lw].name}`}</span>
{lw !== 'fists' && <><span className="equip-stat">DMG: {weaponMap[lw].damage}</span><span className="equip-stat">COND: {character.weaponConditions[lw] ?? weaponMap[lw].maxCondition}/{weaponMap[lw].maxCondition}</span>{weaponMap[lw].passives.length > 0 && <span className="equip-passives">{weaponMap[lw].passives.join(', ')}</span>}<button className="equip-unequip-btn" onClick={unequipBoth}>Unequip</button></>}
</div>
)
return ['left','right'].map(side => {
const wid = character.equipped[side]
if (wid === 'fists') return null
return (
<div key={side} className="equip-row" onMouseEnter={() => setHoverInfo({ type:'weapon', id:wid })} onMouseLeave={() => setHoverInfo(null)}>
<span className="equip-name">{side === 'left' ? 'Left' : 'Right'}: {weaponMap[wid].name}</span>
<span className="equip-stat">DMG: {weaponMap[wid].damage}</span>
<span className="equip-stat">COND: {character.weaponConditions[wid] ?? weaponMap[wid].maxCondition}/{weaponMap[wid].maxCondition}</span>
{weaponMap[wid].passives.length > 0 && <span className="equip-passives">{weaponMap[wid].passives.join(', ')}</span>}
<button className="equip-unequip-btn" onClick={() => unequipWeapon(side)}>Unequip</button>
</div>
)
})
})()}
</div>
<div className="inv-divider" />
</>
)}
{!isPlayer && character.weapon && character.weapon !== 'fists' && (
<div className="inv-equip-section">
<div className="inv-parts-title">Equipped Weapon</div>
{(() => {
const wep = weaponMap[character.weapon]
return (
<div className="equip-row" onMouseEnter={() => setHoverInfo({ type:'weapon', id:character.weapon })} onMouseLeave={() => setHoverInfo(null)}>
<span className="equip-name">{wep?.name ?? character.weapon}</span>
{wep && <><span className="equip-stat">DMG: {character.attack}</span><span className="equip-stat">SPD: {character.speed}</span>{wep.passives?.length > 0 && <span className="equip-passives">{wep.passives.join(', ')}</span>}</>}
</div>
)
})()}
</div>
)}
{!isPlayer && character.weapon && character.weapon !== 'fists' && <div className="inv-divider" />}
<div className="inv-parts-title">Inventory ({character.inventory.length})</div>
{character.inventory.length === 0
? <div className="inv-empty">Empty</div>
: character.inventory.map((itemId, i) => {
const qty = character.itemCounts?.[itemId]
if (qty !== undefined && qty <= 0) return null
return (
<div key={i} className="inv-item" onMouseEnter={() => setHoverInfo({ type:'item', id:itemId })} onMouseLeave={() => setHoverInfo(null)}>
{itemMap[itemId]?.name ?? itemId}
{qty !== undefined
? <span style={{ marginLeft: 6, color: '#9ca3af' }}>×{qty}</span>
: itemMap[itemId] && <span style={{ marginLeft: 6, color: '#9ca3af' }}>×1</span>}
{isPlayer && weaponMap[itemId] && <button className="equip-btn" onClick={() => setEquipArmChoice(itemId)}>Equip</button>}
</div>
)
})
}
{isPlayer && <div className="inv-weight">Weight: {charWeight}/{MAX_WEIGHT}</div>}
<div className="inv-money">{character.money || 0}g</div>
</div>
)}
{character && invTab === 'stats' && (
<div className="inv-stats-content">
<div className="inv-parts-title">{isPlayer ? 'Character' : character.name}</div>
<div className="stat-row"><span className="stat-label">Name</span><span className="stat-value">{isPlayer ? 'You' : character.name}</span></div>
<div className="stat-row"><span className="stat-label">Age</span><span className="stat-value">{character.age ?? '??'}</span></div>
{isPlayer && <div className="stat-row"><span className="stat-label">Size</span><span className="stat-value">{character.size ?? 1}x</span></div>}
<div className="stat-row"><span className="stat-label">Location</span><span className="stat-value">{locMap[character.location]?.name ?? character.location}</span></div>
<div className="inv-divider" />
<div className="inv-divider" />
<div className="stat-row"><span className="stat-label">Integrity</span><span className="stat-value">{charHp}/{MAX_HP}</span></div>
<div className="stat-row"><span className="stat-label">Items</span><span className="stat-value">{character.inventory.length}</span></div>
{isPlayer && <div className="stat-row"><span className="stat-label">Weight</span><span className="stat-value">{charWeight}/{MAX_WEIGHT}</span></div>}
<div className="stat-row"><span className="stat-label">Money</span><span className="stat-value">{character.money || 0}g</span></div>
</div>
)}
</div>
</div>
</div>
</div>
)})()}
{/* ═══════════════════════════════════════════════════════ SETTINGS */}
{showSettings && (
<div className="settings-sidebar">
<div className="settings-header">Settings<button className="settings-close" onClick={() => setShowSettings(false)}>X</button></div>
<div className="settings-body">
<div className="settings-section">
<div className="settings-section-title">Display</div>
<div className="settings-row"><span className="settings-label">Zoom</span><div className="settings-zoom-group"><button className="settings-zoom-btn" onClick={zoomOut}></button><span className="settings-zoom-val">{Math.round(view.scale*100)}%</span><button className="settings-zoom-btn" onClick={zoomIn}>+</button></div></div>
</div>
<div className="settings-section">
<div className="settings-section-title">Game</div>
<div className="settings-row"><span className="settings-label">Date</span><span className="settings-value">{getDateString(gameTime.day)}</span></div>
<div className="settings-row"><span className="settings-label">Time</span><span className="settings-value">{String(gameTime.hour).padStart(2,'0')}:{String(Math.floor(gameTime.minute ?? 0)).padStart(2,'0')}</span></div>
<div className="settings-row"><span className="settings-label">Weather</span><span className="settings-value">{weatherLabels[weather]}</span></div>
</div>
<div className="settings-section">
<div className="settings-section-title">Player</div>
<div className="settings-row"><span className="settings-label">Location</span><span className="settings-value">{locMap[player.location]?.name ?? player.location}</span></div>
<div className="settings-row"><span className="settings-label">Money</span><span className="settings-value">{player.money}g</span></div>
</div>
</div>
</div>
)}
{/* ═══════════════════════════════════════════════════════ WORLD INFO */}
{showWorldInfo && (
<div className="inventory-overlay" onClick={() => setShowWorldInfo(false)}>
<div className="world-info-panel" onClick={e => e.stopPropagation()}>
<button className="inv-close" onClick={() => setShowWorldInfo(false)}>X</button>
<div className="inv-parts-title" style={{ marginBottom: 16 }}>World</div>
<div className="world-info-grid">
<div className="info-row"><span className="info-label">Date</span><span className="info-value">{getDateString(gameTime.day)}</span></div>
<div className="info-row"><span className="info-label">Time</span><span className="info-value">{String(gameTime.hour).padStart(2,'0')}:{String(Math.floor(gameTime.minute ?? 0)).padStart(2,'0')}</span></div>
<div className="info-row"><span className="info-label">Weather</span><span className="info-value">{weatherLabels[weather]}</span></div>
<div className="info-row"><span className="info-label">Location</span><span className="info-value">{locMap[player.location]?.name ?? player.location}</span></div>
</div>
</div>
</div>
)}
{/* ═══════════════════════════════════════════════════════ TOOLTIPS */}
{hovered && (
<div className="tooltip" style={{ left: mousePos.x + 16, top: mousePos.y }}>
<div className="tooltip-name">{locMap[hovered].name}</div>
<div className="tooltip-desc">{locMap[hovered].description}</div>
</div>
)}
{hoverInfo?.type === 'item' && itemMap[hoverInfo.id] && (
<div className="tooltip" style={{ left: mousePos.x + 16, top: mousePos.y }}>
<div className="tooltip-name">{itemMap[hoverInfo.id].name}</div>
<div className="tooltip-desc">{itemMap[hoverInfo.id].description}</div>
</div>
)}
{hoverInfo?.type === 'weapon' && weaponMap[hoverInfo.id] && (
<div className="tooltip" style={{ left: mousePos.x + 16, top: mousePos.y }}>
{hoverInfo.id === 'left_arm' || hoverInfo.id === 'right_arm'
? <><div className="tooltip-name">{weaponMap[hoverInfo.id].name}</div><div className="tooltip-desc">Unarmed strike (DMG: 10)</div></>
: <><div className="tooltip-name">{weaponMap[hoverInfo.id].name}</div><div className="tooltip-desc">DMG: {weaponMap[hoverInfo.id].damage} SPD: {weaponMap[hoverInfo.id].speed}</div>
<div className="tooltip-desc">Condition: {player.weaponConditions[hoverInfo.id] ?? weaponMap[hoverInfo.id].maxCondition}/{weaponMap[hoverInfo.id].maxCondition}</div>
<div className="tooltip-desc">Skills: {weaponMap[hoverInfo.id].skills.map(s=>`${s.name} (${s.damageType})`).join(', ')}</div>
{weaponMap[hoverInfo.id].passives.length > 0 && <div className="tooltip-desc">Passives: {weaponMap[hoverInfo.id].passives.join(', ')}</div>}</>
}
</div>
)}
{/* ═══════════════════════════════════════════════════════ 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 (
<div className="trade-overlay" onClick={() => { setShowTrade(false); setTradeTarget(null) }}>
<div className="trade-panel" onClick={e => e.stopPropagation()}>
<button className="trade-close" onClick={() => { setShowTrade(false); setTradeTarget(null) }}>X</button>
<div className="trade-title">Trade with {tgtChar.name ?? tradeTarget}</div>
<div className="trade-layout">
<div className="trade-col trade-col-left">
<div className="trade-col-header">{srcChar.name} Offers</div>
<div className="trade-items">
{srcItems.map(item => (
<div key={item.id} className="trade-item">
<span className="trade-item-name">{item.name}{item.qty > 1 ? ` (${item.qty})` : ''}{item.cond != null ? ` [${item.cond}/${item.maxCond}]` : ''}</span>
<div className="trade-qty-controls">
<button className="trade-qty-btn" onClick={() => setTradeGive(prev => ({ ...prev, [item.id]: Math.max(0, (prev[item.id] ?? 0) - 1) }))}>-</button>
<span className="trade-qty">{tradeGive[item.id] ?? 0}</span>
<button className="trade-qty-btn" onClick={() => setTradeGive(prev => ({ ...prev, [item.id]: Math.min(item.qty, (prev[item.id] ?? 0) + 1) }))}>+</button>
</div>
</div>
))}
</div>
</div>
<div className="trade-col trade-col-right">
<div className="trade-col-header">{tgtChar.name} Offers</div>
<div className="trade-items">
{tgtItems.map(item => (
<div key={item.id} className="trade-item">
<span className="trade-item-name">{item.name}{item.qty > 1 ? ` (${item.qty})` : ''}{item.cond != null ? ` [${item.cond}/${item.maxCond}]` : ''}</span>
<div className="trade-qty-controls">
<button className="trade-qty-btn" onClick={() => setTradeTake(prev => ({ ...prev, [item.id]: Math.max(0, (prev[item.id] ?? 0) - 1) }))}>-</button>
<span className="trade-qty">{tradeTake[item.id] ?? 0}</span>
<button className="trade-qty-btn" onClick={() => setTradeTake(prev => ({ ...prev, [item.id]: Math.min(item.qty, (prev[item.id] ?? 0) + 1) }))}>+</button>
</div>
</div>
))}
</div>
</div>
</div>
<div className="trade-bar">
<span className="trade-bar-label">Give: <span className="trade-give-val">{giveVal}g</span> &nbsp; Take: <span className="trade-take-val">{takeVal}g</span></span>
</div>
<button className="trade-propose-btn" onClick={proposeTrade}>Propose Trade</button>
</div>
</div>
)
})()}
{hoverInfo?.type === 'enemy' && (
<div className="tooltip" style={{ left: mousePos.x + 16, top: mousePos.y }}>
<div className="tooltip-name">{hoverInfo.name}</div>
<div className="tooltip-desc">{hoverInfo.defeated ? 'Defeated' : 'Hostile'}</div>
</div>
)}
{hoveredChar && !hoveredChar.startsWith('enemy-') && combat?.characters[hoveredChar] && (
<div className="tooltip" style={{ left: mousePos.x + 16, top: mousePos.y }}>
<div className="tooltip-name">{combat.characters[hoveredChar].name ?? hoveredChar}</div>
</div>
)}
{/* ═══════════════════════════════════════════════════════ EQUIP ARM CHOICE */}
{equipArmChoice && (
<div className="arm-choice-overlay" onClick={() => setEquipArmChoice(null)}>
<div className="arm-choice-panel" onClick={e => e.stopPropagation()}>
<div className="arm-choice-title">Equip {weaponMap[equipArmChoice].name}</div>
<div className="arm-choice-btns">
<button className="arm-choice-btn" onClick={() => equipFromInventory(equipArmChoice, 'left')}>Left Arm</button>
<button className="arm-choice-btn" onClick={() => equipFromInventory(equipArmChoice, 'right')}>Right Arm</button>
<button className="arm-choice-btn" onClick={() => equipFromInventory(equipArmChoice, 'both')}>Both Arms</button>
</div>
<button className="arm-choice-cancel" onClick={() => setEquipArmChoice(null)}>Cancel</button>
</div>
</div>
)}
{/* ═══════════════════════════════════════════════════════ CONTEXT MENU */}
{contextMenu && (
<div className="context-menu" style={{ left: contextMenu.x, top: contextMenu.y }} onClick={() => { setHoverInfo(null); setHoveredChar(null); setContextMenu(null) }}>
<button className={`context-menu-btn${canTrade(contextMenu.charId) ? '' : ' disabled'}`} disabled={!canTrade(contextMenu.charId)} onClick={e => { e.stopPropagation(); if (canTrade(contextMenu.charId)) startTrade(contextMenu.charId) }}>Trade</button>
</div>
)}
</div>
)
}