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
}
+
+ )}
+
+ )}
- )}
-
-
-
-
-
-
-
-
-
-
-
Combat Log
-
- {(combat?.log || []).map((entry, i) => (
-
{entry}
+ )}
+
+
+ {/* ── center: minimap + log */}
+
+
+
+
+
+
+
+
-
+ {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 (
-
setAttackEnemy(i)}
- style={{ opacity: inRange ? 1 : 0.4, textAlign: 'center' }}
- disabled={!inRange}
- onMouseEnter={() => setHoveredEnemy(i)}
- onMouseLeave={() => setHoveredEnemy(null)}>
+ setAttackEnemy(i)} style={{ opacity: inRange ? 1 : 0.4 }} disabled={!inRange}
+ onMouseEnter={() => setHoveredEnemy(i)} onMouseLeave={() => setHoveredEnemy(null)}>
{enemy.name}
- HP: {Object.values(enemy.integrity).reduce((a, b) => a + b, 0)}
+ HP: {Object.values(enemy.integrity).reduce((a,b)=>a+b,0)}
)
})}
)}
+ {/* 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 (
-
selectLimbTarget(key)}
- style={{ whiteSpace: 'nowrap', padding: '6px 16px' }}>
- {label}
-
- )
- })}
+
+ {[{ 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}) => (
+ selectLimbTarget(key)} style={{ whiteSpace: 'nowrap', padding: '6px 16px' }}>
+ {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 (
- <>
-
-
-
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 (<>
+
+
+
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 : '-'}
+
+ >)
+ })()}
- )})()}
+ )}
+
+ )
+ })()}
- executeAttack()}
- disabled={!selectedSkill || attackEnemy === null || !selectedTarget || !!moveAnim || !!attackAnim || playerHp <= 0}
- style={{ opacity: !selectedSkill || attackEnemy === null || !selectedTarget || !!moveAnim || !!attackAnim || playerHp <= 0 ? 0.4 : 1 }}>Confirm
- cancelAttack()} style={{ width: 'auto', padding: '10px 24px' }}>Cancel
+ Confirm
+ Cancel
+
+
+ )}
+
+ {/* ── combat over */}
+ {combat && (playerHp <= 0 || !combat.enemies.some(e => e.integrity.head > 0)) && (
+
+
Combat Over
+
+ {fightRecord && Replay}
+ { setCombat(null); setReplayActive(false) }}>Exit
)}
)}
+
+ {/* ═══════════════════════════════════════════════════════ WORLD MAP */}
{!combat && (
<>
{ setShowInventory(true); setPanelTarget('player') }} title="Character">
-
+
-
{ setShowWorldInfo(s => !s) }} title="World">
-
+ setShowWorldInfo(s => !s)} title="World">
+
{ setPassTimeHours(1); setPassTimeOpen(s => !s) }} title="Pass Time">
-
+
setShowSettings(s => !s)} title="Settings">
-
+
-
Day {displayDay} {String(displayHour).padStart(2, '0')}:00
+
Day {displayDay} {String(displayHour).padStart(2,'0')}:00
{weatherLabels[weather]}
@@ -1337,105 +1460,46 @@ function App() {
)}
+
{
- const npcs = liveCharacters.filter(c => c.id !== 'player')
- const shuffled = [...npcs].sort(() => Math.random() - 0.5)
- const picked = shuffled.slice(0, 2)
- setSelected(null)
- startFight(picked)
+ const shuffled = [...liveCharacters.filter(c => c.id !== 'player')].sort(() => Math.random() - 0.5)
+ setSelected(null); startFight(shuffled.slice(0, 2))
}}>Fight 2 random enemies
{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
-
travel(selected)}>
- Go to {locMap[selected].name}
-
+
travel(selected)}>Go to {locMap[selected].name}
)}
-
{shopAtLoc && (
{shopAtLoc.name}
@@ -1443,36 +1507,28 @@ function App() {
{item.name}
{item.price}g
- buyItem(item)}>Buy
+ buyItem(item)}>Buy
))}
)}
-
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 && (
-
{ setShowLocationModal(selected); setSelectedNpc(null); setLocationLog([`Entered ${locMap[selected].name}.`]) }}>
+ { setShowLocationModal(selected); setLocationLog([`Entered ${locMap[selected].name}.`]) }}>
Enter {locMap[selected].name}
)}
@@ -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}
-
setActionNpc(c.id)}>
- Action
-
+
setActionNpc(c.id)}>Action
))
- )}
+ }
@@ -1543,10 +1591,7 @@ function App() {
e.stopPropagation()}>
setActionNpc(null)}>X
- { setActionNpc(null); setShowLocationModal(null); startFight() }}>
- Fight
-
+ { setActionNpc(null); setShowLocationModal(null); startFight() }}>Fight
@@ -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()}>
{ setShowInventory(false); setPanelTarget('player'); setHoverInfo(null) }}>X
- {panelTarget === 'player' ? (
-
- ) : combat?.enemies[panelTarget] ? (
-
- ) : null}
+ {panelTarget === 'player'
+ ?
+ : combat?.enemies[panelTarget]
+ ?
+ : null}
- setInvTab('health')}>Health
- setInvTab('inventory')}>Inventory
- setInvTab('stats')}>Statistics
+ setInvTab('health')}>Health
+ setInvTab('inventory')}>Inventory
+ setInvTab('stats')}>Statistics
{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
- setShowSettings(false)}>X
-
+
Settings setShowSettings(false)}>X
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