pre physics

This commit is contained in:
2026-06-20 14:51:12 -04:00
parent d9f45f71ff
commit 4bb5a1c4cf
2 changed files with 542 additions and 122 deletions
+47
View File
@@ -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;
+489 -116
View File
@@ -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)]
@@ -426,10 +468,18 @@ 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: '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: anim.startPos.x + dx * t + nx * arcWidth,
y: anim.startPos.y + dy * t + ny * arcWidth,
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 + (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 {
<div className="tree-children">
{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 (
<Fragment key={si}>
{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 {
) : (
<div className="tree-node leaf"
onClick={() => {
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 {
</div>
) : (
<>
{[{ 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 renderLimbTree = (tree, depth) =>
Object.entries(tree).map(([key, children]) => {
const hasChildren = Object.keys(children).length > 0
const isSelected = selectedTarget === key
return (
<Fragment key={key}>
<div key={key}
className={'tree-node leaf' + (selectedTarget === (partMap[key] || key) ? ' tree-leaf-selected' : '')}
onClick={() => selectLimbTarget(key)}>
<span className="tree-bullet"></span>
<span className="tree-label">{label}</span>
className={'tree-node' + (hasChildren ? ' folder' : ' leaf') + (isSelected ? (hasChildren ? ' tree-folder-selected' : ' tree-leaf-selected') : '')}
style={{ paddingLeft: 8 + depth * 16 }}
onClick={() => setSelectedTarget(key)}>
{hasChildren ? <span className="tree-toggle"></span> : <span className="tree-bullet"></span>}
<span className="tree-label">{bodyPartLabels[key] || key}</span>
</div>
))}
{hasChildren && (
<div className="tree-children">
{renderLimbTree(children, depth)}
</div>
)}
</Fragment>
)
})
return renderLimbTree(BODY_TREE, 0)
})()}
<div className="tree-node leaf" style={{ cursor: 'default', padding: '6px 8px', gap: 8 }}>
<button className="limb-confirm-btn" onClick={executeAttack}
disabled={!selectedSkill || attackEnemy === null || !selectedTarget || busy || (ac ? isDead(ac.integrity) : false)}
@@ -2888,7 +3203,7 @@ class MoveAction {
<circle key={s.key} cx={bx + s.ox} cy={by + s.oy} r="3" fill={s.fill} stroke="#000" strokeWidth="0.5" />
)
const isFist = w => w === 'fists' || w === 'left_arm' || w === 'right_arm'
const isAtk = t => [ANIM_TYPES.SLASH, ANIM_TYPES.PIERCE_STRIKE, ANIM_TYPES.FIST_STRIKE, ANIM_TYPES.FIST_HOOK].includes(t)
const isAtk = t => [ANIM_TYPES.SLASH, ANIM_TYPES.PIERCE_STRIKE, ANIM_TYPES.FIST_STRIKE, ANIM_TYPES.FIST_HOOK, ANIM_TYPES.OVERHEAD].includes(t)
const allSlots = []
const partyIds = combat.partyIds ?? [combat.playerEntityId].filter(Boolean)
partyIds.forEach(cid => {
@@ -2937,7 +3252,7 @@ class MoveAction {
const bx = shake ? enemy.pos.x + (Math.random() - 0.5) * 1.5 : enemy.pos.x
const by = shake ? enemy.pos.y + (Math.random() - 0.5) * 1.5 : enemy.pos.y
const isFist = wpn === 'fists' || wpn === 'left_arm' || wpn === 'right_arm'
const enemyAnim = animations.find(a => a.charId === eid && (a.type === ANIM_TYPES.SLASH || a.type === ANIM_TYPES.PIERCE_STRIKE || a.type === ANIM_TYPES.FIST_STRIKE || a.type === ANIM_TYPES.FIST_HOOK))
const enemyAnim = animations.find(a => a.charId === eid && (a.type === ANIM_TYPES.SLASH || a.type === ANIM_TYPES.PIERCE_STRIKE || a.type === ANIM_TYPES.FIST_STRIKE || a.type === ANIM_TYPES.FIST_HOOK || a.type === ANIM_TYPES.OVERHEAD))
if (isFist && !enemyAnim) return null
const p = enemyAnim ? { x: getAnimPos(enemyAnim).x - enemy.pos.x, y: getAnimPos(enemyAnim).y - enemy.pos.y } : { x: -dir.dy * 7, y: dir.dx * 7 }
return <circle key={`ew-${eid}`} cx={bx + p.x} cy={by + p.y} r="3" fill={isFist ? enemy.fillColor : '#f97316'} stroke="#000" strokeWidth="0.5" />
@@ -2946,6 +3261,12 @@ class MoveAction {
{projectiles.map(p => (
<circle key={p.id} cx={p.pos.x} cy={p.pos.y} r="1.5" fill="#fbbf24" stroke="#ff6b35" strokeWidth="1" />
))}
{/* Ground items */}
{groundItems.map(gi => (
<circle key={gi.id} cx={gi.x} cy={gi.y} r="2.5" fill="#a78bfa" stroke="#7c3aed" strokeWidth="1"
style={{ cursor: 'pointer', pointerEvents: 'auto' }}
onContextMenu={e => { e.preventDefault(); e.stopPropagation(); setContextMenu({ x: e.clientX, y: e.clientY, groundId: gi.id, itemId: gi.itemId }) }} />
))}
{/* Direction arrows (on top, in front of character) */}
{combat && (() => {
const partyIds = combat.partyIds ?? [combat.playerEntityId].filter(Boolean)
@@ -3167,7 +3488,52 @@ onContextMenu={e => { e.preventDefault(); setHoverInfo(null); setHoveredChar(nul
</div>
)
})}
<div className="inv-total-row"><span>Integrity</span><span>{charHp}/{MAX_HP}</span></div>
<div className="inv-parts-title" style={{ marginTop: 12 }}>Capabilities</div>
{(() => {
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 => (
<div key={c.label}
onMouseEnter={() => setHoveredCap(c.label)}
onMouseLeave={() => setHoveredCap(null)}
style={{ position: 'relative' }}>
<div className="part-row">
<div className="part-color" style={{ background: partColor(c.val, 100) }} />
<div className="part-label">{c.label}</div>
<div className="part-bar-track"><div className="part-bar-fill" style={{ width: `${c.val}%`, background: partColor(c.val, 100) }} /></div>
<div className="part-hp">{c.val}%</div>
</div>
{hoveredCap === c.label && (
<div className="cap-breakdown">
{c.parts.map(p => {
const hp = i[p.part] ?? 0
const max = MAX_BODY[p.part]
return (
<div key={p.part} className="cap-breakdown-row">
<div className="cap-bd-color" style={{ background: partColor(hp, max) }} />
<span className="cap-bd-label">{bodyPartLabels[p.part] || p.part}</span>
<span className="cap-bd-bar"><span className="cap-bd-fill" style={{ width: `${(hp / max) * 100}%`, background: partColor(hp, max) }} /></span>
<span className="cap-bd-hp">{hp}/{max}</span>
</div>
)
})}
</div>
)}
</div>
))
})()}
</div>
)}
{character && invTab === 'inventory' && (
@@ -3230,6 +3596,7 @@ onContextMenu={e => { e.preventDefault(); setHoverInfo(null); setHoveredChar(nul
? <span style={{ marginLeft: 6, color: '#9ca3af' }}>×{qty}</span>
: itemMap[itemId] && <span style={{ marginLeft: 6, color: '#9ca3af' }}>×1</span>}
{isPlayer && weaponMap[itemId] && <button className="equip-btn" onClick={() => setEquipArmChoice(itemId)}>Equip</button>}
{isPlayer && <button className="drop-btn" style={{ marginLeft: 6, fontSize: 10, padding: '1px 6px', background: '#4a3030', border: '1px solid #6b4040', color: '#e0a0a0', borderRadius: 2, cursor: 'pointer' }} onClick={() => dropInventoryItem(itemId)}>Drop</button>}
</div>
)
})
@@ -3527,8 +3894,14 @@ onContextMenu={e => { e.preventDefault(); setHoverInfo(null); setHoveredChar(nul
{contextMenu && (
<div className="backdrop" style={{ position: 'fixed', inset: 0, zIndex: 299 }} onClick={() => setContextMenu(null)} onContextMenu={e => { e.preventDefault(); setContextMenu(null) }}>
<div className="context-menu" style={{ left: contextMenu.x, top: contextMenu.y, position: 'absolute' }} onClick={e => { e.stopPropagation(); setHoverInfo(null); setHoveredChar(null); setContextMenu(null) }}>
{contextMenu.groundId ? (
<button className="context-menu-btn" onClick={e => { e.stopPropagation(); pickupGroundItem(contextMenu.groundId); setContextMenu(null) }}>Pick Up {weaponMap[contextMenu.itemId]?.name ?? itemMap[contextMenu.itemId]?.name ?? contextMenu.itemId}</button>
) : (
<>
<button className="context-menu-btn" onClick={e => { e.stopPropagation(); setShowInventory(true); setPanelTarget(contextMenu.charId); setContextMenu(null) }}>Inspect</button>
<button className={`context-menu-btn${canTrade(contextMenu.charId) ? '' : ' disabled'}`} disabled={!canTrade(contextMenu.charId)} onClick={e => { e.stopPropagation(); if (canTrade(contextMenu.charId)) startTrade(contextMenu.charId) }}>Trade</button>
</>
)}
</div>
</div>
)}