diff --git a/src/App.css b/src/App.css index 310694b..325c6a7 100644 --- a/src/App.css +++ b/src/App.css @@ -102,6 +102,50 @@ body { padding: 16px 60px 24px; } +.combat-over-panel { + z-index: 101; + display: flex; + flex-direction: column; + align-items: center; + gap: 16px; + padding: 24px; +} + +.combat-over-title { + color: #fbbf24; + font-size: 20px; + font-weight: bold; +} + +.combat-over-btns { + display: flex; + gap: 12px; +} + +.combat-over-btn { + background: #2e303a; + color: #e0e0e0; + border: 1px solid #5F71C5; + border-radius: 1px; + padding: 10px 24px; + cursor: pointer; + font-size: 15px; +} + +.combat-over-btn:hover { + background: #5F71C5; + color: #fff; +} + +.combat-over-btn.primary { + background: #5F71C5; + color: #fff; +} + +.combat-over-btn.primary:hover { + background: #4a5cb0; +} + .combat-top-bar { width: 100%; display: flex; diff --git a/src/App.jsx b/src/App.jsx index 613d555..2bea77a 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -2,71 +2,69 @@ import { useState, useRef, useCallback, useEffect, Fragment } from 'react' import './App.css' import maleBody from './assets/body/malebody.png' +// ───────────────────────────────────────────────────────────────────────────── +// 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 }, + { 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' }, + { 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: 'You', location: 'A' }, - { id: '1', name: 'Mara', location: 'A', money: 120, inventory: ['herbs', 'old_map'] }, - { id: '2', name: 'Rook', location: 'A', money: 0, inventory: ['lockpicks'] }, - { id: '3', name: 'Vex', location: 'B', money: 500, inventory: ['silver_brooch', 'fine_wine'] }, - { id: 'd1', name: 'Dust Gutter', location: 'D', money: 15, inventory: ['health_potion'] }, - { id: 'd2', name: 'Bone Collector', location: 'D', money: 30, inventory: ['torch', 'old_map'] }, - { id: 'd3', name: 'Sand Viper', location: 'D', money: 8, inventory: ['lockpicks'] }, - { id: 'd4', name: 'Rust-Eye', location: 'D', money: 45, inventory: ['health_potion', 'silver_brooch'] }, - { id: 'd5', name: 'Cinder', location: 'D', money: 22, inventory: ['herbs', 'rope'] }, + { id: 'player', name: 'You', location: 'A' }, + { id: '1', name: 'Mara', location: 'A', money: 120, inventory: ['herbs', 'old_map'] }, + { id: '2', name: 'Rook', location: 'A', money: 0, inventory: ['lockpicks'] }, + { id: '3', name: 'Vex', location: 'B', money: 500, inventory: ['silver_brooch', 'fine_wine'] }, + { id: 'd1', name: 'Dust Gutter', location: 'D', money: 15, inventory: ['health_potion'] }, + { id: 'd2', name: 'Bone Collector', location: 'D', money: 30, inventory: ['torch', 'old_map'] }, + { id: 'd3', name: 'Sand Viper', location: 'D', money: 8, inventory: ['lockpicks'] }, + { id: 'd4', name: 'Rust-Eye', location: 'D', money: 45, inventory: ['health_potion', 'silver_brooch'] }, + { id: 'd5', name: 'Cinder', location: 'D', money: 22, inventory: ['herbs', 'rope'] }, ] const shops = [ { - id: 'shop_e', - locationId: 'E', - name: 'Epsilon Market', + id: 'shop_e', locationId: 'E', name: 'Epsilon Market', items: [ - { name: 'Health Potion', price: 15, itemId: 'health_potion' }, - { name: 'Steel Sword', price: 80, weaponId: 'steel_sword' }, - { name: 'Iron Shield', price: 50, weaponId: 'iron_shield' }, - { name: 'Dagger', price: 30, weaponId: 'dagger' }, - { name: 'Torch', price: 5, itemId: 'torch' }, - { name: 'Rope', price: 8, itemId: 'rope' }, + { name: 'Health Potion', price: 15, itemId: 'health_potion' }, + { name: 'Steel Sword', price: 80, weaponId: 'steel_sword' }, + { name: 'Iron Shield', price: 50, weaponId: 'iron_shield' }, + { name: 'Dagger', price: 30, weaponId: 'dagger' }, + { name: 'Torch', price: 5, itemId: 'torch' }, + { name: 'Rope', price: 8, itemId: 'rope' }, ], }, ] 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: '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 }] }, + { 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: '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: 'health_potion', name: 'Health Potion', description: 'Restores 15 HP when consumed.', weight: 1 }, - { id: 'torch', name: 'Torch', description: 'A wooden torch that burns for hours. Provides light in dark places.', weight: 2 }, - { id: 'rope', name: 'Rope', description: '20 feet of sturdy rope. Useful for climbing and binding.', weight: 3 }, - { id: 'leather_bag', name: 'Leather Bag', description: 'A worn leather bag with plenty of storage space.', weight: 1 }, - { id: 'herbs', name: 'Herbs', description: 'A bundle of medicinal herbs.', weight: 1 }, - { id: 'old_map', name: 'Old Map', description: 'A faded map of the surrounding region.', weight: 1 }, - { id: 'lockpicks', name: 'Lockpicks', description: 'A set of fine metal tools for picking locks.', weight: 1 }, - { id: 'silver_brooch', name: 'Silver Brooch', description: 'A polished silver brooch inlaid with a small gem.', weight: 1 }, - { id: 'fine_wine', name: 'Fine Wine', description: 'A bottle of aged red wine worth a pretty penny.', weight: 2 }, + { id: 'health_potion', name: 'Health Potion', description: 'Restores 15 HP when consumed.', weight: 1 }, + { id: 'torch', name: 'Torch', description: 'A wooden torch that burns for hours.', weight: 2 }, + { id: 'rope', name: 'Rope', description: '20 feet of sturdy rope.', weight: 3 }, + { id: 'leather_bag', name: 'Leather Bag', description: 'A worn leather bag with plenty of storage space.', weight: 1 }, + { id: 'herbs', name: 'Herbs', description: 'A bundle of medicinal herbs.', weight: 1 }, + { id: 'old_map', name: 'Old Map', description: 'A faded map of the surrounding region.', weight: 1 }, + { id: 'lockpicks', name: 'Lockpicks', description: 'A set of fine metal tools for picking locks.', weight: 1 }, + { id: 'silver_brooch', name: 'Silver Brooch', description: 'A polished silver brooch inlaid with a small gem.', weight: 1 }, + { id: 'fine_wine', name: 'Fine Wine', description: 'A bottle of aged red wine worth a pretty penny.', weight: 2 }, ] const itemMap = {} @@ -74,313 +72,825 @@ items.forEach(i => { itemMap[i.id] = i }) const weaponMap = {} weapons.forEach(w => { weaponMap[w.id] = w }) -weaponMap['left_arm'] = { id: 'left_arm', name: 'Left Arm', 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['right_arm'] = { id: 'right_arm', name: 'Right Arm', 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 }] } +// 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 } -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) +// ───────────────────────────────────────────────────────────────────────────── +// 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 const MAP_BLOCKS = [ - { x: 30, y: 20, w: 20, h: 40 }, - { x: 100, y: 10, w: 25, h: 35 }, - { x: 70, y: 60, w: 40, h: 15 }, - { x: 15, y: 90, w: 35, h: 20 }, + { x: 30, y: 20, w: 20, h: 40 }, + { x: 100, y: 10, w: 25, h: 35 }, + { x: 70, y: 60, w: 40, h: 15 }, + { x: 15, y: 90, w: 35, h: 20 }, { x: 105, y: 100, w: 30, h: 30 }, ] +const INJURY_DECAY = { cut: 0.3, laceration: 0.6, stab: 0.8 } + +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 } -const bodyPartLabels = { - head: 'Head', - torso: 'Torso', - leftArm: 'Left Arm', - rightArm: 'Right Arm', - leftLeg: 'Left Leg', - rightLeg: 'Right Leg', -} - function partColor(hp, max) { - const ratio = hp / max - if (ratio > 0.6) return '#22c55e' - if (ratio > 0.3) return '#eab308' + 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 injuryType(damageType) { - if (damageType === 'blunt') return 'laceration' - if (damageType === 'pierce') return 'stab' - return 'cut' -} - -const INJURY_DECAY = { cut: 0.3, laceration: 0.6, stab: 0.8 } - -function applyInjury(injuries, part, type, severity) { - const next = { ...injuries, [part]: [...(injuries[part] ?? []), { type, severity }] } - return next -} - -function calcIntegrity(integrity, injuries, deltaTime = 1) { - let totalDrain = 0 - for (const part of Object.keys(injuries)) { - for (const inj of injuries[part]) { - totalDrain += (INJURY_DECAY[inj.type] ?? 0.3) * inj.severity - } - } - const drained = { ...integrity } - for (const part of Object.keys(drained)) { - drained[part] = Math.max(0, drained[part] - totalDrain * deltaTime) - } - return drained -} - -function freshInjuries() { - return { head: [], torso: [], leftArm: [], rightArm: [], leftLeg: [], rightLeg: [] } -} - -function totalHp(body) { - return Object.values(body).reduce((a, b) => a + b, 0) -} +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 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 MAP_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) { + 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 steps = Math.floor(speed / step) + let cx = from.x, cy = from.y + for (let i = 0; i < steps; i++) { + const nx = cx + (dx / dist) * step + const ny = cy + (dy / dist) * step + if (MAP_BLOCKS.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) { + 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 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 (MAP_BLOCKS.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 }) { + 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) { + 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, combatLog }) { + 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') { + // Shove is resolved immediately in the handler, not here + return { type: 'shove_pending', actor: 'player', weaponId, skillName, enemyIndex } + } + + const bothMult = (!isArm && equippedWeapons.left === weaponId && equippedWeapons.right === weaponId) ? 1.5 : 1.0 + const { dmg, severity, injType, injLabel } = computeHit({ weaponId, skill, weaponConditions, bothMult }) + + return { + type: 'attack', + actor: 'player', + weaponId, skillName, targetPart, enemyIndex, + dmg, severity, injType, injLabel, + combatLog, bodyParts, bodyInjuries, + } +} + +/** + * Run enemy AI. Returns an array of raw action objects. + * Uses the same computeMove / computeHit as the player. + */ +function runEnemyAI(combat) { + const actions = [] + const moveTargets = {} + const hits = [] + + for (let i = 0; i < combat.enemies.length; i++) { + const e = combat.enemies[i] + if (e.integrity.head <= 0) continue + + const dist = Math.sqrt( + (combat.playerPos.x - e.pos.x) ** 2 + (combat.playerPos.y - e.pos.y) ** 2 + ) + + // ── in range → attack + if (dist <= 15) { + const weaponId = e.weapon ?? 'fists' + const base = weaponMap[weaponId] + const skill = (base?.skills ?? [{ name: 'Attack', mult: 1.0, damageType: 'blunt' }])[ + Math.floor(Math.random() * (base?.skills?.length ?? 1)) + ] + const { dmg, severity, injType, injLabel } = computeHit({ weaponId, skill, weaponConditions: {} }) + hits.push({ name: e.name, dmg, severity, part: randomPart(), type: injType, label: injLabel }) + } else { + // ── out of range → move toward player + const speed = e.speed > 12 ? 3 : 1.5 + const newPos = computeMove(e.pos, combat.playerPos, speed) + if (newPos.x !== e.pos.x || newPos.y !== e.pos.y) { + moveTargets[i] = { fromX: e.pos.x, fromY: e.pos.y, toX: newPos.x, toY: newPos.y } + } + } + } + + if (Object.keys(moveTargets).length > 0) actions.push({ type: 'enemyMove', targets: moveTargets }) + if (hits.length > 0) actions.push({ type: 'enemyAttack', hits }) + return actions +} + +// ───────────────────────────────────────────────────────────────────────────── +// ACTION HANDLERS (called from the animation / timer loop) +// ───────────────────────────────────────────────────────────────────────────── + +const actionHandlers = { + /** + * Player melee attack — triggers the attack animation state. + */ + attack: (action, { setAttackAnim, bodyParts, bodyInjuries }) => { + setAttackAnim({ + actor: 'player', + targetPart: action.targetPart, + weaponId: action.weaponId, + skillName: action.skillName, + dmg: action.dmg, + severity: action.severity, + injType: action.injType, + injLabel: action.injLabel, + enemyIndex: action.enemyIndex, + combatLog: action.combatLog, + bodyParts, + bodyInjuries, + startTs: null, + }) + }, + + /** + * Shove action — resolved here using the shared computeShove helper. + */ + shove_pending: (action, { combat, setCombat }) => { + const enemy = combat.enemies[action.enemyIndex] + if (!enemy || enemy.integrity.head <= 0) return + const { pos, actualPush } = computeShove(combat.playerPos, enemy.pos) + setCombat(c => ({ + ...c, + enemies: c.enemies.map((e, i) => i === action.enemyIndex ? { ...e, pos } : e), + log: [...c.log, `You shove ${enemy.name} back ${Math.round(actualPush)} units!`], + })) + }, + + /** + * Player movement — uses the shared computeMove helper via the moveAnim loop. + */ + move: (action, { setMoveAnim }) => { + setMoveAnim({ fromX: action.fromX, fromY: action.fromY, toX: action.toX, toY: action.toY, speed: action.speed }) + }, + + /** + * Enemy movement batch. + */ + enemyMove: (action, { setEnemyMoveAnims }) => { + setEnemyMoveAnims(action.targets) + }, + + /** + * Enemy melee hits — resolved immediately (no animation frame for enemies yet). + */ + enemyAttack: (action, { setCombat, setBodyParts, setBodyInjuries, combat, combatTimeRef }) => { + const newLog = [...combat.log] + for (const entry of action.hits) { + newLog.push(`${entry.name} lands a ${entry.label} on your ${bodyPartLabels[entry.part]} (severity ${entry.severity}, integrity -${entry.dmg}).`) + } + setCombat({ ...combat, log: newLog }) + for (const entry of action.hits) { + setBodyParts(p => ({ ...p, [entry.part]: Math.max(0, p[entry.part] - entry.dmg) })) + } + setBodyInjuries(prev => { + const injuries = { ...prev } + for (const entry of action.hits) { + const key = `${entry.type}_${entry.severity}_${entry.part}_${Date.now()}_${Math.random()}` + injuries[key] = { type: entry.type, severity: entry.severity, part: entry.part } + } + return injuries + }) + }, +} + +// ───────────────────────────────────────────────────────────────────────────── +// 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: '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: 'leftLeg', left: '20%', top: '58%', width: '30%', height: '36%' }, { part: 'rightLeg', left: '50%', top: '58%', width: '30%', height: '36%' }, ] function BodyImage({ body }) { - const c = (part) => partColor(body[part], MAX_BODY[part]) - return (
{bodyOverlays.map(o => (
+ style={{ left: o.left, top: o.top, width: o.width, height: o.height, background: partColor(body[o.part], MAX_BODY[o.part]) }} /> ))}
) } -function App() { - 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 [playerLoc, setPlayerLoc] = useState('A') - const [travelAnim, setTravelAnim] = useState(null) - const [combat, setCombat] = useState(null) - const [bodyParts, setBodyParts] = useState(freshBody()) - const [bodyInjuries, setBodyInjuries] = useState(freshInjuries()) - const [combatTime, setCombatTime] = useState(0) - const [displayTime, setDisplayTime] = useState(0) - const [playerMoney, setPlayerMoney] = useState(50) +// ───────────────────────────────────────────────────────────────────────────── +// APP +// ───────────────────────────────────────────────────────────────────────────── + +export default function App() { + // ── world state + const [playerLoc, setPlayerLoc] = useState('A') + const [playerMoney, setPlayerMoney] = useState(50) const [playerInventory, setPlayerInventory] = useState(['leather_bag']) - const [hoverInfo, setHoverInfo] = useState(null) const [equippedWeapons, setEquippedWeapons] = useState({ left: 'fists', right: 'rusty_sword' }) - const [weaponConditions, setWeaponConditions] = useState({ rusty_sword: 65 }) - const [buyArmChoice, setBuyArmChoice] = useState(null) - const [showInventory, setShowInventory] = useState(false) - const [showWorldInfo, setShowWorldInfo] = useState(false) - const [invTab, setInvTab] = useState('health') - const [gameTime, setGameTime] = useState({ day: 1, hour: 8 }) - const [contextMenu, setContextMenu] = useState(null) - const [weather, setWeather] = useState('clear') - const [expandedWeapon, setExpandedWeapon] = useState(null) - const [weaponsOpen, setWeaponsOpen] = useState(false) - const [itemsOpen, setItemsOpen] = useState(false) - const [movementOpen, setMovementOpen] = useState(false) - const [moveMode, setMoveMode] = useState(null) - const [playerMoveTarget, setPlayerMoveTarget] = useState(null) - const [moveAnim, setMoveAnim] = useState(null) - const [attackAnim, setAttackAnim] = useState(null) - const [attackLerp, setAttackLerp] = useState(null) - const [hitShake, setHitShake] = useState(null) - const [mapView, setMapView] = useState({ x: 0, y: 0, size: 150 }) - const mapPanning = useRef(false) - const mapMoved = useRef(false) - const mapPanStart = useRef({ x: 0, y: 0 }) - const moveAnimRef = useRef(null) - moveAnimRef.current = moveAnim - const attackAnimRef = useRef(null) - attackAnimRef.current = attackAnim - const combatRef = useRef(null) - combatRef.current = combat + const [weaponConditions,setWeaponConditions]= useState({ rusty_sword: 65 }) + const [gameTime, setGameTime] = useState({ day: 1, hour: 8 }) + const [weather, setWeather] = useState('clear') - const locMap = {} - locations.forEach(l => { locMap[l.id] = l }) + // ── 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 [showSettings, setShowSettings] = useState(false) + const [invTab, setInvTab] = useState('health') + const [panelTarget, setPanelTarget] = useState('player') + const [contextMenu, setContextMenu] = useState(null) + const [buyArmChoice, setBuyArmChoice] = useState(null) + const [passTimeOpen, setPassTimeOpen] = useState(false) + const [passTimeHours, setPassTimeHours] = useState(1) + const [skipAnim, setSkipAnim] = useState(null) + const [zoomAnim, setZoomAnim] = useState(null) + const [showLocationModal,setShowLocationModal]= useState(null) + const [actionNpc, setActionNpc] = useState(null) + const [locationLog, setLocationLog] = useState([]) + // ── combat state + const [combat, setCombat] = useState(null) + const [bodyParts, setBodyParts] = useState(freshBody()) + const [bodyInjuries, setBodyInjuries] = useState(freshInjuries()) + 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(null) + const [weaponsOpen, setWeaponsOpen] = useState(false) + const [itemsOpen, setItemsOpen] = useState(false) + const [movementOpen, setMovementOpen] = useState(false) + 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) + + // ── animation state + const [actionQueue, setActionQueue] = useState([]) + const [moveAnim, setMoveAnim] = useState(null) + const [enemyMoveAnims, setEnemyMoveAnims] = useState(null) + const [attackAnim, setAttackAnim] = useState(null) + const [attackLerp, setAttackLerp] = useState(null) + const [hitShake, setHitShake] = useState(null) + const [minimapView, setMinimapView] = useState({ x: 0, y: 0, scale: 1 }) + + // ── replay + const [fightRecord, setFightRecord] = useState(null) + const [replayActive, setReplayActive] = 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 + // timerActiveRef is a standalone ref — written synchronously in executeAttack/startMove + // so the rAF loop sees it on the very next frame, not after a React re-render. + const timerActiveRef = useRef(false) + // timerRunning is the React state mirror used only for the UI play icon. + const [timerRunning, setTimerRunning] = useState(false) + const actionQueueRef = useRef([]); actionQueueRef.current = actionQueue + const bodyPartsRef = useRef(null); bodyPartsRef.current = bodyParts + const bodyInjuriesRef = useRef(null); bodyInjuriesRef.current = bodyInjuries + const moveAnimRef = useRef(null); moveAnimRef.current = moveAnim + const enemyMoveAnimsRef = useRef(null); enemyMoveAnimsRef.current = enemyMoveAnims + const attackAnimRef = useRef(null); attackAnimRef.current = attackAnim + const replayFrameRef = useRef(null) + const replayIdxRef = useRef(0) + 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 equippedWeaponsRef = useRef(null); equippedWeaponsRef.current = equippedWeapons + + // ───────────────────────────────────────────────────────────────────────── + // DERIVED + // ───────────────────────────────────────────────────────────────────────── + + const locMap = {}; locations.forEach(l => { locMap[l.id] = l }) const weatherLabels = { clear: 'Clear', cloudy: 'Cloudy', rain: 'Rain', storm: 'Storm' } - - const combatLogEnd = useRef(null) - const svgRef = useRef(null) - const isPanning = useRef(false) - const panStart = useRef({ x: 0, y: 0 }) - - const playerHp = totalHp(bodyParts) + const playerHp = totalHp(bodyParts) const currentWeight = playerInventory.reduce((sum, id) => sum + (itemMap[id]?.weight ?? 0), 0) - - const [showSettings, setShowSettings] = useState(false) - const [showLocationModal, setShowLocationModal] = useState(null) - const [surroundingsExpanded, setSurroundingsExpanded] = useState(false) - const [charactersExpanded, setCharactersExpanded] = useState(false) - const [actionNpc, setActionNpc] = useState(null) - const [selectedNpc, setSelectedNpc] = useState(null) - const [locationLog, setLocationLog] = useState([`You arrive at ${locMap[playerLoc]?.name}.`]) - 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 [targetEnemy, setTargetEnemy] = useState(null) - const [attackEnemy, setAttackEnemy] = useState(null) - const [panelTarget, setPanelTarget] = useState('player') - const [passTimeOpen, setPassTimeOpen] = useState(false) - const [passTimeHours, setPassTimeHours] = useState(1) - const [skipAnim, setSkipAnim] = useState(null) - const [zoomAnim, setZoomAnim] = useState(null) - const passTimeRef = useRef(null) - const viewRef = useRef(null) - viewRef.current = view - const [minimapView, setMinimapView] = useState({ x: 0, y: 0, scale: 1 }) - const minimapPanRef = useRef(false) - const minimapPanStart = useRef({ x: 0, y: 0 }) - const minimapMovedRef = useRef(false) - - const liveCharacters = characters + const shopAtLoc = shops.find(s => s.locationId === selected && s.locationId === playerLoc) + const liveCharacters= characters 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) + if (c.to === locId) result.push(c.from) } return result } - const shopAtLoc = shops.find(s => s.locationId === selected && s.locationId === playerLoc) + // ───────────────────────────────────────────────────────────────────────── + // TIMER — only ticks while timerActive is true + // ───────────────────────────────────────────────────────────────────────── - const travel = (targetId) => { - if (travelAnim) return - const from = locations.find(l => l.id === playerLoc) - const to = locations.find(l => l.id === targetId) - if (!from || !to) return - setTravelAnim({ from, to, fromId: playerLoc, toId: targetId, startTs: null, progress: 0 }) - } + useEffect(() => { + if (!combat || replayActive) { setDisplayTime(0); return } + let lastTime = performance.now() + let rafId - const buyItem = (item) => { - if (playerMoney < item.price) return - if (item.weaponId) { - setBuyArmChoice({ item }) - } else { - setPlayerMoney(m => m - item.price) - setPlayerInventory(prev => [...prev, item.itemId]) + const tick = () => { + rafId = requestAnimationFrame(tick) + const now = performance.now() + const dt = (now - lastTime) / 1000 + lastTime = now + + if (!timerActiveRef.current) return // ← timer only advances during actions + + setCombatTime(t => t + dt) + setDisplayTime(t => t + dt) + + // ── dispatch queued actions once no animation is running + let dispatchedThisTick = false + if (!enemyMoveAnimsRef.current && !attackAnimRef.current && !moveAnimRef.current) { + const q = actionQueueRef.current + if (q.length > 0) { + dispatchedThisTick = true + const ctx = { + combat: combatRef.current, + setAttackAnim, setMoveAnim, setCombat, + setEnemyMoveAnims, setBodyParts, setBodyInjuries, + bodyParts: bodyPartsRef.current, + bodyInjuries: bodyInjuriesRef.current, + combatTimeRef, + } + for (const action of q) { + const handler = actionHandlers[action.type] + if (handler) handler(action, ctx) + } + actionQueueRef.current = [] + setActionQueue([]) + + // ── enemy reacts after player commits + const enemyActions = runEnemyAI(combatRef.current) + for (const action of enemyActions) { + const handler = actionHandlers[action.type] + if (handler) handler(action, ctx) + } + } + } + + // ── pause timer once everything settles (skip on ticks that dispatched + // actions, since animation refs won't update until React re-renders) + if ( + !dispatchedThisTick && + !enemyMoveAnimsRef.current && + !attackAnimRef.current && + !moveAnimRef.current && + actionQueueRef.current.length === 0 + ) { + timerActiveRef.current = false + setTimerRunning(false) + } } - } - const confirmEquipArm = (arm) => { - const { item } = buyArmChoice - setPlayerMoney(m => m - item.price) - setWeaponConditions(prev => ({ ...prev, [item.weaponId]: weaponMap[item.weaponId].maxCondition })) - setEquippedWeapons(prev => { - if (arm === 'left') return { ...prev, left: item.weaponId } - if (arm === 'right') return { ...prev, right: item.weaponId } - return { left: item.weaponId, right: item.weaponId } + rafId = requestAnimationFrame(tick) + return () => cancelAnimationFrame(rafId) + }, [combat, replayActive]) + + // ───────────────────────────────────────────────────────────────────────── + // ATTACK ANIMATION (lerp player toward enemy, apply hit at peak) + // ───────────────────────────────────────────────────────────────────────── + + useEffect(() => { + if (replayActive) return + const anim = attackAnimRef.current + if (!anim) return + const startTs = performance.now() + const hitAt = 0.12 + let hit = false + const startPos = combatRef.current?.playerPos ?? { x: 20, y: 75 } + const targetE = anim.enemyIndex !== undefined ? combatRef.current?.enemies[anim.enemyIndex] : null + const targetPos= targetE?.pos ?? startPos + + const frame = requestAnimationFrame(function tick() { + const elapsed = (performance.now() - startTs) / 1000 + const lerp = elapsed < hitAt + ? elapsed / hitAt + : elapsed < 0.25 + ? 1 - (elapsed - hitAt) / (0.25 - hitAt) + : 0 + setAttackLerp({ from: startPos, to: targetPos, t: lerp }) + + if (elapsed >= hitAt && !hit) { + hit = true + setHitShake({ enemyIndex: anim.enemyIndex, startTs: performance.now() }) + + // degrade weapon condition + const wid = anim.weaponId + if (wid !== 'fists' && wid !== 'left_arm' && wid !== 'right_arm') { + const base = weaponMap[wid] + setWeaponConditions(prev => ({ ...prev, [wid]: Math.max(0, (prev[wid] ?? base.maxCondition) - (1 + Math.floor(Math.random() * 2))) })) + } + + const decayedPlayer = calcIntegrity(anim.bodyParts, anim.bodyInjuries, hitAt) + let log = [...anim.combatLog] + const cc = combatRef.current + + if (anim.enemyIndex !== undefined && cc) { + const enemy = cc.enemies[anim.enemyIndex] + if (enemy) { + const decayedEnemy = calcIntegrity(enemy.integrity, enemy.injuries, hitAt) + const hitEnemy = { ...decayedEnemy, [anim.targetPart]: Math.max(0, decayedEnemy[anim.targetPart] - anim.dmg) } + const newInjuries = applyInjury(enemy.injuries, anim.targetPart, anim.injType, anim.severity) + + log.push(`You ${anim.skillName?.toLowerCase() ?? 'attack'} ${enemy.name}'s ${bodyPartLabels[anim.targetPart]} — ${anim.injLabel} (severity ${anim.severity}, -${anim.dmg} integrity).`) + if (hitEnemy.head <= 0) { + log.push(`You crush ${enemy.name}'s head!`) + log.push(`${enemy.name} is defeated!`) + } + + setCombat(prev => ({ + ...prev, + enemies: prev.enemies.map((e, i) => i === anim.enemyIndex ? { ...e, integrity: hitEnemy, injuries: newInjuries } : e), + log, + })) + } + } + + setBodyParts(decayedPlayer) + setBodyInjuries(anim.bodyInjuries) + } + + if (elapsed >= 0.35) { + setAttackAnim(null) + setAttackLerp(null) + setHitShake(null) + } else { + requestAnimationFrame(tick) + } + }) + return () => cancelAnimationFrame(frame) + }, [attackAnim]) + + // ───────────────────────────────────────────────────────────────────────── + // MOVE ANIMATION (player) + // Uses the shared computeMove helper. + // ───────────────────────────────────────────────────────────────────────── + + useEffect(() => { + if (replayActive) return + const anim = moveAnimRef.current + if (!anim) return + const frame = requestAnimationFrame(() => { + const newPos = computeMove( + { x: anim.fromX, y: anim.fromY }, + { x: anim.toX, y: anim.toY }, + anim.speed + ) + if (newPos.x === anim.toX && newPos.y === anim.toY) { + setCombat(c => ({ ...c, playerPos: newPos })) + setMoveAnim(null) + } else if (newPos.x === anim.fromX && newPos.y === anim.fromY) { + setMoveAnim(null) // blocked + } else { + setMoveAnim({ fromX: newPos.x, fromY: newPos.y, toX: anim.toX, toY: anim.toY, speed: anim.speed }) + setCombat(c => ({ ...c, playerPos: newPos })) + } + }) + return () => cancelAnimationFrame(frame) + }, [moveAnim]) + + // ───────────────────────────────────────────────────────────────────────── + // ENEMY MOVE ANIMATION + // ───────────────────────────────────────────────────────────────────────── + + useEffect(() => { + if (replayActive) return + const anims = enemyMoveAnimsRef.current + if (!anims || Object.keys(anims).length === 0) return + const startTs = performance.now() + const DURATION = 200 + const frame = requestAnimationFrame(function tick() { + const t = Math.min((performance.now() - startTs) / DURATION, 1) + setCombat(c => { + const enemies = [...c.enemies] + for (const idxStr of Object.keys(anims)) { + const idx = parseInt(idxStr) + const a = anims[idx] + enemies[idx] = { ...enemies[idx], pos: { x: a.fromX + (a.toX - a.fromX) * t, y: a.fromY + (a.toY - a.fromY) * t } } + } + return { ...c, enemies } + }) + if (t >= 1) setEnemyMoveAnims(null) + else requestAnimationFrame(tick) + }) + return () => cancelAnimationFrame(frame) + }, [enemyMoveAnims]) + + // ───────────────────────────────────────────────────────────────────────── + // HIT SHAKE + // ───────────────────────────────────────────────────────────────────────── + + useEffect(() => { + if (replayActive || !hitShake) return + const frame = requestAnimationFrame(function tick() { + if (!hitShake) return + if (performance.now() - hitShake.startTs >= 300) setHitShake(null) + else requestAnimationFrame(tick) + }) + return () => cancelAnimationFrame(frame) + }, [hitShake]) + + // ───────────────────────────────────────────────────────────────────────── + // 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) { setPlayerLoc(travelAnim.toId); setTravelAnim(null) } + }) + return () => cancelAnimationFrame(frame) + }, [travelAnim]) + + // ───────────────────────────────────────────────────────────────────────── + // COMBAT OVER + // ───────────────────────────────────────────────────────────────────────── + + useEffect(() => { + if (!combat || replayActive) return + if (playerHp <= 0 || !combat.enemies.some(e => e.integrity.head > 0)) endFight() + }, [combat, bodyParts, playerHp]) + + // ───────────────────────────────────────────────────────────────────────── + // SNAPSHOT (recording) + // ───────────────────────────────────────────────────────────────────────── + + const pushSnapshot = () => { + if (!recordingRef.current || !combat) return + recordingRef.current.frames.push({ + time: combatTime, + combat: JSON.parse(JSON.stringify(combat)), + bodyParts: JSON.parse(JSON.stringify(bodyParts)), + bodyInjuries: JSON.parse(JSON.stringify(bodyInjuries)), + attackLerp: attackLerp ? { ...attackLerp } : null, + moveAnim: moveAnim ? { ...moveAnim } : null, + hitShake: hitShake ? { ...hitShake } : null, }) - setBuyArmChoice(null) } - const handleWheel = (e) => { - e.preventDefault() - const rect = svgRef.current.getBoundingClientRect() - const mx = e.clientX - rect.left - const my = e.clientY - rect.top - zoomTo(e.deltaY > 0 ? 1 / 1.3 : 1.3, mx, my) + 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.bodyParts) !== JSON.stringify(bodyParts)) { + pushSnapshot() + } + }, [combat, bodyParts, bodyInjuries]) + + // ───────────────────────────────────────────────────────────────────────── + // 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 } }), 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 duration = (skipAnim.target - skipAnim.current) * 2000 + const frame = requestAnimationFrame(function tick() { + const t = Math.min((performance.now() - skipAnim.start) / duration, 1) + if (t >= 1) setSkipAnim(null) + else { setSkipAnim(s => ({ ...s, current: s.current + (s.target - s.current) * t })); 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 + // ───────────────────────────────────────────────────────────────────────── + + useEffect(() => { + if (!replayActive && replayFrameRef.current) { cancelAnimationFrame(replayFrameRef.current); replayFrameRef.current = null } + }, [replayActive]) + + const startReplay = () => { + setReplayActive(true) + const rec = fightRecord + const f0 = rec.frames[0] + setCombat(f0.combat); setBodyParts(f0.bodyParts); setBodyInjuries(f0.bodyInjuries) + setAttackLerp(f0.attackLerp ?? null); setMoveAnim(f0.moveAnim ?? null); setHitShake(f0.hitShake ?? null) + setAttackAnim(null); setCombatTime(0); setDisplayTime(0) + replayIdxRef.current = 0 + + const startTs = performance.now() + const tick = () => { + const elapsed = performance.now() - startTs + const idx = rec.frames.findLastIndex(f => f.time <= elapsed / 1000) + if (idx > replayIdxRef.current) { + replayIdxRef.current = idx + const f = rec.frames[idx] + setCombat(f.combat); setBodyParts(f.bodyParts); setBodyInjuries(f.bodyInjuries) + setAttackLerp(f.attackLerp ?? null); setMoveAnim(f.moveAnim ?? null); setHitShake(f.hitShake ?? null) + setCombatTime(f.time) + } + if (idx >= rec.frames.length - 1) return + replayFrameRef.current = requestAnimationFrame(tick) + } + replayFrameRef.current = requestAnimationFrame(tick) } - const handleBgPointerDown = (e) => { - if (e.button !== 0) return - 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 - 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 - 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 mx = e.clientX - ox, my = e.clientY - oy - const px = panStart.current.x, py = panStart.current.y - const dx = (mx - px) / panStart.current.s / v.scale - const dy = (my - py) / panStart.current.s / v.scale - setView({ ...v, x: panStart.current.vx + dx, y: panStart.current.vy + dy }) - } - - const handleBgPointerUp = () => { - isPanning.current = false - } - - 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 - const nx = vbX * (1 - f) + v.x * f - const ny = vbY * (1 - f) + v.y * f - setZoomAnim({ from: { x: v.x, y: v.y, scale: v.scale }, to: { x: nx, y: ny, scale: ns }, start: performance.now() }) - } - - const zoomIn = () => { - const rect = svgRef.current.getBoundingClientRect() - zoomTo(1.3, rect.width / 2, rect.height / 2) - } - - const zoomOut = () => { - const rect = svgRef.current.getBoundingClientRect() - zoomTo(1 / 1.3, rect.width / 2, rect.height / 2) - } + // ───────────────────────────────────────────────────────────────────────── + // COMBAT ACTIONS + // ───────────────────────────────────────────────────────────────────────── const makeEnemy = (npc, index) => { const npcWeaponId = npc.weapon ?? 'fists' - const npcWeapon = weaponMap[npcWeaponId] - const spread = 20 return { - id: npc.id, - name: npc.name, + id: npc.id, name: npc.name, age: Math.floor(Math.random() * 30) + 18, location: npc.location ?? 'Unknown', - pos: { x: 120 + index * spread, y: 60 + index * spread }, - integrity: freshBody(), - injuries: freshInjuries(), - attack: npcWeapon?.damage ?? 30, - speed: npcWeapon?.speed ?? 10, + pos: { x: 120 + index * 20, y: 60 + index * 20 }, + integrity: freshBody(), injuries: freshInjuries(), + attack: weaponMap[npcWeaponId]?.damage ?? 30, + speed: weaponMap[npcWeaponId]?.speed ?? 10, weapon: npcWeaponId, inventory: npc.inventory ?? [], money: npc.money ?? 0, @@ -393,937 +903,550 @@ function App() { return npc ? [npc] : [] })() if (list.length === 0) return - const enemies = list.map((npc, i) => makeEnemy(npc, i)) - setCombat({ - playerPos: { x: 20, y: 75 }, - log: [`You engage ${enemies.map(e => e.name).join(' and ')} in combat!`], - enemies - }) - setCombatTime(0) + const enemies = list.map((npc, i) => makeEnemy(npc, i)) + const initCombat = { playerPos: { x: 20, y: 75 }, log: [`You engage ${enemies.map(e => e.name).join(' and ')} in combat!`], enemies } + setCombat(initCombat) + timerActiveRef.current = false; setTimerRunning(false) + setCombatTime(0); setDisplayTime(0) + setSelectedEnemy(0); setReplayActive(false) setExpandedWeapon('ROOT') - setDisplayTime(0) - setSelectedEnemy(0) - } - - const 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 - } - - const canAttack = (weaponId, skillName, enemyIndex) => { - if (!combat) return false - const enemy = combat.enemies[enemyIndex ?? selectedEnemy] - if (!enemy || enemy.integrity.head <= 0) return false - const dx = combat.playerPos.x - enemy.pos.x - const dy = combat.playerPos.y - enemy.pos.y - const range = getSkillRange(weaponId, skillName) - return Math.sqrt(dx * dx + dy * dy) <= range - } - - const startMove = (targetX, targetY) => { - if (!combat) return - if (moveAnim || attackAnim) return - const fromX = combat.playerPos.x - const fromY = combat.playerPos.y - const dx = targetX - fromX - const dy = targetY - fromY - const dist = Math.sqrt(dx * dx + dy * dy) - if (dist < 1) return - const speed = moveMode === 'walk' ? 1 : 3 - setMoveAnim({ fromX, fromY, toX: targetX, toY: targetY, speed }) - setMoveMode(null) - } - - const zoomMap = (step) => { - setMapView(v => { - const newSize = Math.max(30, Math.min(250, v.size + step * 10)) - const cx = v.x + v.size / 2 - const cy = v.y + v.size / 2 - const clampedX = newSize > 150 ? (150 - newSize) / 2 : Math.max(0, Math.min(150 - newSize, cx - newSize / 2)) - const clampedY = newSize > 150 ? (150 - newSize) / 2 : Math.max(0, Math.min(150 - newSize, cy - newSize / 2)) - return { x: clampedX, y: clampedY, size: newSize } - }) - } - - const playerAttack = (targetPart, weaponId, skillName, enemyIndex) => { - if (!combat) return - if (attackAnim) return - const ei = enemyIndex ?? selectedEnemy - const enemy = combat.enemies[ei] - if (!enemy) return - if (enemy.integrity.head <= 0) { - setCombat(c => ({ ...c, log: [...c.log, 'That enemy is already defeated!'] })) - return + recordingRef.current = { + initialCombat: JSON.parse(JSON.stringify(initCombat)), + frames: [{ time: 0, combat: JSON.parse(JSON.stringify(initCombat)), bodyParts: freshBody(), bodyInjuries: freshInjuries(), attackLerp: null, moveAnim: null, hitShake: null }], } - if (!canAttack(weaponId, skillName, ei)) { - setCombat(c => ({ ...c, log: [...c.log, 'You are too far from the enemy to attack!'] })) - return - } - if (attackAnim) return - const wid = weaponId || equippedWeapons.right - if (wid === 'left_arm' || wid === 'right_arm') { - const armDmg = 10 - const dmg = Math.max(1, armDmg + Math.floor(Math.random() * 5) - 2) - const severity = Math.round(dmg / 5) + 1 - setAttackAnim({ - targetPart, weaponId: wid, skillName: 'Punch', - dmg, severity, injType: 'bruise', injLabel: 'bruise', - enemyIndex: ei, - combatLog: combat.log, - bodyParts: bodyParts, - bodyInjuries: bodyInjuries, - startTs: null - }) - return - } - const base = weaponMap[wid] - const skill = (base.skills ?? []).find(s => s.name === skillName) ?? { name: 'Attack', mult: 1.0, damageType: 'blunt' } - const bothHands = equippedWeapons.left === wid && equippedWeapons.right === wid - const bothMult = bothHands ? 1.5 : 1.0 - - if (skill.damageType === 'shove') { - const actionTime = Math.max(1, (20 - base.speed) * 0.3 + 1) - const pushDist = 30 + Math.floor(Math.random() * 20) - const dx = enemy.pos.x - combat.playerPos.x - const dy = enemy.pos.y - combat.playerPos.y - const dist = Math.sqrt(dx * dx + dy * dy) || 1 - const nx = dx / dist, ny = dy / dist - let ex = enemy.pos.x, ey = enemy.pos.y - const steps = Math.ceil(pushDist / 3) - for (let i = 1; i <= steps; i++) { - const sx = enemy.pos.x + nx * (pushDist * i / steps) - const sy = enemy.pos.y + ny * (pushDist * i / steps) - if (MAP_BLOCKS.some(b => rectCollides(sx, sy, b))) break - ex = sx; ey = sy - } - const actualPush = Math.sqrt((ex - enemy.pos.x) ** 2 + (ey - enemy.pos.y) ** 2) - const clamped = { x: Math.max(5, Math.min(145, ex)), y: Math.max(5, Math.min(145, ey)) } - setCombatTime(t => t + actionTime) - setCombat(c => { - const enemies = [...c.enemies] - enemies[ei] = { ...enemies[ei], pos: clamped } - return { - ...c, - enemies, - log: [...c.log, `You shove the ${enemy.name} back ${Math.round(actualPush)} units!`] - } - }) - return - } - - const cond = weaponConditions[wid] ?? base.maxCondition - const conditionPenalty = cond < 30 ? 0.5 : cond < 60 ? 0.25 : 0 - const adjustedDmg = Math.round(base.damage * skill.mult * bothMult * (1 - conditionPenalty)) - const variance = Math.floor(Math.random() * (Math.max(2, adjustedDmg / 2) + 1)) - Math.floor(Math.max(2, adjustedDmg / 4)) - const dmg = Math.max(1, adjustedDmg + variance) - const severity = Math.round(dmg / 5) + 1 - const injType = injuryType(skill.damageType) - const injLabel = injType === 'laceration' ? 'laceration' : injType === 'stab' ? 'stab wound' : 'cut' - - setAttackAnim({ - targetPart, weaponId: wid, skillName, - dmg, severity, injType, injLabel, - enemyIndex: ei, - combatLog: combat.log, - bodyParts: bodyParts, - bodyInjuries: bodyInjuries, - startTs: null - }) } - useEffect(() => { - const anim = attackAnimRef.current - if (!anim) return - const startTs = performance.now() - const hitAt = 0.12 - let hit = false - const startPos = combatRef.current?.playerPos ?? { x: 20, y: 75 } - const targetEnemy = anim.enemyIndex !== undefined ? combatRef.current?.enemies[anim.enemyIndex] : null - const targetPos = targetEnemy?.pos ?? startPos - const frame = requestAnimationFrame(function tick() { - const elapsed = (performance.now() - startTs) / 1000 - setCombatTime(ct => ct + 1/60) - - let lerp - if (elapsed < hitAt) { - lerp = elapsed / hitAt - } else if (elapsed < 0.25) { - lerp = 1 - (elapsed - hitAt) / (0.25 - hitAt) - } else { - lerp = 0 - } - setAttackLerp({ from: startPos, to: targetPos, t: lerp }) - - if (elapsed >= hitAt && !hit) { - hit = true - setHitShake({ enemyIndex: anim.enemyIndex, startTs: performance.now() }) - const wid = anim.weaponId - const isArm = wid === 'left_arm' || wid === 'right_arm' - if (!isArm) { - const base = weaponMap[wid] - setWeaponConditions(prev => wid !== 'fists' ? { ...prev, [wid]: Math.max(0, (prev[wid] ?? base.maxCondition) - (1 + Math.floor(Math.random() * 2))) } : prev) - } - - const decayedPlayer = calcIntegrity(anim.bodyParts, anim.bodyInjuries, hitAt) - let finalPlayerParts = decayedPlayer - let finalPlayerInjuries = anim.bodyInjuries - let log = [...anim.combatLog] - - const cc = combatRef.current - if (anim.enemyIndex !== undefined && cc) { - const enemy = cc.enemies[anim.enemyIndex] - if (enemy) { - const decayedEnemy = calcIntegrity(enemy.integrity, enemy.injuries, hitAt) - const hitEnemy = { ...decayedEnemy, [anim.targetPart]: Math.max(0, decayedEnemy[anim.targetPart] - anim.dmg) } - const newEnemyInjuries = applyInjury(enemy.injuries, anim.targetPart, anim.injType, anim.severity) - - log.push(`You ${anim.skillName ? anim.skillName.toLowerCase() : 'attack'} the ${enemy.name}'s ${bodyPartLabels[anim.targetPart]} — ${anim.injLabel} (severity ${anim.severity}, integrity -${anim.dmg}).`) - - if (hitEnemy.head <= 0) { - log.push(`You crush the ${enemy.name}'s head!`) - log.push(`You defeated the ${enemy.name}!`) - } - - if (hitEnemy.head > 0) { - const eDmg = Math.max(1, enemy.attack + Math.floor(Math.random() * 6) - 2) - const eSeverity = 1 + Math.floor(Math.random() * 4) - const eType = ['cut', 'laceration', 'stab'][Math.floor(Math.random() * 3)] - const ePart = randomPart() - const eLabel = eType === 'laceration' ? 'laceration' : eType === 'stab' ? 'stab wound' : 'cut' - const eTime = Math.max(2, (20 - enemy.speed) * 0.3 + 1) - - const decayedPlayer2 = calcIntegrity(finalPlayerParts, finalPlayerInjuries, eTime) - finalPlayerParts = { ...decayedPlayer2, [ePart]: Math.max(0, decayedPlayer2[ePart] - eDmg) } - finalPlayerInjuries = applyInjury(finalPlayerInjuries, ePart, eType, eSeverity) - - log.push(`${enemy.name} lands a ${eLabel} on your ${bodyPartLabels[ePart]} (severity ${eSeverity}, integrity -${eDmg}).`) - - if (finalPlayerParts.head <= 0) { - log.push('You have been defeated...') - } - } - - setCombat(prev => { - const enemies = prev.enemies.map((e, i) => i === anim.enemyIndex ? { ...e, integrity: hitEnemy, injuries: newEnemyInjuries } : e) - return { ...prev, enemies, log } - }) - } - } - - setBodyParts(finalPlayerParts) - setBodyInjuries(finalPlayerInjuries) - } - if (elapsed >= 0.35) { - setAttackAnim(null) - setAttackLerp(null) - setHitShake(null) - } else { - requestAnimationFrame(tick) - } - }) - return () => cancelAnimationFrame(frame) - }, [attackAnim]) - - useEffect(() => { - if (!travelAnim) return - if (travelAnim.startTs === null) { - setTravelAnim(a => ({ ...a, startTs: performance.now() })) - return - } - const duration = 1000 - const frame = requestAnimationFrame(() => { - const elapsed = performance.now() - travelAnim.startTs - const progress = Math.min(1, elapsed / duration) - setTravelAnim(a => ({ ...a, progress })) - if (progress >= 1) { - setPlayerLoc(travelAnim.toId) - setTravelAnim(null) - } - }) - return () => cancelAnimationFrame(frame) - }, [travelAnim]) - - useEffect(() => { - const anim = moveAnimRef.current - if (!anim) return - const frame = requestAnimationFrame(() => { - const { fromX, fromY, toX, toY, speed } = anim - const dx = toX - fromX - const dy = toY - fromY - const dist = Math.sqrt(dx * dx + dy * dy) - if (dist <= speed) { - setCombat(c => ({ ...c, playerPos: { x: toX, y: toY } })) - setMoveAnim(null) - return - } - const step = 0.5 - const steps = Math.floor(speed / step) - let cx = fromX, cy = fromY - for (let i = 0; i < steps; i++) { - const nx = cx + (dx / dist) * step - const ny = cy + (dy / dist) * step - if (MAP_BLOCKS.some(b => rectCollides(nx, ny, b))) break - cx = nx; cy = ny - } - if (cx === fromX && cy === fromY) { - setMoveAnim(null) - } else { - setMoveAnim({ fromX: cx, fromY: cy, toX, toY, speed }) - setCombat(c => ({ ...c, playerPos: { x: cx, y: cy } })) - setCombatTime(t => t + 1/60) - } - }) - return () => cancelAnimationFrame(frame) - }, [moveAnim]) - - useEffect(() => { - if (!combat) { setDisplayTime(0); return } - if (displayTime >= combatTime) return - const frame = requestAnimationFrame(() => { - setDisplayTime(t => Math.min(t + 0.03, combatTime)) - }) - return () => cancelAnimationFrame(frame) - }, [displayTime, combatTime, combat]) - - useEffect(() => { - if (!hitShake) return - const duration = 300 - const frame = requestAnimationFrame(function tick() { - if (!hitShake) return - if (performance.now() - hitShake.startTs >= duration) { - setHitShake(null) - } else { - requestAnimationFrame(tick) - } - }) - return () => cancelAnimationFrame(frame) - }, [hitShake]) - - const lastDecayRef = useRef(-1) - useEffect(() => { - if (!combat) { lastDecayRef.current = -1; return } - const s = Math.floor(combatTime) - if (s > lastDecayRef.current && s >= 1) { - const delta = s - Math.max(0, lastDecayRef.current) - lastDecayRef.current = s - setBodyParts(p => calcIntegrity(p, bodyInjuries, delta)) - setCombat(c => ({ - ...c, - enemies: c.enemies.map(e => ({ ...e, integrity: calcIntegrity(e.integrity, e.injuries, delta) })) - })) - } - }, [combatTime, combat, bodyInjuries]) - - useEffect(() => { - if (combat) return - const interval = setInterval(() => { - setGameTime(gt => { - let h = gt.hour + 1 - let d = gt.day - if (h >= 24) { h = 0; d++ } - return { day: d, hour: h } - }) - }, 5000) - return () => clearInterval(interval) - }, [combat]) - - useEffect(() => { - if (combat) return - const interval = setInterval(() => { - const types = ['clear', 'clear', 'cloudy', 'cloudy', 'rain', 'storm'] - setWeather(types[Math.floor(Math.random() * types.length)]) - }, 30000) - return () => clearInterval(interval) - }, [combat]) - - useEffect(() => { - if (!skipAnim) return - const duration = (skipAnim.target - skipAnim.current) * 2000 - const frame = requestAnimationFrame(function tick() { - const elapsed = performance.now() - skipAnim.start - const t = Math.min(elapsed / duration, 1) - const current = skipAnim.current + (skipAnim.target - skipAnim.current) * t - if (t >= 1) { - setSkipAnim(null) - } else { - setSkipAnim(s => ({ ...s, current })) - requestAnimationFrame(tick) - } - }) - return () => cancelAnimationFrame(frame) - }, [skipAnim]) - - useEffect(() => { - if (!zoomAnim) return - const duration = 300 - const frame = requestAnimationFrame(function tick() { - const t = Math.min((performance.now() - zoomAnim.start) / duration, 1) - const ease = 1 - Math.pow(1 - t, 3) - const x = zoomAnim.from.x + (zoomAnim.to.x - zoomAnim.from.x) * ease - const y = zoomAnim.from.y + (zoomAnim.to.y - zoomAnim.from.y) * ease - const scale = zoomAnim.from.scale + (zoomAnim.to.scale - zoomAnim.from.scale) * ease - setView({ x, y, scale }) - if (t >= 1) { - setZoomAnim(null) - } else { - requestAnimationFrame(tick) - } - }) - return () => cancelAnimationFrame(frame) - }, [zoomAnim]) - - const handleItemClick = (itemId, i) => { - setShowInventory(true); setPanelTarget('player') - } - - const partMap = { larm: 'leftArm', rarm: 'rightArm', lleg: 'leftLeg', rleg: 'rightLeg' } - - const selectLimbTarget = (part) => { - const fullPart = partMap[part] || part - setSelectedTarget(fullPart) + const endFight = () => { + if (recordingRef.current) { pushSnapshot(); setFightRecord(recordingRef.current); recordingRef.current = null } + timerActiveRef.current = false; setTimerRunning(false) + setCombatTime(0); setDisplayTime(0) } + /** Enqueue a player attack; timer starts immediately. */ const executeAttack = () => { if (!limbSelect || !selectedTarget) return - playerAttack(selectedTarget, limbSelect, selectedSkill, attackEnemy) - setLimbSelect(null) - setSelectedSkill(null) - setSelectedTarget(null) - setAttackEnemy(null) + if (!combat) return + if (attackAnim || moveAnim || enemyMoveAnims) return + const ei = attackEnemy ?? selectedEnemy + const enemy = combat.enemies[ei] + if (!enemy || enemy.integrity.head <= 0) return + + const weaponId = limbSelect + if (!canReach(combat.playerPos, enemy.pos, weaponId, selectedSkill)) return + + const action = buildPlayerAttack({ + weaponId, skillName: selectedSkill, targetPart: selectedTarget, + enemyIndex: ei, equippedWeapons, weaponConditions, + bodyParts, bodyInjuries, combatLog: combat.log, + }) + + // Write to the ref immediately so the rAF loop sees it on the very next + // frame — before React has flushed the setActionQueue state update. + actionQueueRef.current = [...actionQueueRef.current, action] + setActionQueue(q => [...q, action]) + // Activate the timer via the ref (synchronous) AND the state (for the UI icon). + timerActiveRef.current = true; setTimerRunning(true) + setLimbSelect(null); setSelectedSkill(null); setSelectedTarget(null); setAttackEnemy(null) } const cancelAttack = () => { - setLimbSelect(null) - setSelectedSkill(null) - setSelectedTarget(null) - setAttackEnemy(null) + setLimbSelect(null); setSelectedSkill(null); setSelectedTarget(null); setAttackEnemy(null) } + /** Enqueue a player move; timer starts immediately. */ + const startMove = (targetX, targetY) => { + if (!combat || moveAnim || attackAnim) return + const fromX = combat.playerPos.x, fromY = combat.playerPos.y + const dist = Math.sqrt((targetX - fromX) ** 2 + (targetY - fromY) ** 2) + if (dist < 1) return + const speed = moveMode === 'walk' ? 1 : 3 + const action = { type: 'move', actor: 'player', fromX, fromY, toX: targetX, toY: targetY, speed } + // Same pattern: ref-first so the loop sees it synchronously. + actionQueueRef.current = [...actionQueueRef.current, action] + setActionQueue(q => [...q, action]) + timerActiveRef.current = true; setTimerRunning(true) + setMoveMode(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 === playerLoc) + const to = locations.find(l => l.id === targetId) + if (!from || !to) return + setTravelAnim({ from, to, fromId: playerLoc, toId: targetId, startTs: null, progress: 0 }) + } + + const buyItem = (item) => { + if (playerMoney < item.price) return + if (item.weaponId) { setBuyArmChoice({ item }) } + else { setPlayerMoney(m => m - item.price); setPlayerInventory(prev => [...prev, item.itemId]) } + } + + const confirmEquipArm = (arm) => { + const { item } = buyArmChoice + setPlayerMoney(m => m - item.price) + setWeaponConditions(prev => ({ ...prev, [item.weaponId]: weaponMap[item.weaponId].maxCondition })) + setEquippedWeapons(prev => arm === 'both' ? { left: item.weaponId, right: item.weaponId } : { ...prev, [arm]: item.weaponId }) + setBuyArmChoice(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 displayDay = skipTotalHours !== null ? Math.floor(skipTotalHours / 24) : gameTime.day + const displayHour = skipTotalHours !== null ? Math.floor(skipTotalHours % 24) : gameTime.hour + + const busy = !!(moveAnim || attackAnim || enemyMoveAnims) + + // ───────────────────────────────────────────────────────────────────────── + // RENDER + // ───────────────────────────────────────────────────────────────────────── return (
+ + {/* ═══════════════════════════════════════════════════════ COMBAT PANEL */} {combat && (
-
{(attackAnim || moveAnim) && ( +
+ {timerRunning && ( - )} Time: {Math.floor(displayTime / 60)}:{String(Math.floor(displayTime) % 60).padStart(2, '0')}.{String(Math.floor((displayTime % 1) * 100)).padStart(2, '0')}
+ )} + Time: {Math.floor(displayTime / 60)}:{String(Math.floor(displayTime) % 60).padStart(2,'0')}.{String(Math.floor((displayTime % 1) * 100)).padStart(2,'0')} +
-
-
- {playerHp > 0 && combat.enemies.some(e => e.integrity.head > 0) && ( -
-
-
setExpandedWeapon(expandedWeapon === 'ROOT' ? null : 'ROOT')}> - {expandedWeapon === 'ROOT' ? '▼' : '▶'} - { e.stopPropagation(); setShowInventory(true); setPanelTarget('player') }}>Player -
- {expandedWeapon === 'ROOT' && ( -
-
setWeaponsOpen(!weaponsOpen)}> - {weaponsOpen ? '▼' : '▶'} - Weapons -
- {weaponsOpen && ( -
- {(() => { - const lw = equippedWeapons.left - const rw = equippedWeapons.right - const leftFree = lw === 'fists' - const rightFree = rw === 'fists' - const entries = [] - if (leftFree) { - entries.push( -
setHoverInfo({ type: 'weapon', id: 'left_arm' })} - onMouseLeave={() => setHoverInfo(null)} - onClick={() => { setLimbSelect('left_arm'); setSelectedSkill(null); setSelectedTarget(null); setMoveMode(null) }}> - - Left Arm -
- ) - } else { - entries.push( -
setHoverInfo({ type: 'weapon', id: lw })} - onMouseLeave={() => setHoverInfo(null)} - onClick={() => { setLimbSelect(lw); setSelectedSkill(null); setSelectedTarget(null); setMoveMode(null) }}> - - Left: {weaponMap[lw].name} -
- ) - } - if (rightFree) { - entries.push( -
setHoverInfo({ type: 'weapon', id: 'right_arm' })} - onMouseLeave={() => setHoverInfo(null)} - onClick={() => { setLimbSelect('right_arm'); setSelectedSkill(null); setSelectedTarget(null); setMoveMode(null) }}> - - Right Arm -
- ) - } else if (lw !== rw) { - entries.push( -
setHoverInfo({ type: 'weapon', id: rw })} - onMouseLeave={() => setHoverInfo(null)} - onClick={() => { setLimbSelect(rw); setSelectedSkill(null); setSelectedTarget(null); setMoveMode(null) }}> - - Right: {weaponMap[rw].name} -
- ) - } - if (leftFree && rightFree) { - entries.push( -
setHoverInfo({ type: 'weapon', id: 'fists' })} - onMouseLeave={() => setHoverInfo(null)} - onClick={() => { setLimbSelect('fists'); setSelectedSkill(null); setSelectedTarget(null); setMoveMode(null) }}> - - Fists -
- ) - } - return entries - })()} -
- )} -
setItemsOpen(!itemsOpen)}> - {itemsOpen ? '▼' : '▶'} - Items -
- {itemsOpen && ( -
- {playerInventory.length === 0 ? ( -
No items
- ) : ( - playerInventory.map((itemId, i) => ( -
handleItemClick(itemId, i)}> - - {itemMap[itemId]?.name ?? itemId} -
- )) - )} -
- )} -
setMovementOpen(!movementOpen)}> - {movementOpen ? '▼' : '▶'} - Movement -
- {movementOpen && ( -
-
{ if (!moveAnim && !attackAnim) setMoveMode(m => m === "walk" ? null : "walk") }}> - - Walk -
-
{ if (!moveAnim && !attackAnim) setMoveMode(m => m === "run" ? null : "run") }}> - - Run -
- {moveMode && ( -
-
Click on the mini-map to move
-
- )} -
- )} + +
+ {/* ── left: action tree */} +
+ {playerHp > 0 && combat.enemies.some(e => e.integrity.head > 0) && ( +
+
+
setExpandedWeapon(expandedWeapon === 'ROOT' ? null : 'ROOT')}> + {expandedWeapon === 'ROOT' ? '▼' : '▶'} + { e.stopPropagation(); setShowInventory(true); setPanelTarget('player') }}>Player +
+ {expandedWeapon === 'ROOT' && ( +
+ {/* Weapons */} +
setWeaponsOpen(!weaponsOpen)}> + {weaponsOpen ? '▼' : '▶'} + Weapons
- )} -
+ {weaponsOpen && ( +
+ {(() => { + const lw = equippedWeapons.left, rw = equippedWeapons.right + const entries = [] + if (lw === 'fists') { + entries.push(
setHoverInfo({ type: 'weapon', id: 'left_arm' })} onMouseLeave={() => setHoverInfo(null)} + onClick={() => { setLimbSelect('left_arm'); setSelectedSkill(null); setSelectedTarget(null); setMoveMode(null) }}> + Left Arm
) + } else { + entries.push(
setHoverInfo({ type: 'weapon', id: lw })} onMouseLeave={() => setHoverInfo(null)} + onClick={() => { setLimbSelect(lw); setSelectedSkill(null); setSelectedTarget(null); setMoveMode(null) }}> + Left: {weaponMap[lw].name}
) + } + if (rw === 'fists') { + entries.push(
setHoverInfo({ type: 'weapon', id: 'right_arm' })} onMouseLeave={() => setHoverInfo(null)} + onClick={() => { setLimbSelect('right_arm'); setSelectedSkill(null); setSelectedTarget(null); setMoveMode(null) }}> + Right Arm
) + } else if (lw !== rw) { + entries.push(
setHoverInfo({ type: 'weapon', id: rw })} onMouseLeave={() => setHoverInfo(null)} + onClick={() => { setLimbSelect(rw); setSelectedSkill(null); setSelectedTarget(null); setMoveMode(null) }}> + Right: {weaponMap[rw].name}
) + } + if (lw === 'fists' && rw === 'fists') { + entries.push(
setHoverInfo({ type: 'weapon', id: 'fists' })} onMouseLeave={() => setHoverInfo(null)} + onClick={() => { setLimbSelect('fists'); setSelectedSkill(null); setSelectedTarget(null); setMoveMode(null) }}> + Fists
) + } + return entries + })()} +
+ )} + + {/* Items */} +
setItemsOpen(!itemsOpen)}> + {itemsOpen ? '▼' : '▶'} + Items +
+ {itemsOpen && ( +
+ {playerInventory.length === 0 + ?
No items
+ : playerInventory.map((itemId, i) => ( +
{ setShowInventory(true); setPanelTarget('player') }}> + {itemMap[itemId]?.name ?? itemId} +
+ )) + } +
+ )} + + {/* Movement */} +
setMovementOpen(!movementOpen)}> + {movementOpen ? '▼' : '▶'} + Movement +
+ {movementOpen && ( +
+
{ if (!busy) setMoveMode(m => m === 'walk' ? null : 'walk') }}> + Walk +
+
{ if (!busy) setMoveMode(m => m === 'run' ? null : 'run') }}> + Run +
+ {moveMode &&
Click on the mini-map to move
} +
+ )} +
+ )}
- )} -
-
-
-
- - - -
- { - 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 - const dy = ev.clientY - minimapPanStart.current.y - if (Math.abs(dx) > 2 || Math.abs(dy) > 2) minimapMovedRef.current = true - const r = svg.getBoundingClientRect() - const worldDx = (dx / r.width) * 160 / minimapView.scale - const worldDy = (dy / r.height) * 160 / minimapView.scale - setMinimapView(v => { - return { ...v, x: minimapPanStart.current.vx - worldDx, y: minimapPanStart.current.vy - worldDy } - }) - } - 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 delta = e.deltaY > 0 ? 0.9 : 1.1 - const rect = e.currentTarget.getBoundingClientRect() - const cx = e.clientX, cy = e.clientY - setMinimapView(v => { - const newScale = Math.max(0.5, Math.min(3, v.scale * delta)) - const mx = (cx - rect.left) / rect.width - const my = (cy - rect.top) / rect.height - const newW = 160 / newScale - const newX = v.x + mx * (160 / v.scale - 160 / newScale) - const newY = v.y + my * (160 / v.scale - 160 / newScale) - return { x: newX, y: newY, scale: newScale } - }) - }} - onClick={(e) => { - if (minimapMovedRef.current) { minimapMovedRef.current = false; return } - if (limbSelect) return - if (!moveMode) return - const rect = e.currentTarget.getBoundingClientRect() - const x = ((e.clientX - rect.left) / rect.width) * 160 / minimapView.scale + minimapView.x - const y = ((e.clientY - rect.top) / rect.height) * 160 / minimapView.scale + minimapView.y - startMove(x, y) - }}> - - - {MAP_BLOCKS.map((block, i) => ( - - ))} - {(combat?.enemies || []).map((enemy, i) => ( - { e.stopPropagation(); setHoverInfo({ type: 'enemy', name: enemy.name, attack: enemy.attack, speed: enemy.speed }) }} - onMouseMove={(e) => setMousePos({ x: e.clientX, y: e.clientY })} - onMouseLeave={() => setHoverInfo(null)} - onClick={(e) => { e.stopPropagation(); setTargetEnemy(i); setShowInventory(true); setPanelTarget(i) }} /> - ))} - {combat && selectedSkill && limbSelect && } - {combat && } - -
-
-
Combat Log
-
- {(combat?.log || []).map((entry, i) => ( -
{entry}
+ )} +
+ + {/* ── center: minimap + log */} +
+
+
+ + + +
+ { + if (limbSelect) return + minimapPanRef.current = true; minimapMovedRef.current = false + minimapPanStart.current = { x: e.clientX, y: e.clientY, vx: minimapView.x, vy: minimapView.y } + const svg = e.currentTarget + const onMove = ev => { + if (!minimapPanRef.current) return + const dx = ev.clientX - minimapPanStart.current.x, dy = ev.clientY - minimapPanStart.current.y + if (Math.abs(dx) > 2 || Math.abs(dy) > 2) minimapMovedRef.current = true + const r = svg.getBoundingClientRect() + setMinimapView(v => ({ ...v, x: minimapPanStart.current.vx - (dx / r.width) * 160 / v.scale, y: minimapPanStart.current.vy - (dy / r.height) * 160 / v.scale })) + } + const onUp = () => { minimapPanRef.current = false; window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp) } + window.addEventListener('mousemove', onMove); window.addEventListener('mouseup', onUp) + }} + onWheel={e => { + if (limbSelect) return; e.preventDefault() + const d = e.deltaY > 0 ? 0.9 : 1.1 + const r = e.currentTarget.getBoundingClientRect() + setMinimapView(v => { + const ns = Math.max(0.5, Math.min(3, v.scale * d)) + return { x: v.x + (e.clientX - r.left) / r.width * (160/v.scale - 160/ns), y: v.y + (e.clientY - r.top) / r.height * (160/v.scale - 160/ns), scale: ns } + }) + }} + onClick={e => { + if (minimapMovedRef.current) { minimapMovedRef.current = false; return } + if (limbSelect || !moveMode) return + const r = e.currentTarget.getBoundingClientRect() + startMove(((e.clientX - r.left) / r.width) * 160 / minimapView.scale + minimapView.x, + ((e.clientY - r.top) / r.height) * 160 / minimapView.scale + minimapView.y) + }}> + + + {MAP_BLOCKS.map((block, i) => )} + {(combat?.enemies || []).map((enemy, i) => ( + { e.stopPropagation(); setHoverInfo({ type: 'enemy', name: enemy.name, attack: enemy.attack, speed: enemy.speed }) }} + onMouseMove={e => setMousePos({ x: e.clientX, y: e.clientY })} + onMouseLeave={() => setHoverInfo(null)} + onClick={e => { e.stopPropagation(); setShowInventory(true); setPanelTarget(i) }} /> ))} -
-
+ {combat && selectedSkill && limbSelect && ( + + )} + {combat && ( + + )} + +
-
-
- {(combat?.enemies || []).map((enemy, i) => ( -
{ setSelectedEnemy(i); setTargetEnemy(i); setShowInventory(true); setPanelTarget(i) }}> - {enemy.name} -
- ))} +
+
Combat Log
+
+ {(combat?.log || []).map((entry, i) =>
{entry}
)}
- {showSettings && ( -
-
- Settings - -
-
-
-
Display
-
- Zoom -
- - {Math.round(view.scale * 100)}% - -
+ {/* ── right: enemy list */} +
+
+ {(combat?.enemies || []).map((enemy, i) => ( +
{ setSelectedEnemy(i); setShowInventory(true); setPanelTarget(i) }}> + {enemy.name}
-
-
-
Game
-
- Day - {gameTime.day} -
-
- Time - {String(gameTime.hour).padStart(2, "0")}:00 -
-
- Weather - {weatherLabels[weather]} -
-
-
-
Player
-
- Location - {locMap[playerLoc]?.name ?? playerLoc} -
-
- Money - {playerMoney}g -
-
+ ))}
- )} +
+ + {/* ── attack modal */} {limbSelect && ( -
cancelAttack()} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 8 }}> +
e.stopPropagation()}> {(() => { - const weapId = limbSelect - const weapon = weaponMap[weapId] - const weaponName = weapon?.name ?? weapId - const skills = weapon?.skills ?? [] + const weapon = weaponMap[limbSelect] + const weaponName = weapon?.name ?? limbSelect + const skills = weapon?.skills ?? [] return (
{weaponName}
+ {/* skill list */} {skills.length > 0 && (
Select Skill
{skills.map((skill, i) => ( - ))}
)} + {/* enemy list */} {selectedSkill && (
Select Enemy
{(combat?.enemies || []).map((enemy, i) => { - const inRange = canAttack(limbSelect, selectedSkill, i) + const inRange = canReach(combat.playerPos, enemy.pos, limbSelect, selectedSkill) return ( - ) })}
)} + {/* limb selector */} {attackEnemy !== null && (
Limb
-
- {[ - { key: 'torso', label: 'Torso' }, - { key: 'head', label: 'Head' }, - { key: 'larm', label: 'Left Arm' }, - { key: 'rarm', label: 'Right Arm' }, - { key: 'lleg', label: 'Left Leg' }, - { key: 'rleg', label: 'Right Leg' }, - ].map(({ key, label }) => { - const fullPart = partMap[key] || key - const isSelected = selectedTarget === fullPart - return ( - - ) - })} +
+ {[{ 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}) => ( + + ))}
)}
- {skills.length > 0 && ( -
- {(() => { - if (!selectedSkill && skills.length > 0) { - const sName = hoveredSkill - const s = skills.find(x => x.name === sName) - if (!s) return null - const descs = { blunt: 'Blunt force trauma', slash: 'Slashing cut', pierce: 'Piercing strike', shove: 'Knocks the target back' } - return ( - <> -
-
{s.name}
-
{descs[s.damageType] || s.damageType} attack
-
-
-
Damage: {Math.round((weapon?.damage ?? 0) * s.mult)}
-
Multiplier: {s.mult}x
-
Type: {s.damageType}
-
Range: {s.range}
-
- - ) - } - if (selectedSkill && attackEnemy === null) { - const ei = hoveredEnemy - const e = ei !== null && combat?.enemies[ei] - if (!e) return null - return ( - <> -
-
{e.name}
-
-
-
HP: {Object.values(e.integrity).reduce((a, b) => a + b, 0)}
-
Head: {e.integrity.head}
-
Torso: {e.integrity.torso}
-
L.Arm: {e.integrity.larm}
-
R.Arm: {e.integrity.rarm}
-
L.Leg: {e.integrity.lleg}
-
R.Leg: {e.integrity.rleg}
-
- - ) - } - if (selectedSkill && attackEnemy !== null) { - const e = combat?.enemies[attackEnemy] - const limbKeyMap = { torso: 'torso', head: 'head', leftArm: 'larm', rightArm: 'rarm', leftLeg: 'lleg', rightLeg: 'rleg' } - const limbKey = selectedTarget ? (limbKeyMap[selectedTarget] || selectedTarget) : null - const hp = limbKey && e ? e.integrity[limbKey] : null - return ( - <> -
-
{e?.name || ''}
-
Target limb: {selectedTarget || 'None'}
-
-
-
Limb HP: {hp !== null ? hp : '-'}
-
- - ) - } - return null - })()} -
- )} + + {/* side info panel */} + {skills.length > 0 && ( +
+ {(() => { + if (!selectedSkill) { + const s = skills.find(x => x.name === hoveredSkill) + if (!s) return null + const descs = { blunt: 'Blunt force trauma', slash: 'Slashing cut', pierce: 'Piercing strike', shove: 'Knocks the target back' } + return (<> +
+
{s.name}
+
{descs[s.damageType] || s.damageType}
+
+
+
Damage: {Math.round((weapon?.damage ?? 0) * s.mult)}
+
Multiplier: {s.mult}x
+
Type: {s.damageType}
+
Range: {s.range}
+
+ ) + } + if (attackEnemy === null) { + const e = hoveredEnemy !== null ? combat?.enemies[hoveredEnemy] : null + if (!e) return null + return (<> +
+
{e.name}
+
+
+
HP: {Object.values(e.integrity).reduce((a,b)=>a+b,0)}
+ {Object.entries(e.integrity).map(([p,v]) =>
{bodyPartLabels[p]}: {v}
)} +
+ ) + } + const e = combat?.enemies[attackEnemy] + const limbKey = selectedTarget + const hp = limbKey && e ? e.integrity[limbKey] : null + return (<> +
+
{e?.name || ''}
+
Target: {selectedTarget ? bodyPartLabels[selectedTarget] : 'None'}
+
+
+
Limb HP: {hp !== null ? hp : '-'}
+
+ ) + })()}
- )})()} + )} +
+ ) + })()}
- - + + +
+
+ )} + + {/* ── combat over */} + {combat && (playerHp <= 0 || !combat.enemies.some(e => e.integrity.head > 0)) && ( +
+
Combat Over
+
+ {fightRecord && } +
)}
)} + + {/* ═══════════════════════════════════════════════════════ WORLD MAP */} {!combat && ( <> setMousePos({ x: e.clientX, y: e.clientY })}> + onMouseMove={e => setMousePos({ x: e.clientX, y: e.clientY })}> - - + {connections.map((c, i) => { - const from = locMap[c.from] - const to = locMap[c.to] - return ( - - ) + const from = locMap[c.from], to = locMap[c.to] + return })} - {locations.map(l => ( setSelected(selected === l.id ? null : l.id)} onMouseEnter={() => setHovered(l.id)} - onMouseMove={(e) => setMousePos({ x: e.clientX, y: e.clientY })} + onMouseMove={e => setMousePos({ x: e.clientX, y: e.clientY })} onMouseLeave={() => setHovered(null)} /> - {l.id} + {l.id} {shops.some(s => s.locationId === l.id) && ( - S + S )} {!travelAnim && playerLoc === l.id && ( - <> - - P - + <> + P )} ))} - {travelAnim && ( - - )} + {travelAnim && ( + + )}
-
-
Day {displayDay} {String(displayHour).padStart(2, '0')}:00
+
Day {displayDay} {String(displayHour).padStart(2,'0')}:00
{weatherLabels[weather]}
@@ -1337,105 +1460,46 @@ function App() {
Pass Time
- setPassTimeHours(Number(e.target.value))} - className="pass-time-slider" /> + setPassTimeHours(Number(e.target.value))} className="pass-time-slider" /> {passTimeHours}h
)} +
{hovered && ( -
+
{locMap[hovered].name}
{locMap[hovered].description}
)} - {hoverInfo?.type === 'item' && itemMap[hoverInfo.id] && ( -
-
{itemMap[hoverInfo.id].name}
-
{itemMap[hoverInfo.id].description}
-
- )} - - {hoverInfo?.type === 'weapon' && hoverInfo.id && (weaponMap[hoverInfo.id] || hoverInfo.id === 'left_arm' || hoverInfo.id === 'right_arm') && ( -
- {hoverInfo.id === 'left_arm' || hoverInfo.id === 'right_arm' ? ( - <> -
{hoverInfo.id === 'left_arm' ? 'Left Arm' : 'Right Arm'}
-
A weak unarmed punch (DMG: 10)
- - ) : ( - <> -
{weaponMap[hoverInfo.id].name}
-
DMG: {weaponMap[hoverInfo.id].damage} SPD: {weaponMap[hoverInfo.id].speed}
-
- Condition: {weaponConditions[hoverInfo.id] ?? weaponMap[hoverInfo.id].maxCondition}/{weaponMap[hoverInfo.id].maxCondition} -
-
- Skills: {weaponMap[hoverInfo.id].skills.map(s => `${s.name} (${s.damageType})`).join(', ')} -
- {weaponMap[hoverInfo.id].passives.length > 0 && ( -
- Passives: {weaponMap[hoverInfo.id].passives.join(', ')} -
- )} - - )} -
- )} - {selected && (
{locMap[selected].name}
{locMap[selected].description}
- {playerLoc !== selected && connectedTo(playerLoc).includes(selected) && (
Travel here
- +
)} - {shopAtLoc && (
{shopAtLoc.name}
@@ -1443,36 +1507,28 @@ function App() {
{item.name} {item.price}g - +
))}
)} -
Characters here
- {liveCharacters.filter(c => c.location === selected).length === 0 ? ( -
None
- ) : ( - liveCharacters.filter(c => c.location === selected).map(c => ( + {liveCharacters.filter(c => c.location === selected).length === 0 + ?
None
+ : liveCharacters.filter(c => c.location === selected).map(c => (
{c.name}
- {c.money !== undefined && ( - {c.money}g - )} - {c.inventory && c.inventory.length > 0 && ( - {c.inventory.map(id => itemMap[id]?.name ?? id).join(', ')} - )} + {c.money !== undefined && {c.money}g} + {c.inventory?.length > 0 && {c.inventory.map(id => itemMap[id]?.name ?? id).join(', ')}}
)) - )} + }
{playerLoc === selected && ( - )} @@ -1491,9 +1547,7 @@ function App() {
Log
- {locationLog.map((line, i) => ( -
{line}
- ))} + {locationLog.map((line, i) =>
{line}
)}
@@ -1504,16 +1558,14 @@ function App() {
Connections
{connections.filter(c => c.from === showLocationModal || c.to === showLocationModal).map(c => { - const otherId = c.from === showLocationModal ? c.to : c.from - return
{locMap[otherId]?.name}
+ const id = c.from === showLocationModal ? c.to : c.from + return
{locMap[id]?.name}
})}
{shops.filter(s => s.locationId === showLocationModal).length > 0 && (
Shops
- {shops.filter(s => s.locationId === showLocationModal).map(s => ( -
{s.name}
- ))} + {shops.filter(s => s.locationId === showLocationModal).map(s =>
{s.name}
)}
)}
@@ -1521,19 +1573,15 @@ function App() {
Characters
- {liveCharacters.filter(c => c.id !== 'player' && c.location === showLocationModal).length === 0 ? ( -
No one here.
- ) : ( - liveCharacters.filter(c => c.id !== 'player' && c.location === showLocationModal).map(c => ( + {liveCharacters.filter(c => c.id !== 'player' && c.location === showLocationModal).length === 0 + ?
No one here.
+ : liveCharacters.filter(c => c.id !== 'player' && c.location === showLocationModal).map(c => (
{c.name}
- +
)) - )} + }
@@ -1543,10 +1591,7 @@ function App() {
e.stopPropagation()}>
- +
@@ -1557,28 +1602,27 @@ function App() { )} + {/* ═══════════════════════════════════════════════════════ INVENTORY PANEL */} {showInventory && ( -
{ setShowInventory(false); setPanelTarget('player'); setHoverInfo(null); setContextMenu(null) }} +
{ setShowInventory(false); setPanelTarget('player'); setHoverInfo(null); setContextMenu(null) }} onContextMenu={e => { e.preventDefault(); setContextMenu(null) }} - onMouseMove={(e) => setMousePos({ x: e.clientX, y: e.clientY })}> + onMouseMove={e => setMousePos({ x: e.clientX, y: e.clientY })}>
e.stopPropagation()}>
- {panelTarget === 'player' ? ( - - ) : combat?.enemies[panelTarget] ? ( - - ) : null} + {panelTarget === 'player' + ? + : combat?.enemies[panelTarget] + ? + : null}
- - - + + +
{panelTarget === 'player' ? ( @@ -1587,73 +1631,44 @@ function App() {
Body Parts
{Object.keys(MAX_BODY).map(part => { - const hp = bodyParts[part] - const max = MAX_BODY[part] + const hp = bodyParts[part], max = MAX_BODY[part] const injuries = bodyInjuries[part] ?? [] return (
{bodyPartLabels[part]}
-
-
-
+
{hp}/{max}
- {injuries.length > 0 && ( -
- {injuries.map((inj, i) => ( - {inj.type} ({inj.severity}) - ))} -
- )} + {injuries.length > 0 &&
{injuries.map((inj,i) => {inj.type} ({inj.severity}))}
}
) })} -
- Integrity - {playerHp}/{MAX_HP} -
+
Integrity{playerHp}/{MAX_HP}
)} - {invTab === 'inventory' && (
Weapons
{(() => { const lw = equippedWeapons.left, rw = equippedWeapons.right - if (lw === rw) { - return ( -
setHoverInfo({ type: 'weapon', id: lw })} - onMouseLeave={() => setHoverInfo(null)} - onContextMenu={e => { e.preventDefault(); setContextMenu({ x: e.clientX, y: e.clientY, weaponId: lw, side: 'both' }) }}> - {lw === 'fists' ? 'Fists' : `Both: ${weaponMap[lw].name}`} - {lw !== 'fists' && ( - <>DMG: {weaponMap[lw].damage} - COND: {weaponConditions[lw] ?? weaponMap[lw].maxCondition}/{weaponMap[lw].maxCondition} - {weaponMap[lw].passives.length > 0 && ( - {weaponMap[lw].passives.join(', ')} - )} - )} -
- ) - } - return ['left', 'right'].map(side => { + if (lw === rw) return ( +
setHoverInfo({ type:'weapon', id:lw })} onMouseLeave={() => setHoverInfo(null)}> + {lw === 'fists' ? 'Fists' : `Both: ${weaponMap[lw].name}`} + {lw !== 'fists' && <>DMG: {weaponMap[lw].damage}COND: {weaponConditions[lw] ?? weaponMap[lw].maxCondition}/{weaponMap[lw].maxCondition}{weaponMap[lw].passives.length > 0 && {weaponMap[lw].passives.join(', ')}}} +
+ ) + return ['left','right'].map(side => { const wid = equippedWeapons[side] if (wid === 'fists') return null return ( -
setHoverInfo({ type: 'weapon', id: wid })} - onMouseLeave={() => setHoverInfo(null)} - onContextMenu={e => { e.preventDefault(); setContextMenu({ x: e.clientX, y: e.clientY, weaponId: wid, side }) }}> +
setHoverInfo({ type:'weapon', id:wid })} onMouseLeave={() => setHoverInfo(null)}> {side === 'left' ? 'Left' : 'Right'}: {weaponMap[wid].name} DMG: {weaponMap[wid].damage} COND: {weaponConditions[wid] ?? weaponMap[wid].maxCondition}/{weaponMap[wid].maxCondition} - {weaponMap[wid].passives.length > 0 && ( - {weaponMap[wid].passives.join(', ')} - )} + {weaponMap[wid].passives.length > 0 && {weaponMap[wid].passives.join(', ')}}
) }) @@ -1661,24 +1676,18 @@ function App() {
Inventory ({playerInventory.length})
- {playerInventory.length === 0 ? ( -
Empty
- ) : ( - <> - {playerInventory.map((itemId, i) => ( -
setHoverInfo({ type: 'item', id: itemId })} - onMouseLeave={() => setHoverInfo(null)}> - {itemMap[itemId]?.name ?? itemId} -
- ))} - - )} + {playerInventory.length === 0 + ?
Empty
+ : playerInventory.map((itemId, i) => ( +
setHoverInfo({ type:'item', id:itemId })} onMouseLeave={() => setHoverInfo(null)}> + {itemMap[itemId]?.name ?? itemId} +
+ )) + }
Weight: {currentWeight}/{MAX_WEIGHT}
{playerMoney}g
)} - {invTab === 'stats' && (
Character
@@ -1688,21 +1697,12 @@ function App() {
Equipped{equippedWeapons.left === equippedWeapons.right ? (equippedWeapons.left === 'fists' ? 'Fists' : `Both: ${weaponMap[equippedWeapons.left].name}`) : [equippedWeapons.left !== 'fists' ? `L:${weaponMap[equippedWeapons.left].name}` : '', equippedWeapons.right !== 'fists' ? `R:${weaponMap[equippedWeapons.right].name}` : ''].filter(Boolean).join(' + ')}
{(() => { - const shown = new Set() - const rows = [] - const addWeapon = (wid) => { - if (shown.has(wid) || wid === 'fists') return - shown.add(wid) - rows.push( - -
Skills{weaponMap[wid].skills.map(s => s.name).join(', ')}
-
Weapon SPD{weaponMap[wid].speed}
-
- ) + const shown = new Set(), rows = [] + const addWeapon = wid => { + if (shown.has(wid) || wid === 'fists') return; shown.add(wid) + rows.push(
Skills{weaponMap[wid].skills.map(s=>s.name).join(', ')}
Weapon SPD{weaponMap[wid].speed}
) } - addWeapon(equippedWeapons.left) - addWeapon(equippedWeapons.right) - return rows + addWeapon(equippedWeapons.left); addWeapon(equippedWeapons.right); return rows })()}
Integrity{playerHp}/{MAX_HP}
@@ -1718,86 +1718,55 @@ function App() {
Body Parts
{Object.keys(MAX_BODY).map(part => { - const hp = combat.enemies[panelTarget].integrity[part] - const max = MAX_BODY[part] + const hp = combat.enemies[panelTarget].integrity[part], max = MAX_BODY[part] const injuries = combat.enemies[panelTarget].injuries[part] ?? [] return (
{bodyPartLabels[part]}
-
-
-
+
{hp}/{max}
- {injuries.length > 0 && ( -
- {injuries.map((inj, i) => ( - {inj.type} ({inj.severity}) - ))} -
- )} + {injuries.length > 0 &&
{injuries.map((inj,i) => {inj.type} ({inj.severity}))}
}
) })} -
- Integrity - {Object.values(combat.enemies[panelTarget].integrity).reduce((a, b) => a + b, 0)}/{MAX_HP} -
+
Integrity{Object.values(combat.enemies[panelTarget].integrity).reduce((a,b)=>a+b,0)}/{MAX_HP}
)} - {invTab === 'inventory' && (
-
Inventory ({(combat.enemies[panelTarget].inventory || []).length})
- {(combat.enemies[panelTarget].inventory || []).length === 0 ? ( -
Empty
- ) : ( - <> - {combat.enemies[panelTarget].inventory.map((itemId, i) => ( -
setHoverInfo({ type: 'item', id: itemId })} - onMouseLeave={() => setHoverInfo(null)}> - {itemMap[itemId]?.name ?? itemId} -
- ))} - - )} +
Inventory ({(combat.enemies[panelTarget].inventory||[]).length})
+ {(combat.enemies[panelTarget].inventory||[]).length === 0 + ?
Empty
+ : combat.enemies[panelTarget].inventory.map((itemId, i) => ( +
setHoverInfo({ type:'item', id:itemId })} onMouseLeave={() => setHoverInfo(null)}> + {itemMap[itemId]?.name ?? itemId} +
+ )) + }
{combat.enemies[panelTarget].money || 0}g
)} - {invTab === 'stats' && (
{(() => { - const e = combat.enemies[panelTarget] - const wid = e.weapon ?? 'fists' - const wep = weaponMap[wid] - return ( - <> -
{e.name}
-
Name{e.name}
-
Age{e.age ?? '??'}
-
Location{e.location ?? 'Unknown'}
-
-
Weapon{wep?.name ?? wid}
- {wep && wid !== 'fists' && ( - <>
DMG{e.attack}
-
SPD{e.speed}
- )} - {wep && wep.skills.length > 0 && ( -
Skills{wep.skills.map(s => s.name).join(', ')}
- )} - {wid === 'fists' && ( -
Attack{e.attack}
- )} -
-
Integrity{Object.values(e.integrity).reduce((a, b) => a + b, 0)}/{MAX_HP}
-
Items{(e.inventory || []).length}
-
Money{e.money || 0}g
- - ) + const e = combat.enemies[panelTarget], wid = e.weapon ?? 'fists', wep = weaponMap[wid] + return (<> +
{e.name}
+
Name{e.name}
+
Age{e.age ?? '??'}
+
Location{e.location ?? 'Unknown'}
+
+
Weapon{wep?.name ?? wid}
+ {wep && wid !== 'fists' && <>
DMG{e.attack}
SPD{e.speed}
} + {wep?.skills?.length > 0 &&
Skills{wep.skills.map(s=>s.name).join(', ')}
} +
+
Integrity{Object.values(e.integrity).reduce((a,b)=>a+b,0)}/{MAX_HP}
+
Items{(e.inventory||[]).length}
+
Money{e.money||0}g
+ ) })()}
)} @@ -1809,54 +1778,31 @@ function App() {
)} + {/* ═══════════════════════════════════════════════════════ SETTINGS */} {showSettings && (
-
- Settings - -
+
Settings
Display
-
- Zoom -
- - {Math.round(view.scale * 100)}% - -
-
+
Zoom
{Math.round(view.scale*100)}%
Game
-
- Day - {gameTime.day} -
-
- Time - {String(gameTime.hour).padStart(2, '0')}:00 -
-
- Weather - {weatherLabels[weather]} -
+
Day{gameTime.day}
+
Time{String(gameTime.hour).padStart(2,'0')}:00
+
Weather{weatherLabels[weather]}
Player
-
- Location - {locMap[playerLoc]?.name ?? playerLoc} -
-
- Money - {playerMoney}g -
+
Location{locMap[playerLoc]?.name ?? playerLoc}
+
Money{playerMoney}g
)} + {/* ═══════════════════════════════════════════════════════ WORLD INFO */} {showWorldInfo && (
setShowWorldInfo(false)}>
e.stopPropagation()}> @@ -1864,7 +1810,7 @@ function App() {
World
Day{gameTime.day}
-
Time{String(gameTime.hour).padStart(2, '0')}:00
+
Time{String(gameTime.hour).padStart(2,'0')}:00
Weather{weatherLabels[weather]}
Location{locMap[playerLoc]?.name ?? playerLoc}
@@ -1872,15 +1818,38 @@ function App() {
)} + {/* ═══════════════════════════════════════════════════════ TOOLTIPS */} + {hovered && ( +
+
{locMap[hovered].name}
+
{locMap[hovered].description}
+
+ )} + {hoverInfo?.type === 'item' && itemMap[hoverInfo.id] && ( +
+
{itemMap[hoverInfo.id].name}
+
{itemMap[hoverInfo.id].description}
+
+ )} + {hoverInfo?.type === 'weapon' && weaponMap[hoverInfo.id] && ( +
+ {hoverInfo.id === 'left_arm' || hoverInfo.id === 'right_arm' + ? <>
{weaponMap[hoverInfo.id].name}
Unarmed strike (DMG: 10)
+ : <>
{weaponMap[hoverInfo.id].name}
DMG: {weaponMap[hoverInfo.id].damage} SPD: {weaponMap[hoverInfo.id].speed}
+
Condition: {weaponConditions[hoverInfo.id] ?? weaponMap[hoverInfo.id].maxCondition}/{weaponMap[hoverInfo.id].maxCondition}
+
Skills: {weaponMap[hoverInfo.id].skills.map(s=>`${s.name} (${s.damageType})`).join(', ')}
+ {weaponMap[hoverInfo.id].passives.length > 0 &&
Passives: {weaponMap[hoverInfo.id].passives.join(', ')}
} + } +
+ )} {hoverInfo?.type === 'enemy' && ( -
+
{hoverInfo.name}
{hoverInfo.defeated ? 'Defeated' : 'Hostile'}
)} + + {/* ═══════════════════════════════════════════════════════ BUY ARM CHOICE */} {buyArmChoice && (
setBuyArmChoice(null)}>
e.stopPropagation()}> @@ -1896,6 +1865,4 @@ function App() { )}
) -} - -export default App +} \ No newline at end of file