diff --git a/src/App.css b/src/App.css index b23a195..8fe2f85 100644 --- a/src/App.css +++ b/src/App.css @@ -1709,6 +1709,53 @@ body { text-align: right; } +.cap-breakdown { + padding: 2px 0 6px 16px; + display: flex; + flex-direction: column; + gap: 2px; +} + +.cap-breakdown-row { + display: flex; + align-items: center; + gap: 6px; + font-size: 11px; +} + +.cap-bd-color { + width: 6px; + height: 6px; + border-radius: 1px; + flex-shrink: 0; +} + +.cap-bd-label { + color: #9ca3af; + width: 80px; + flex-shrink: 0; +} + +.cap-bd-bar { + flex: 1; + height: 6px; + background: #2e303a; + border-radius: 1px; + overflow: hidden; +} + +.cap-bd-fill { + display: block; + height: 100%; + border-radius: 1px; +} + +.cap-bd-hp { + color: #6b7280; + width: 50px; + text-align: right; +} + .part-injuries { display: flex; flex-wrap: wrap; diff --git a/src/App.jsx b/src/App.jsx index ff49b55..37ffaab 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,4 +1,4 @@ -import { useState, useRef, useCallback, useEffect, Fragment } from 'react' +import { useState, useRef, useCallback, useEffect, useLayoutEffect, Fragment } from 'react' import './App.css' import maleBody from './assets/body/malebody.png' import pistolShotMp3 from './assets/pistolshot.mp3' @@ -37,12 +37,12 @@ const characters = [ const weapons = [ { id: 'fists', name: 'Fists', damage: 35, condition: 100, maxCondition: 100, passives: ['Brawler'], weight: 0, speed: 14, skills: [{ name: 'Jab', mult: 1.0, damageType: 'blunt', range: 12 }, { name: 'Hook', mult: 1.3, damageType: 'blunt', range: 12 }, { name: 'Kick', mult: 1.5, damageType: 'blunt', range: 14 }, { name: 'Shove', mult: 0, damageType: 'shove', range: 10 }] }, - { id: 'pistol', name: 'Pistol', damage: 70, condition: 100, maxCondition: 100, passives: ['Ranged'], weight: 3, speed: 12, compatibleBulletSizes: ['9mm'], skills: [{ name: 'Shoot', mult: 1.0, damageType: 'pierce', range: 200 }, { name: 'Load', mult: 0, damageType: 'reload', range: 0 }, { name: 'Unload', mult: 0, damageType: 'unload', range: 0 }] }, - { id: 'rusty_sword', name: 'Rusty Sword', damage: 60, condition: 65, maxCondition: 100, passives: ['Bleed'], weight: 4, speed: 10, skills: [{ name: 'Slash', mult: 1.0, damageType: 'slash', range: 15 }, { name: 'Stab', mult: 1.2, damageType: 'pierce', range: 18 }] }, - { id: 'steel_sword', name: 'Steel Sword', damage: 85, condition: 100, maxCondition: 100, passives: ['Bleed', 'Pierce'], weight: 5, speed: 9, skills: [{ name: 'Slash', mult: 1.0, damageType: 'slash', range: 15 }, { name: 'Stab', mult: 1.2, damageType: 'pierce', range: 18 }, { name: 'Overhead', mult: 1.4, damageType: 'slash', range: 14 }] }, - { id: 'iron_shield', name: 'Iron Shield', damage: 40, condition: 100, maxCondition: 100, passives: ['Block', 'Sturdy'], weight: 6, speed: 7, skills: [{ name: 'Bash', mult: 1.0, damageType: 'blunt', range: 12 }, { name: 'Shield Slam', mult: 1.3, damageType: 'blunt', range: 13 }] }, - { id: 'dagger', name: 'Dagger', damage: 50, condition: 100, maxCondition: 100, passives: ['Quick', 'Bleed'], weight: 2, speed: 13, skills: [{ name: 'Slash', mult: 1.0, damageType: 'slash', range: 12 }, { name: 'Stab', mult: 1.3, damageType: 'pierce', range: 15 }] }, - { id: 'warhammer', name: 'Warhammer', damage: 100, condition: 100, maxCondition: 100, passives: ['Crush', 'Stagger'], weight: 8, speed: 6, skills: [{ name: 'Pound', mult: 1.0, damageType: 'blunt', range: 14 }, { name: 'Slam', mult: 1.4, damageType: 'blunt', range: 13 }] }, + { id: 'pistol', name: 'Pistol', damage: 70, condition: 100, maxCondition: 100, passives: ['Ranged'], weight: 3, speed: 12, compatibleBulletSizes: ['9mm'], skills: [{ name: 'Shoot', mult: 1.0, damageType: 'pierce', range: 200 }, { name: 'Load', mult: 0, damageType: 'reload', range: 0 }, { name: 'Unload', mult: 0, damageType: 'unload', range: 0 }, { name: 'Drop', mult: 0, damageType: 'drop', range: 0 }] }, + { id: 'rusty_sword', name: 'Rusty Sword', damage: 60, condition: 65, maxCondition: 100, passives: ['Bleed'], weight: 4, speed: 10, skills: [{ name: 'Slash', mult: 1.0, damageType: 'slash', range: 15 }, { name: 'Stab', mult: 1.2, damageType: 'pierce', range: 18 }, { name: 'Drop', mult: 0, damageType: 'drop', range: 0 }] }, + { 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 }, { name: 'Drop', mult: 0, damageType: 'drop', range: 0 }] }, + { 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 }, { name: 'Drop', mult: 0, damageType: 'drop', range: 0 }] }, + { 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 }, { name: 'Drop', mult: 0, damageType: 'drop', range: 0 }] }, + { 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 }, { name: 'Drop', mult: 0, damageType: 'drop', range: 0 }] }, ] const items = [ @@ -64,23 +64,54 @@ weaponMap['right_arm'] = { id: 'right_arm', name: 'Right Arm', ...ARM_WEAPON } // CONSTANTS // ───────────────────────────────────────────────────────────────────────────── -const MAX_BODY = { head: 100, torso: 100, leftArm: 100, rightArm: 100, leftLeg: 100, rightLeg: 100 } +const BODY_TREE = { + head: {}, + torso: { + leftUpperArm: { leftForearm: { leftHand: { leftFingers: {} } } }, + rightUpperArm: { rightForearm: { rightHand: { rightFingers: {} } } }, + leftUpperLeg: { leftLowerLeg: { leftFoot: { leftToes: {} } } }, + rightUpperLeg: { rightLowerLeg: { rightFoot: { rightToes: {} } } }, + } +} + +const ALL_PARTS = (function() { + const parts = [] + function walk(node) { for (const k of Object.keys(node)) { parts.push(k); if (Object.keys(node[k]).length > 0) walk(node[k]) } } + walk(BODY_TREE) + return parts +})() + +const BODY_CHILDREN = (function() { + const map = {} + function walk(node, parent) { for (const [k, v] of Object.entries(node)) { map[k] = Object.keys(v); if (Object.keys(v).length > 0) walk(v, k) } } + walk(BODY_TREE) + return map +})() + +const BODY_DESCENDANTS = {} +for (const part of ALL_PARTS) { + const descs = [] + function walk(p) { for (const c of BODY_CHILDREN[p] || []) { descs.push(c); walk(c) } } + walk(part) + BODY_DESCENDANTS[part] = descs +} + +const MAX_BODY = { + head: 80, torso: 200, + leftUpperArm: 70, leftForearm: 50, leftHand: 30, leftFingers: 15, + rightUpperArm: 70, rightForearm: 50, rightHand: 30, rightFingers: 15, + leftUpperLeg: 100, leftLowerLeg: 70, leftFoot: 40, leftToes: 15, + rightUpperLeg: 100, rightLowerLeg: 70, rightFoot: 40, rightToes: 15, +} const MAX_HP = Object.values(MAX_BODY).reduce((a, b) => a + b, 0) const MAX_WEIGHT = 50 -function generateLocationBlocks(locationId) { - let seed = 0 - for (let i = 0; i < locationId.length; i++) { - seed = ((seed << 5) - seed) + locationId.charCodeAt(i) - seed = seed & seed - } - let s = seed - function rng() { s = (s * 1664525 + 1013904223) & 0xFFFFFFFF; return (s >>> 0) / 0xFFFFFFFF } +function generateLocationBlocks() { const blocks = [] - const num = 3 + Math.floor(rng() * 3) + const num = 3 + Math.floor(Math.random() * 4) for (let i = 0; i < num; i++) { - const w = 15 + Math.floor(rng() * 25); const h = 15 + Math.floor(rng() * 35) - const x = 5 + Math.floor(rng() * (150 - w - 5)); const y = 5 + Math.floor(rng() * (150 - h - 5)) + const w = 10 + Math.floor(Math.random() * 30); const h = 10 + Math.floor(Math.random() * 40) + const x = 5 + Math.floor(Math.random() * (150 - w - 5)); const y = 5 + Math.floor(Math.random() * (150 - h - 5)) blocks.push({ x, y, w, h }) } return blocks @@ -98,13 +129,16 @@ function getItemValue(id) { const INJURY_DECAY = { cut: 0.3, laceration: 0.6, stab: 0.8 } const TURN_SPEED = 6 +const FRICTION = 3 const CURRENCY = 'ahn' const PISTOL_MAG = 7 const bodyPartLabels = { head: 'Head', torso: 'Torso', - leftArm: 'Left Arm', rightArm: 'Right Arm', - leftLeg: 'Left Leg', rightLeg: 'Right Leg', + leftUpperArm: 'Left Upper Arm', leftForearm: 'Left Forearm', leftHand: 'Left Hand', leftFingers: 'Left Fingers', + rightUpperArm: 'Right Upper Arm', rightForearm: 'Right Forearm', rightHand: 'Right Hand', rightFingers: 'Right Fingers', + leftUpperLeg: 'Left Upper Leg', leftLowerLeg: 'Left Lower Leg', leftFoot: 'Left Foot', leftToes: 'Left Toes', + rightUpperLeg: 'Right Upper Leg', rightLowerLeg: 'Right Lower Leg', rightFoot: 'Right Foot', rightToes: 'Right Toes', } // ───────────────────────────────────────────────────────────────────────────── @@ -165,10 +199,21 @@ function partColor(hp, max) { return '#ef4444' } -function freshBody() { return { head: 100, torso: 100, leftArm: 100, rightArm: 100, leftLeg: 100, rightLeg: 100 } } -function freshInjuries(){ return { head: [], torso: [], leftArm: [], rightArm: [], leftLeg: [], rightLeg: [] } } +function freshBody() { return { ...MAX_BODY } } +function freshInjuries(){ return Object.fromEntries(ALL_PARTS.map(p => [p, []])) } function totalHp(body) { return Object.values(body).reduce((a, b) => a + b, 0) } -function isDead(integrity) { return !integrity || integrity.head <= 0 || integrity.torso <= 0 } +function isDead(integrity) { return !integrity || integrity.head <= 0 } +function propagateDestruction(integrity) { + const out = { ...integrity } + for (const part of ALL_PARTS) { + if ((out[part] ?? 100) <= 0) { + for (const desc of BODY_DESCENDANTS[part]) { + if (out[desc] !== undefined) out[desc] = 0 + } + } + } + return out +} function createCharacter(data) { const weaponId = data.weapon ?? 'fists' @@ -190,6 +235,7 @@ function createCharacter(data) { fillColor: data.fillColor ?? `hsl(${Math.random() * 360}, 70%, 55%)`, weapon: weaponId, pos: data.pos ? { x: data.pos.x, y: data.pos.y } : undefined, + vel: { x: 0, y: 0 }, speed: (data.speed ?? wpn?.speed ?? 10) / 2, maxSpeed: data.maxSpeed ?? 2, attack: data.attack ?? wpn?.damage ?? 30, @@ -199,8 +245,7 @@ function createCharacter(data) { } function randomPart() { - const parts = ['head', 'torso', 'leftArm', 'rightArm', 'leftLeg', 'rightLeg'] - return parts[Math.floor(Math.random() * parts.length)] + return ALL_PARTS[Math.floor(Math.random() * ALL_PARTS.length)] } function injuryType(damageType) { @@ -220,7 +265,7 @@ function calcIntegrity(integrity, injuries, deltaTime = 1) { } const out = {} for (const part of Object.keys(integrity)) out[part] = Math.max(0, integrity[part] - drain * deltaTime) - return out + return propagateDestruction(out) } // ───────────────────────────────────────────────────────────────────────────── @@ -391,10 +436,7 @@ function runEnemyAI(combat, playerParts, playerInjuries, deadThisTick) { if (minDist > maxRange) { // out of range → move toward target const speed = e.speed > 12 ? 10 : 5 - const newPos = computeMove(e.pos, targetPos, speed, combat.blocks) - if (newPos.x !== e.pos.x || newPos.y !== e.pos.y) { - actions.push({ type: 'move', charId: cid, fromX: e.pos.x, fromY: e.pos.y, toX: newPos.x, toY: newPos.y, speed }) - } + actions.push({ type: 'move', charId: cid, fromX: e.pos.x, fromY: e.pos.y, toX: targetPos.x, toY: targetPos.y, speed }) continue } const skill = skills[Math.floor(Math.random() * skills.length)] @@ -424,12 +466,20 @@ function runEnemyAI(combat, playerParts, playerInjuries, deadThisTick) { // ───────────────────────────────────────────────────────────────────────────── const bodyOverlays = [ - { part: 'head', left: '22%', top: '1%', width: '56%', height: '20%' }, - { part: 'torso', left: '18%', top: '22%', width: '64%', height: '35%' }, - { part: 'leftArm', left: '2%', top: '22%', width: '20%', height: '34%' }, - { part: 'rightArm', left: '78%', top: '22%', width: '20%', height: '34%' }, - { part: 'leftLeg', left: '20%', top: '58%', width: '30%', height: '36%' }, - { part: 'rightLeg', left: '50%', top: '58%', width: '30%', height: '36%' }, + { part: 'head', left: '22%', top: '1%', width: '56%', height: '20%' }, + { part: 'torso', left: '18%', top: '22%', width: '64%', height: '35%' }, + { part: 'leftUpperArm', left: '2%', top: '22%', width: '20%', height: '14%' }, + { part: 'leftForearm', left: '2%', top: '36%', width: '16%', height: '12%' }, + { part: 'leftHand', left: '3%', top: '48%', width: '14%', height: '8%' }, + { part: 'rightUpperArm', left: '78%', top: '22%', width: '20%', height: '14%' }, + { part: 'rightForearm', left: '82%', top: '36%', width: '16%', height: '12%' }, + { part: 'rightHand', left: '83%', top: '48%', width: '14%', height: '8%' }, + { part: 'leftUpperLeg', left: '20%', top: '58%', width: '30%', height: '16%' }, + { part: 'leftLowerLeg', left: '22%', top: '74%', width: '26%', height: '14%' }, + { part: 'leftFoot', left: '24%', top: '88%', width: '22%', height: '8%' }, + { part: 'rightUpperLeg', left: '50%', top: '58%', width: '30%', height: '16%' }, + { part: 'rightLowerLeg', left: '52%', top: '74%', width: '26%', height: '14%' }, + { part: 'rightFoot', left: '54%', top: '88%', width: '22%', height: '8%' }, ] function BodyImage({ body }) { @@ -523,6 +573,9 @@ export default function App() { const [weather, setWeather] = useState('clear') const [defeatedNpcs, setDefeatedNpcs] = useState([]) const [characterOverrides, setCharacterOverrides] = useState({}) + const worldItemsRef = useRef({}) + const groundItemsRef = useRef([]) + const savedMapsRef = useRef({}) const liveCharacters = characters.map(c => ({ ...c, ...characterOverrides[c.id] })).filter(c => !defeatedNpcs.includes(c.id)) // ── ui state @@ -590,8 +643,12 @@ export default function App() { // ── projectiles (modular projectile framework) const [projectiles, setProjectiles] = useState([]) + // ── ground items + const [groundItems, setGroundItems] = useState([]) + // ── hovered character for arrow glow const [hoveredChar, setHoveredChar] = useState(null) + const [hoveredCap, setHoveredCap] = useState(null) // ── replay const [fightRecord, setFightRecord] = useState(null) @@ -631,6 +688,7 @@ export default function App() { const characterActionsRef = useRef({}) // { [charId]: action } const characterDirsRef = useRef({}) // { [charId]: {dx,dy} } const characterPositionsRef = useRef({}) // { [charId]: {x,y} } + const characterVelocitiesRef = useRef({}) // { [charId]: {x,y} } const recordingRef = useRef(null) const viewRef = useRef(null); viewRef.current = view @@ -693,6 +751,7 @@ export default function App() { FIST_STRIKE: 'fist_strike', FIST_HOOK: 'fist_hook', SHOOT: 'shoot', + OVERHEAD: 'overhead', } const ANIM_CFG = { @@ -701,6 +760,7 @@ export default function App() { [ANIM_TYPES.FIST_STRIKE]: { duration: 0.25, hitAt: 0.1 }, [ANIM_TYPES.FIST_HOOK]: { duration: 0.3, hitAt: 0.15 }, [ANIM_TYPES.SHOOT]: { duration: 0.5, hitAt: 0.5 }, + [ANIM_TYPES.OVERHEAD]: { duration: 0.35, hitAt: 0.18 }, } // ───────────────────────────────────────────────────────────────────────── @@ -821,8 +881,11 @@ export default function App() { */ function createWeaponStrikeAnim(action, cc) { if (!cc) return null - const isPlayerSide = action.actor === 'player' || action.actor === 'party' const attackerId = action.charId + if (characterVelocitiesRef.current[attackerId]) { + characterVelocitiesRef.current[attackerId] = { x: 0, y: 0 } + } + const isPlayerSide = action.actor === 'player' || action.actor === 'party' const targetId = action.targetCharId ?? (isPlayerSide ? action.enemyCharId : (cc.partyIds?.[0] ?? cc.playerEntityId)) const attacker = cc.characters[attackerId] const target = cc.characters[targetId] @@ -835,6 +898,7 @@ export default function App() { const isFist = action.weaponId === 'fists' || action.weaponId === 'left_arm' || action.weaponId === 'right_arm' const type = isFist ? (action.weaponId === 'right_arm' ? ANIM_TYPES.FIST_HOOK : ANIM_TYPES.FIST_STRIKE) + : action.skillName === 'Overhead' ? ANIM_TYPES.OVERHEAD : damageType === 'slash' ? ANIM_TYPES.SLASH : ANIM_TYPES.PIERCE_STRIKE @@ -927,30 +991,57 @@ export default function App() { } /** - * Fist hook: wide curved arc — sweeps out to the side then into the target. + * Fist hook: sweeps in a wide arc toward the enemy via a quadratic bezier. */ function getFistHookPos(anim) { const { hitAt, duration } = ANIM_CFG[ANIM_TYPES.FIST_HOOK] - const t = anim.elapsed < hitAt - ? anim.elapsed / hitAt - : anim.elapsed < duration - ? 1 - (anim.elapsed - hitAt) / (duration - hitAt) - : 0 - const mid = { - x: (anim.startPos.x + anim.endPos.x) / 2, - y: (anim.startPos.y + anim.endPos.y) / 2, - } const dx = anim.endPos.x - anim.startPos.x const dy = anim.endPos.y - anim.startPos.y const dist = Math.sqrt(dx * dx + dy * dy) || 1 const nx = -dy / dist const ny = dx / dist - const arcWidth = Math.sin(t * Math.PI) * 12 + // Control point offset perpendicular to the attack line — creates the hook arc + const cp = { + x: (anim.startPos.x + anim.endPos.x) / 2 + nx * Math.min(dist * 0.4, 20), + y: (anim.startPos.y + anim.endPos.y) / 2 + ny * Math.min(dist * 0.4, 20), + } + + // Forward phase: bezier curve from start → cp → end + if (anim.elapsed < hitAt) { + const t = anim.elapsed / hitAt + const u = 1 - t + return { + x: u * u * anim.startPos.x + 2 * u * t * cp.x + t * t * anim.endPos.x, + y: u * u * anim.startPos.y + 2 * u * t * cp.y + t * t * anim.endPos.y, + } + } + // Return phase: ease back to start + if (anim.elapsed < duration) { + const t = (anim.elapsed - hitAt) / (duration - hitAt) + const ease = t * t + return { + x: anim.endPos.x + (anim.startPos.x - anim.endPos.x) * ease, + y: anim.endPos.y + (anim.startPos.y - anim.endPos.y) * ease, + } + } + return { ...anim.startPos } + } + + /** + * Overhead: straight vertical chop toward the target. + */ + function getOverheadPos(anim) { + const { hitAt, duration } = ANIM_CFG[ANIM_TYPES.OVERHEAD] + const t = anim.elapsed < hitAt + ? anim.elapsed / hitAt + : anim.elapsed < duration + ? 1 - (anim.elapsed - hitAt) / (duration - hitAt) + : 0 return { - x: anim.startPos.x + dx * t + nx * arcWidth, - y: anim.startPos.y + dy * t + ny * arcWidth, + x: anim.startPos.x + (anim.endPos.x - anim.startPos.x) * t, + y: anim.startPos.y + (anim.endPos.y - anim.startPos.y) * t, } } @@ -982,16 +1073,16 @@ export default function App() { bothMult: 1.0, }) - const parts = ['head', 'torso', 'leftArm', 'rightArm', 'leftLeg', 'rightLeg'] - const part = parts[Math.floor(Math.random() * parts.length)] + const part = ALL_PARTS[Math.floor(Math.random() * ALL_PARTS.length)] setCombat(c => { const characters = { ...c.characters } const e = { ...characters[charId] } const integrity = { ...e.integrity } - const injuries = { ...(e.injuries ?? { head: [], torso: [], leftArm: [], rightArm: [], leftLeg: [], rightLeg: [] }) } + const injuries = { ...(e.injuries ?? freshInjuries()) } integrity[part] = Math.max(0, (integrity[part] ?? 100) - dmg) + Object.assign(integrity, propagateDestruction(integrity)) if (integrity[part] > 0 && severity > 0) { injuries[part] = [...(injuries[part] ?? []), { type: injType, severity }] } @@ -1040,7 +1131,7 @@ export default function App() { setCombat(c => ({ ...c, log: [...c.log, `Too far`] })) return } - const targetPart = 'torso' + const targetPart = randomPart() const enemyIndex = (cc.enemyIds ?? []).indexOf(charId) const action = buildPlayerAttack({ weaponId, skillName, targetPart, @@ -1057,12 +1148,12 @@ export default function App() { if (action.type === 'shove_pending') { const enemy2 = cc.characters[action.enemyCharId] if (!enemy2 || isDead(enemy2.integrity)) return - const { pos, actualPush } = computeShove(actorPos, enemy2.pos, cc.blocks) - setCombat(c => ({ - ...c, - characters: { ...c.characters, [action.enemyCharId]: { ...enemy2, pos } }, - log: [...c.log, `${actorChar.name} shoves ${enemy2.name} back ${Math.round(actualPush)} units!`], - })) + const dx = enemy2.pos.x - actorPos.x + const dy = enemy2.pos.y - actorPos.y + const dist = Math.sqrt(dx * dx + dy * dy) || 1 + const pushSpeed = 60 + Math.random() * 20 + characterVelocitiesRef.current[action.enemyCharId] = { x: (dx / dist) * pushSpeed, y: (dy / dist) * pushSpeed } + setCombat(c => ({ ...c, log: [...c.log, `${actorChar.name} shoves ${enemy2.name}!`] })) setLimbSelect(null); setSelectedSkill(null); setCurrentActor(null); setSkillDropdownOpen([]); setAimMode(false) return } @@ -1081,6 +1172,7 @@ export default function App() { case ANIM_TYPES.PIERCE_STRIKE: return getPierceStrikePos(anim) case ANIM_TYPES.FIST_STRIKE: return getFistStrikePos(anim) case ANIM_TYPES.FIST_HOOK: return getFistHookPos(anim) + case ANIM_TYPES.OVERHEAD: return getOverheadPos(anim) default: return null } } @@ -1141,15 +1233,20 @@ class ParallelAction { } class MoveAction { - constructor(data) { Object.assign(this, data); this.cx = this.fromX; this.cy = this.fromY } + constructor(data) { Object.assign(this, data) } step(dt) { - const steps = Math.max(1, Math.round(dt * 60)); const blk = this.blocks ?? [] - for (let i = 0; i < steps; i++) { - const next = computeMove({ x: this.cx, y: this.cy }, { x: this.toX, y: this.toY }, this.speed * dt * 60 / steps, blk) - if (next.x === this.cx && next.y === this.cy) return true - this.cx = next.x; this.cy = next.y - this.setPos(next) - if (next.x === this.toX && next.y === this.toY) return true + const pos = characterPositionsRef.current[this.charId] + if (!pos) return true + const dx = this.toX - pos.x + const dy = this.toY - pos.y + const dist = Math.sqrt(dx * dx + dy * dy) + if (dist < 1.5) { + characterVelocitiesRef.current[this.charId] = { x: 0, y: 0 } + return true + } + characterVelocitiesRef.current[this.charId] = { + x: (dx / dist) * this.speed * 60, + y: (dy / dist) * this.speed * 60, } return false } @@ -1230,7 +1327,7 @@ class MoveAction { if (enemy) { const decayedEnemy = calcIntegrity(enemy.integrity, enemy.injuries, ANIM_CFG[anim.type].hitAt) const prevHp = decayedEnemy[anim.targetPart] - const hitEnemy = { ...decayedEnemy, [anim.targetPart]: Math.max(0, prevHp - anim.dmg) } + const hitEnemy = propagateDestruction({ ...decayedEnemy, [anim.targetPart]: Math.max(0, prevHp - anim.dmg) }) const newInjuries = prevHp > 0 && anim.severity > 0 ? applyInjury(enemy.injuries, anim.targetPart, anim.injType, anim.severity) : enemy.injuries @@ -1266,7 +1363,7 @@ class MoveAction { const decayedTarget = calcIntegrity(targetIntegrity, targetInjuries, ANIM_CFG[anim.type].hitAt) const prevHp = decayedTarget[anim.targetPart] - const hitTarget = { ...decayedTarget, [anim.targetPart]: Math.max(0, prevHp - anim.dmg) } + const hitTarget = propagateDestruction({ ...decayedTarget, [anim.targetPart]: Math.max(0, prevHp - anim.dmg) }) const newInjuries = prevHp > 0 && anim.severity > 0 ? applyInjury(targetInjuries, anim.targetPart, anim.injType, anim.severity) : targetInjuries @@ -1297,6 +1394,58 @@ class MoveAction { } } + // ───────────────────────────────────────────────────────────────────────── + // PHYSICS — integrates velocities, resolves collisions, applies friction + // ───────────────────────────────────────────────────────────────────────── + + function stepPhysics(dt, blocks) { + const blk = blocks ?? [] + const updates = {} + for (const cid of Object.keys(characterVelocitiesRef.current)) { + const vel = characterVelocitiesRef.current[cid] + if (!vel || (vel.x === 0 && vel.y === 0)) continue + const pos = characterPositionsRef.current[cid] + if (!pos) continue + let nx = pos.x, ny = pos.y + const travel = Math.max(Math.abs(vel.x * dt), Math.abs(vel.y * dt)) + const subSteps = Math.max(1, Math.ceil(travel / 2)) + const subDt = dt / subSteps + for (let i = 0; i < subSteps; i++) { + let sx = nx + vel.x * subDt + let sy = ny + vel.y * subDt + const hitBlock = blk.find(b => rectCollides(sx, sy, b)) + if (hitBlock) { + if (!blk.some(b => rectCollides(sx, ny, b))) { + sy = ny + vel.y * subDt + } else if (!blk.some(b => rectCollides(nx, sy, b))) { + sx = nx + vel.x * subDt + } else { + vel.x = 0; vel.y = 0; break + } + } + nx = Math.max(5, Math.min(145, sx)) + ny = Math.max(5, Math.min(145, sy)) + } + pos.x = nx; pos.y = ny + updates[cid] = { x: nx, y: ny } + vel.x /= (1 + FRICTION * dt) + vel.y /= (1 + FRICTION * dt) + if (Math.abs(vel.x) < 0.01) vel.x = 0 + if (Math.abs(vel.y) < 0.01) vel.y = 0 + } + const keys = Object.keys(updates) + if (keys.length > 0) { + setCombat(c => { + if (!c) return c + const chars = { ...c.characters } + for (const cid of keys) { + if (chars[cid]) chars[cid] = { ...chars[cid], pos: { ...updates[cid] } } + } + return { ...c, characters: chars } + }) + } + } + // ───────────────────────────────────────────────────────────────────────── // TIMER — ticks while timerActive is true, updates all character actions // ───────────────────────────────────────────────────────────────────────── @@ -1427,12 +1576,6 @@ class MoveAction { const rawAnim = characterActionsRef.current[cid] if (!rawAnim) continue - // Sync move action start position from live ref - if (rawAnim.type === 'move') { - const cp = characterPositionsRef.current[cid] - if (cp) { rawAnim.cx = cp.x; rawAnim.cy = cp.y } - } - const done = rawAnim.step(dt) if (ANIM_CFG[rawAnim.type] && rawAnim.elapsed >= ANIM_CFG[rawAnim.type].hitAt && !rawAnim.hitFired) { rawAnim.hitFired = true @@ -1486,15 +1629,14 @@ class MoveAction { // Enemy move → attack transition (skip party members) if (rawAnim.type === 'move' && cid !== playerCharId && !(cc.partyIds ?? []).includes(cid)) { const e = fresh?.characters[cid] ?? cc.characters[cid] - const moveSub = rawAnim.actions?.find(a => a instanceof MoveAction) - const finalPos = moveSub ? { x: moveSub.toX, y: moveSub.toY } : { x: rawAnim.toX, y: rawAnim.toY } + const charPos = characterPositionsRef.current[cid] ?? e?.pos if (e && !isDead(e.integrity)) { const partyIds = cc.partyIds ?? [cc.playerEntityId].filter(Boolean) let targetId = null, minDist = Infinity for (const pid of partyIds) { const pch = (fresh ?? cc).characters[pid] if (!pch || isDead(pch.integrity) || deadThisTickRef.current.has(pid)) continue - const d = Math.sqrt((pch.pos.x - finalPos.x) ** 2 + (pch.pos.y - finalPos.y) ** 2) + const d = Math.sqrt((pch.pos.x - charPos.x) ** 2 + (pch.pos.y - charPos.y) ** 2) if (d < minDist) { minDist = d; targetId = pid } } if (targetId) { @@ -1533,6 +1675,9 @@ class MoveAction { } } + // ── Physics step (integrate velocities, resolve collisions) ── + if (cc) { stepPhysics(dt, cc.blocks) } + // ── Run enemy AI every frame for idle enemies ── if (fresh) { const actions = runEnemyAI(fresh, playerRef.current.integrity, playerRef.current.injuries, deadThisTickRef.current) @@ -1554,13 +1699,6 @@ class MoveAction { new MoveAction({ ...act, blocks: fresh.blocks, - setPos: p => { - setCombat(c => ({ - ...c, - characters: { ...c.characters, [act.charId]: { ...c.characters[act.charId], pos: { ...p } } } - })) - characterPositionsRef.current[act.charId] = p - } }) ] }) @@ -1595,6 +1733,13 @@ class MoveAction { return () => cancelAnimationFrame(frame) }, [travelAnim]) + // ── Initialize starting location on mount ── + useLayoutEffect(() => { + if (player.location && !combat) { + enterLocation(player.location) + } + }, []) + // ───────────────────────────────────────────────────────────────────────── // COMBAT OVER // ───────────────────────────────────────────────────────────────────────── @@ -1754,6 +1899,11 @@ class MoveAction { const startFight = (npcList) => { const list = npcList ?? [] if (list.length === 0) return + // Save current map state before fight + if (combat) { + savedMapsRef.current[player.location] = saveCurrentMapState() + worldItemsRef.current[player.location] = groundItemsRef.current + } const enemies = list.map((npc, i) => makeEnemy(npc, i)) const characters = {} @@ -1800,17 +1950,21 @@ class MoveAction { characters, enemyIds, log: [`You engage ${enemies.map(e => e.name).join(' and ')} in combat!`], - blocks: generateLocationBlocks(player.location), + blocks: generateLocationBlocks(), } setCombat(initCombat) + const locItems = worldItemsRef.current[player.location] ?? [] + setGroundItems(locItems); groundItemsRef.current = locItems stopTimer(); setBusy(false); prevBusyRef.current = false setCombatTime(0); setDisplayTime(0) characterActionsRef.current = {} characterPositionsRef.current = {} + characterVelocitiesRef.current = {} characterDirsRef.current = {} for (const cid of Object.keys(characters)) { characterActionsRef.current[cid] = null characterPositionsRef.current[cid] = { ...characters[cid].pos } + characterVelocitiesRef.current[cid] = { x: 0, y: 0 } characterDirsRef.current[cid] = { dx: 0, dy: 1 } } setSelectedEnemy(0); setReplayActive(false); setCurrentActor(null) @@ -1827,6 +1981,20 @@ class MoveAction { /** Enter a location — creates a combat view with all NPCs present as non-hostile */ const enterLocation = (locationId, playerData) => { const pd = playerData ?? player + + // Restore saved map if one exists for this location + const saved = savedMapsRef.current[locationId] + if (saved) { + restoreMapState(locationId, saved, pd) + const locItems = worldItemsRef.current[locationId] ?? saved.groundItems + setGroundItems(locItems); groundItemsRef.current = locItems + setCombatTime(0); setDisplayTime(0) + recordingRef.current = null; setFightRecord(null); setProjectiles([]); setHitShake(null) + speedRef.current = 1; setSpeedMult(1) + setAimMode(false); setLimbSelect(null); setSelectedSkill(null); setSelectedTarget(null); setAttackEnemy(null); setCurrentActor(null); setSkillDropdownOpen([]) + return + } + const characters = {} characters['player'] = createCharacter({ @@ -1885,29 +2053,35 @@ class MoveAction { characters, enemyIds: [], log: [`You arrive at ${locName}.`], - blocks: generateLocationBlocks(locationId), + blocks: generateLocationBlocks(), } setCombat(initCombat) + const locItems = worldItemsRef.current[locationId] ?? [] + setGroundItems(locItems); groundItemsRef.current = locItems setCombatTime(0); setDisplayTime(0) characterActionsRef.current = {} characterPositionsRef.current = {} + characterVelocitiesRef.current = {} characterDirsRef.current = {} for (const cid of Object.keys(characters)) { characterActionsRef.current[cid] = null characterPositionsRef.current[cid] = { ...characters[cid].pos } + characterVelocitiesRef.current[cid] = { x: 0, y: 0 } characterDirsRef.current[cid] = { dx: 0, dy: 1 } } - recordingRef.current = null; setFightRecord(null); setProjectiles([]); setHitShake(null) + recordingRef.current = null; setFightRecord(null); setProjectiles([]); setHitShake(null); setGroundItems([]) speedRef.current = 1; setSpeedMult(1) setAimMode(false); setLimbSelect(null); setSelectedSkill(null); setSelectedTarget(null); setAttackEnemy(null); setCurrentActor(null); setSkillDropdownOpen([]) } const endFight = () => { if (recordingRef.current) { pushSnapshot(); setFightRecord(recordingRef.current); recordingRef.current = null } + if (combat) worldItemsRef.current[player.location] = groundItemsRef.current stopTimer() setCombatTime(0); setDisplayTime(0) characterActionsRef.current = {} - setProjectiles([]) + characterVelocitiesRef.current = {} + setProjectiles([]); setGroundItems([]); groundItemsRef.current = [] if (combat) { const deadIds = combat.enemyIds.map(id => combat.characters[id]).filter(e => isDead(e.integrity)).map(e => e.id) if (deadIds.length > 0) setDefeatedNpcs(prev => [...new Set([...prev, ...deadIds])]) @@ -1943,6 +2117,60 @@ class MoveAction { } } + /** Save the current map (combat) state for a location */ + const saveCurrentMapState = () => { + if (!combat) return null + const cc = combatRef.current ?? combat + return { + playerEntityId: cc.playerEntityId, + partyIds: [...(cc.partyIds ?? [])], + npcIds: [...(cc.npcIds ?? [])], + enemyIds: [...(cc.enemyIds ?? [])], + characters: JSON.parse(JSON.stringify(cc.characters)), + log: [...cc.log], + blocks: cc.blocks, + groundItems: [...(groundItemsRef.current ?? [])], + } + } + + /** Restore a saved map state, updating player with current data */ + const restoreMapState = (locationId, saved, playerData) => { + const pd = playerData ?? player + const savedPlayer = saved.characters['player'] + saved.characters['player'] = createCharacter({ + id: 'player', name: pd.name, + pos: savedPlayer?.pos ?? { x: 20, y: 75 }, + integrity: { ...pd.integrity }, + injuries: JSON.parse(JSON.stringify(pd.injuries)), + equipped: { ...pd.equipped }, + weaponConditions: { ...pd.weaponConditions }, + weaponMagazines: { ...pd.weaponMagazines }, + itemCounts: { ...pd.itemCounts }, + }) + setCombat({ + playerEntityId: saved.playerEntityId, + partyIds: saved.partyIds, + npcIds: saved.npcIds, + enemyIds: saved.enemyIds, + characters: saved.characters, + log: [`You arrive at ${locMap[locationId]?.name ?? locationId}.`], + blocks: saved.blocks, + }) + setGroundItems(saved.groundItems) + groundItemsRef.current = saved.groundItems + setCombatTime(0); setDisplayTime(0) + characterPositionsRef.current = {} + characterVelocitiesRef.current = {} + characterDirsRef.current = {} + characterActionsRef.current = {} + for (const cid of Object.keys(saved.characters)) { + characterPositionsRef.current[cid] = { ...saved.characters[cid].pos } + characterVelocitiesRef.current[cid] = { x: 0, y: 0 } + characterDirsRef.current[cid] = { dx: 0, dy: 1 } + characterActionsRef.current[cid] = null + } + } + /** Execute a player attack; queue it for execution. */ const executeAttack = () => { if (!limbSelect || !selectedTarget) { @@ -1987,12 +2215,12 @@ class MoveAction { if (action.type === 'shove_pending') { const enemy2 = cc.characters[action.enemyCharId] if (!enemy2 || isDead(enemy2.integrity)) return - const { pos, actualPush } = computeShove(actorPos, enemy2.pos, fresh.blocks) - setCombat(c => ({ - ...c, - characters: { ...c.characters, [action.enemyCharId]: { ...enemy2, pos } }, - log: [...c.log, `${actorChar.name} shoves ${enemy2.name} back ${Math.round(actualPush)} units!`], - })) + const dx = enemy2.pos.x - actorPos.x + const dy = enemy2.pos.y - actorPos.y + const dist = Math.sqrt(dx * dx + dy * dy) || 1 + const pushSpeed = 60 + Math.random() * 20 + characterVelocitiesRef.current[action.enemyCharId] = { x: (dx / dist) * pushSpeed, y: (dy / dist) * pushSpeed } + setCombat(c => ({ ...c, log: [...c.log, `${actorChar.name} shoves ${enemy2.name}!`] })) setLimbSelect(null); setSelectedSkill(null); setSelectedTarget(null); setAttackEnemy(null); setCurrentActor(null); setSkillDropdownOpen([]) return } @@ -2068,13 +2296,7 @@ class MoveAction { const maxSpd = ch.maxSpeed ?? 10 const pct = moveMode === 'run' ? 1.0 : 0.5 const speed = maxSpd * pct - const moveAction = new MoveAction({ type: 'move', fromX: curPos.x, fromY: curPos.y, toX: targetX, toY: targetY, speed, blocks: combat.blocks, - setPos: (p, cid) => { - const id = cid || actorId - setCombat(c => ({ ...c, characters: { ...c.characters, [id]: { ...c.characters[id], pos: { ...p } } } })) - characterPositionsRef.current[id] = p - } - }) + const moveAction = new MoveAction({ type: 'move', charId: actorId, fromX: curPos.x, fromY: curPos.y, toX: targetX, toY: targetY, speed, blocks: combat.blocks }) const dx = targetX - curPos.x, dy = targetY - curPos.y const d = Math.sqrt(dx * dx + dy * dy) const dir = d > 0 ? { dx: dx / d, dy: dy / d } : { dx: 0, dy: 1 } @@ -2275,6 +2497,11 @@ class MoveAction { const travel = (targetId) => { if (travelAnim) return + // Save current map state before leaving + if (combat) { + savedMapsRef.current[player.location] = saveCurrentMapState() + worldItemsRef.current[player.location] = groundItemsRef.current + } const from = locations.find(l => l.id === player.location) const to = locations.find(l => l.id === targetId) if (!from || !to) return @@ -2303,13 +2530,84 @@ class MoveAction { setEquipArmChoice(null) } + const dropWeapon = (weaponId, actorId) => { + const isPlayer = actorId === 'player' + if (isPlayer) { + const p = playerRef.current + const slots = [] + if (p.equipped.left === weaponId) slots.push('left') + if (p.equipped.right === weaponId) slots.push('right') + if (slots.length === 0) return + const newEquipped = { ...p.equipped } + for (const s of slots) newEquipped[s] = 'fists' + setPlayer(p => ({ ...p, equipped: newEquipped })) + setCombat(prev => { + const ch = prev.characters['player'] + if (!ch) return prev + const ce = { ...ch.equipped } + for (const s of slots) ce[s] = 'fists' + return { ...prev, characters: { ...prev.characters, 'player': { ...ch, equipped: ce } } } + }) + } else { + setCombat(prev => { + const ch = prev.characters[actorId] + if (!ch) return prev + const equipped = { ...ch.equipped } + const slots = [] + if (equipped.left === weaponId) slots.push('left') + if (equipped.right === weaponId) slots.push('right') + if (slots.length === 0) return prev + for (const s of slots) equipped[s] = 'fists' + return { ...prev, characters: { ...prev.characters, [actorId]: { ...ch, equipped } } } + }) + } + const cc = combatRef.current + const charPos = isPlayer + ? characterPositionsRef.current['player'] ?? cc?.characters['player']?.pos ?? { x: 80, y: 80 } + : cc?.characters[actorId]?.pos ?? { x: 80, y: 80 } + const gi = { id: `gi_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`, itemId: weaponId, x: charPos.x, y: charPos.y } + setGroundItems(prev => { const next = [...prev, gi]; groundItemsRef.current = next; return next }) + } + + const pickupGroundItem = (groundId) => { + const gi = groundItemsRef.current.find(g => g.id === groundId) + if (!gi) return + const cc = combatRef.current + const playerPos = characterPositionsRef.current['player'] ?? cc?.characters['player']?.pos + if (!playerPos) return + const dx = playerPos.x - gi.x, dy = playerPos.y - gi.y + const dist = Math.sqrt(dx * dx + dy * dy) + if (dist > 15) { + setCombat(prev => ({ ...prev, log: [...prev.log, 'Too far to pick up.'] })) + return + } + const itemWpn = weaponMap[gi.itemId] + if (itemWpn) { + setPlayer(p => ({ ...p, inventory: [...p.inventory, gi.itemId], weaponConditions: p.weaponConditions[gi.itemId] !== undefined ? p.weaponConditions : { ...p.weaponConditions, [gi.itemId]: itemWpn.maxCondition } })) + setCombat(prev => ({ ...prev, characters: { ...prev.characters, 'player': { ...prev.characters['player'], inventory: [...(prev.characters['player']?.inventory ?? []), gi.itemId] } }, log: [...prev.log, `Picked up ${itemWpn.name ?? gi.itemId}.`] })) + } else { + setPlayer(p => ({ ...p, inventory: [...p.inventory, gi.itemId] })) + setCombat(prev => ({ ...prev, characters: { ...prev.characters, 'player': { ...prev.characters['player'], inventory: [...(prev.characters['player']?.inventory ?? []), gi.itemId] } }, log: [...prev.log, `Picked up ${itemMap[gi.itemId]?.name ?? gi.itemId}.`] })) + } + setGroundItems(prev => { const next = prev.filter(g => g.id !== groundId); groundItemsRef.current = next; return next }) + } + + const dropInventoryItem = (itemId) => { + const p = playerRef.current + const idx = p.inventory.indexOf(itemId) + if (idx === -1) return + const cc = combatRef.current + const pos = characterPositionsRef.current['player'] ?? cc?.characters['player']?.pos ?? { x: 80, y: 80 } + const gi = { id: `gi_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`, itemId, x: pos.x, y: pos.y } + setGroundItems(prev => { const next = [...prev, gi]; groundItemsRef.current = next; return next }) + setPlayer(p => ({ ...p, inventory: p.inventory.filter(id => id !== itemId) })) + setCombat(prev => ({ ...prev, characters: { ...prev.characters, 'player': { ...prev.characters['player'], inventory: (prev.characters['player']?.inventory ?? []).filter(id => id !== itemId) } }, log: [...prev.log, `Dropped ${weaponMap[itemId]?.name ?? itemMap[itemId]?.name ?? itemId}.`] })) + } + // ───────────────────────────────────────────────────────────────────────── // DISPLAY HELPERS // ───────────────────────────────────────────────────────────────────────── - const partMap = { larm: 'leftArm', rarm: 'rightArm', lleg: 'leftLeg', rleg: 'rightLeg' } - const selectLimbTarget = (part) => setSelectedTarget(partMap[part] || part) - /** Render inline skill → enemy → limb → confirm dropdown inside the action tree */ const renderSkillDropdown = (weaponId, actorId) => { const weapon = weaponMap[weaponId] @@ -2322,7 +2620,7 @@ class MoveAction {
{skills.map((skill, si) => { const active = selectedSkill === skill.name - const isSpecial = skill.name === 'Load' || skill.name === 'Unload' + const isSpecial = skill.name === 'Load' || skill.name === 'Unload' || skill.name === 'Drop' return ( {active && isSpecial ? ( @@ -2330,6 +2628,7 @@ class MoveAction { onClick={() => { if (skill.name === 'Load') { startReload(); return } if (skill.name === 'Unload') { unloadPistol(); return } + if (skill.name === 'Drop') { dropWeapon(weaponId, actorId); return } }} onMouseEnter={() => setHoveredSkill(skill.name)} onMouseLeave={() => setHoveredSkill(null)}> @@ -2350,7 +2649,7 @@ class MoveAction { ) : (
{ - if (isSpecial) { if (skill.name === 'Load') { startReload(); return }; if (skill.name === 'Unload') { unloadPistol(); return } } + if (isSpecial) { if (skill.name === 'Load') { startReload(); return }; if (skill.name === 'Unload') { unloadPistol(); return }; if (skill.name === 'Drop') { dropWeapon(weaponId, actorId); return } } setSelectedSkill(skill.name); setLimbSelect(weaponId); setCurrentActor(actorId) setAttackEnemy(null); setSelectedTarget(null) setAimMode(true) @@ -2370,14 +2669,30 @@ class MoveAction {
) : ( <> - {[{ 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)}> - - {label} -
- ))} + {(() => { + const renderLimbTree = (tree, depth) => + Object.entries(tree).map(([key, children]) => { + const hasChildren = Object.keys(children).length > 0 + const isSelected = selectedTarget === key + return ( + +
setSelectedTarget(key)}> + {hasChildren ? : } + {bodyPartLabels[key] || key} +
+ {hasChildren && ( +
+ {renderLimbTree(children, depth)} +
+ )} +
+ ) + }) + return renderLimbTree(BODY_TREE, 0) + })()}
) })} -
Integrity{charHp}/{MAX_HP}
+
Capabilities
+ {(() => { + const i = character.integrity + const cap = (val) => Math.max(0, Math.min(100, Math.round(val * 100))) + const consciousness = cap(i.head / MAX_BODY.head) + const sight = cap(i.head / MAX_BODY.head) + const hearing = cap(i.head / MAX_BODY.head) + const manipulation = cap((i.leftHand / MAX_BODY.leftHand + i.leftFingers / MAX_BODY.leftFingers + i.rightHand / MAX_BODY.rightHand + i.rightFingers / MAX_BODY.rightFingers) / 4) + const movement = cap((i.leftUpperLeg / MAX_BODY.leftUpperLeg + i.leftLowerLeg / MAX_BODY.leftLowerLeg + i.leftFoot / MAX_BODY.leftFoot + i.leftToes / MAX_BODY.leftToes + i.rightUpperLeg / MAX_BODY.rightUpperLeg + i.rightLowerLeg / MAX_BODY.rightLowerLeg + i.rightFoot / MAX_BODY.rightFoot + i.rightToes / MAX_BODY.rightToes) / 8) + const caps = [ + { label: 'Consciousness', val: consciousness, parts: [{ part: 'head' }] }, + { label: 'Sight', val: sight, parts: [{ part: 'head' }] }, + { label: 'Hearing', val: hearing, parts: [{ part: 'head' }] }, + { label: 'Manipulation', val: manipulation, parts: [{ part: 'leftHand' }, { part: 'leftFingers' }, { part: 'rightHand' }, { part: 'rightFingers' }] }, + { label: 'Movement', val: movement, parts: [{ part: 'leftUpperLeg' }, { part: 'leftLowerLeg' }, { part: 'leftFoot' }, { part: 'leftToes' }, { part: 'rightUpperLeg' }, { part: 'rightLowerLeg' }, { part: 'rightFoot' }, { part: 'rightToes' }] }, + ] + return caps.map(c => ( +
setHoveredCap(c.label)} + onMouseLeave={() => setHoveredCap(null)} + style={{ position: 'relative' }}> +
+
+
{c.label}
+
+
{c.val}%
+
+ {hoveredCap === c.label && ( +
+ {c.parts.map(p => { + const hp = i[p.part] ?? 0 + const max = MAX_BODY[p.part] + return ( +
+
+ {bodyPartLabels[p.part] || p.part} + + {hp}/{max} +
+ ) + })} +
+ )} +
+ )) + })()}
)} {character && invTab === 'inventory' && ( @@ -3230,6 +3596,7 @@ onContextMenu={e => { e.preventDefault(); setHoverInfo(null); setHoveredChar(nul ? ×{qty} : itemMap[itemId] && ×1} {isPlayer && weaponMap[itemId] && } + {isPlayer && }
) }) @@ -3527,8 +3894,14 @@ onContextMenu={e => { e.preventDefault(); setHoverInfo(null); setHoveredChar(nul {contextMenu && (
setContextMenu(null)} onContextMenu={e => { e.preventDefault(); setContextMenu(null) }}>
{ e.stopPropagation(); setHoverInfo(null); setHoveredChar(null); setContextMenu(null) }}> - - + {contextMenu.groundId ? ( + + ) : ( + <> + + + + )}
)}