This commit is contained in:
2026-06-10 21:30:19 -04:00
parent 19f01559eb
commit 80028261e1
+213 -101
View File
@@ -21,13 +21,13 @@ const connections = [
] ]
const characters = [ const characters = [
{ id: 'player', name: 'You', location: 'A', size: 1.3 }, { id: 'player', name: 'You', location: 'A', size: 1 },
{ id: '1', name: 'Mara', location: 'A', money: 120, inventory: ['pistol_ammo'], size: 1 }, { id: '1', name: 'Mara', location: 'A', money: 120, inventory: ['pistol_ammo'], size: 1 },
{ id: '2', name: 'Rook', location: 'A', money: 0, inventory: ['pistol_ammo'], size: 1.2 }, { id: '2', name: 'Rook', location: 'A', money: 0, inventory: ['pistol_ammo'], size: 1 },
{ id: '3', name: 'Vex', location: 'B', money: 500, inventory: ['pistol_ammo'], size: 0.9 }, { id: '3', name: 'Vex', location: 'B', money: 500, inventory: ['pistol_ammo'], size: 1 },
{ id: 'd1', name: 'Dust Gutter', location: 'D', money: 15, inventory: ['pistol_ammo'], size: 1 }, { id: 'd1', name: 'Dust Gutter', location: 'D', money: 15, inventory: ['pistol_ammo'], size: 1 },
{ id: 'd2', name: 'Bone Collector', location: 'D', money: 30, inventory: ['pistol_ammo'], size: 1.1 }, { id: 'd2', name: 'Bone Collector', location: 'D', money: 30, inventory: ['pistol_ammo'], size: 1 },
{ id: 'd3', name: 'Sand Viper', location: 'D', money: 8, inventory: ['pistol_ammo'], size: 0.8 }, { id: 'd3', name: 'Sand Viper', location: 'D', money: 8, inventory: ['pistol_ammo'], size: 1 },
{ id: 'd4', name: 'Rust-Eye', location: 'D', money: 45, inventory: ['pistol_ammo'], size: 1 }, { id: 'd4', name: 'Rust-Eye', location: 'D', money: 45, inventory: ['pistol_ammo'], size: 1 },
{ id: 'd5', name: 'Cinder', location: 'D', money: 22, inventory: ['pistol_ammo'], size: 1 }, { id: 'd5', name: 'Cinder', location: 'D', money: 22, inventory: ['pistol_ammo'], size: 1 },
] ]
@@ -40,6 +40,7 @@ const shops = [
{ name: 'Iron Shield', price: 50, weaponId: 'iron_shield' }, { name: 'Iron Shield', price: 50, weaponId: 'iron_shield' },
{ name: 'Dagger', price: 30, weaponId: 'dagger' }, { name: 'Dagger', price: 30, weaponId: 'dagger' },
{ name: 'Pistol Ammo', price: 5, itemId: 'pistol_ammo' }, { name: 'Pistol Ammo', price: 5, itemId: 'pistol_ammo' },
{ name: 'Bouncing Ammo', price: 8, itemId: 'bouncing_ammo' },
], ],
}, },
] ]
@@ -56,6 +57,7 @@ const weapons = [
const items = [ const items = [
{ id: 'pistol_ammo', name: 'Pistol Ammo', description: 'Standard pistol ammunition.', weight: 0.5 }, { id: 'pistol_ammo', name: 'Pistol Ammo', description: 'Standard pistol ammunition.', weight: 0.5 },
{ id: 'bouncing_ammo', name: 'Bouncing Ammo', description: 'Experimental ammo that bounces off walls.', weight: 0.5 },
] ]
const itemMap = {} const itemMap = {}
@@ -132,7 +134,8 @@ function createCharacter(data) {
fillColor: data.fillColor ?? `hsl(${Math.random() * 360}, 70%, 55%)`, fillColor: data.fillColor ?? `hsl(${Math.random() * 360}, 70%, 55%)`,
weapon: weaponId, weapon: weaponId,
pos: data.pos ? { x: data.pos.x, y: data.pos.y } : undefined, pos: data.pos ? { x: data.pos.x, y: data.pos.y } : undefined,
speed: data.speed ?? wpn?.speed ?? 10, speed: (data.speed ?? wpn?.speed ?? 10) / 2,
maxSpeed: data.maxSpeed ?? 2,
attack: data.attack ?? wpn?.damage ?? 30, attack: data.attack ?? wpn?.damage ?? 30,
weaponMagazines: data.weaponMagazines ? { ...data.weaponMagazines } : {}, weaponMagazines: data.weaponMagazines ? { ...data.weaponMagazines } : {},
itemCounts: data.itemCounts ? { ...data.itemCounts } : {}, itemCounts: data.itemCounts ? { ...data.itemCounts } : {},
@@ -244,6 +247,7 @@ function computeHit({ weaponId, skill, weaponConditions, bothMult = 1.0 }) {
* Get range for a weapon + skill combo. * Get range for a weapon + skill combo.
*/ */
function getSkillRange(weaponId, skillName) { function getSkillRange(weaponId, skillName) {
if (weaponId === 'left_arm' || weaponId === 'right_arm') return 12
const base = weaponMap[weaponId] const base = weaponMap[weaponId]
if (!base) return 15 if (!base) return 15
const skill = (base.skills ?? []).find(s => s.name === skillName) const skill = (base.skills ?? []).find(s => s.name === skillName)
@@ -380,12 +384,12 @@ export default function App() {
// ── world state // ── world state
const [player, setPlayer] = useState(createCharacter({ const [player, setPlayer] = useState(createCharacter({
id: 'player', name: 'You', age: 24, location: 'A', money: 50, id: 'player', name: 'You', age: 24, location: 'A', money: 50,
inventory: ['pistol_ammo'], inventory: ['pistol_ammo', 'bouncing_ammo'],
equipped: { left: 'pistol', right: 'rusty_sword' }, equipped: { left: 'pistol', right: 'rusty_sword' },
weaponConditions: { rusty_sword: 65, pistol: 100 }, weaponConditions: { rusty_sword: 65, pistol: 100 },
size: 1.3, size: 1.3,
weaponMagazines: { pistol: 2 }, weaponMagazines: { pistol: [{ ammoType: 'pistol_ammo' }, { ammoType: 'pistol_ammo' }] },
itemCounts: { pistol_ammo: 20 }, itemCounts: { pistol_ammo: 20, bouncing_ammo: 10 },
})) }))
const [gameTime, setGameTime] = useState({ day: 1, hour: 8 }) const [gameTime, setGameTime] = useState({ day: 1, hour: 8 })
const [weather, setWeather] = useState('clear') const [weather, setWeather] = useState('clear')
@@ -446,6 +450,8 @@ export default function App() {
// ── aiming mode (pistol skill selected, waiting for minimap click) // ── aiming mode (pistol skill selected, waiting for minimap click)
const [aimMode, setAimMode] = useState(false) const [aimMode, setAimMode] = useState(false)
// ── reload ammo picker removed; uses selectedSkill === 'Reload' inline
// ── projectiles (modular projectile framework) // ── projectiles (modular projectile framework)
const [projectiles, setProjectiles] = useState([]) const [projectiles, setProjectiles] = useState([])
@@ -462,10 +468,11 @@ export default function App() {
const displayTimeRef = useRef(0); displayTimeRef.current = displayTime const displayTimeRef = useRef(0); displayTimeRef.current = displayTime
const timerActiveRef = useRef(false) const timerActiveRef = useRef(false)
const [timerRunning, setTimerRunning] = useState(false) const [timerRunning, setTimerRunning] = useState(false)
const prevBusyRef = useRef(false)
const speedRef = useRef(1) const speedRef = useRef(1)
const [speedMult, setSpeedMult] = useState(1) const [speedMult, setSpeedMult] = useState(1)
const playerRef = useRef(null); playerRef.current = player const playerRef = useRef(null); playerRef.current = player
const magazineRef = useRef({ pistol: 2 }); magazineRef.current = player.weaponMagazines const magazineRef = useRef({ pistol: [{ ammoType: 'pistol_ammo' }, { ammoType: 'pistol_ammo' }] }); magazineRef.current = player.weaponMagazines
// ── Universal character action/direction/position refs ── // ── Universal character action/direction/position refs ──
// Every character (player, enemies) uses the same ref structure. // Every character (player, enemies) uses the same ref structure.
@@ -484,7 +491,6 @@ export default function App() {
const minimapPanStart = useRef({ x: 0, y: 0 }) const minimapPanStart = useRef({ x: 0, y: 0 })
const minimapMovedRef = useRef(false) const minimapMovedRef = useRef(false)
const replayElapsedRef = useRef(0) const replayElapsedRef = useRef(0)
const wasBusyRef = useRef(false)
// ───────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────
// DERIVED // DERIVED
@@ -552,7 +558,7 @@ export default function App() {
[PROJ_TYPES.BULLET]: { speed: 800, hitRadius: 10 }, [PROJ_TYPES.BULLET]: { speed: 800, hitRadius: 10 },
} }
function createProjectile(startPos, endPos, actor, weaponId, skillName) { function createProjectile(startPos, endPos, actor, weaponId, skillName, ammoType) {
const dx = endPos.x - startPos.x const dx = endPos.x - startPos.x
const dy = endPos.y - startPos.y const dy = endPos.y - startPos.y
const clickDist = Math.sqrt(dx * dx + dy * dy) || 1 const clickDist = Math.sqrt(dx * dx + dy * dy) || 1
@@ -569,6 +575,7 @@ export default function App() {
dir: { x: dx / clickDist, y: dy / clickDist }, dir: { x: dx / clickDist, y: dy / clickDist },
weaponId, weaponId,
skillName, skillName,
ammoType,
hitFired: false, hitFired: false,
} }
} }
@@ -597,6 +604,18 @@ export default function App() {
const cy = p.pos.y + p.dir.y * moveDist * t const cy = p.pos.y + p.dir.y * moveDist * t
if (MAP_BLOCKS.some(b => rectCollides(cx, cy, b))) { if (MAP_BLOCKS.some(b => rectCollides(cx, cy, b))) {
if (p.ammoType === 'bouncing_ammo') {
if (Math.abs(p.dir.x) > Math.abs(p.dir.y)) {
p.dir.x = -p.dir.x
} else {
p.dir.y = -p.dir.y
}
p.pos.x = cx - p.dir.x * moveDist * (1 - t)
p.pos.y = cy - p.dir.y * moveDist * (1 - t)
p.distTraveled = 0
p.dist -= moveDist * t
break
}
applyProjectileHit(p, null) applyProjectileHit(p, null)
hit = true; break hit = true; break
} }
@@ -819,7 +838,6 @@ export default function App() {
/** Queue a shot toward a map coordinate — queues a shoot action. */ /** Queue a shot toward a map coordinate — queues a shoot action. */
const queueShot = (tx, ty) => { const queueShot = (tx, ty) => {
if (!combat) return if (!combat) return
if (!timerActiveRef.current) { timerActiveRef.current = true; setTimerRunning(true) }
const startPos = characterPositionsRef.current[combat.playerEntityId] ?? combat.characters[combat.playerEntityId].pos const startPos = characterPositionsRef.current[combat.playerEntityId] ?? combat.characters[combat.playerEntityId].pos
characterActionsRef.current[combat.playerEntityId] = new AnimAction({ type: 'shoot', startPos: { x: startPos.x, y: startPos.y }, endPos: { x: tx, y: ty }, elapsed: 0 }) characterActionsRef.current[combat.playerEntityId] = new AnimAction({ type: 'shoot', startPos: { x: startPos.x, y: startPos.y }, endPos: { x: tx, y: ty }, elapsed: 0 })
setLimbSelect(null); setMoveMode(null); setAimMode(false); setSelectedSkill(null) setLimbSelect(null); setMoveMode(null); setAimMode(false); setSelectedSkill(null)
@@ -900,24 +918,37 @@ class MoveAction {
function applyAnimHit(anim) { function applyAnimHit(anim) {
const liveCc = combatRef.current const liveCc = combatRef.current
const attackerId = anim.charId
const targetId = anim.actor === 'player' ? anim.enemyCharId : liveCc?.playerEntityId
let inRange = false let inRange = false
if (liveCc) { if (liveCc) {
const attackerId = anim.charId // Primary: live positions from characterPositionsRef (updated by move actions)
const targetId = anim.actor === 'player' ? anim.enemyCharId : liveCc.playerEntityId const attackerPos = characterPositionsRef.current[attackerId] ?? liveCc.characters[attackerId]?.pos
const attacker = liveCc.characters[attackerId] const targetPos = characterPositionsRef.current[targetId] ?? liveCc.characters[targetId]?.pos
const target = liveCc.characters[targetId] if (attackerPos && targetPos) {
if (attacker && target) { inRange = canReach(attackerPos, targetPos, anim.weaponId, anim.skillName)
const range = anim.actor === 'player' }
? getSkillRange(anim.weaponId, anim.skillName) // Fallback: rendered combatRef positions
: 15 if (!inRange) {
inRange = canReach(attacker.pos, target.pos, anim.weaponId, anim.skillName) const a2 = liveCc.characters[attackerId]?.pos
const t2 = liveCc.characters[targetId]?.pos
if (a2 && t2) {
inRange = canReach(a2, t2, anim.weaponId, anim.skillName)
}
} }
} }
if (!inRange) { if (!inRange) {
const aPos = liveCc?.characters[attackerId]?.pos
const tPos = liveCc?.characters[targetId]?.pos
const aPos2 = characterPositionsRef.current[attackerId]
const tPos2 = characterPositionsRef.current[targetId]
const dist = aPos && tPos ? Math.sqrt((aPos.x - tPos.x)**2 + (aPos.y - tPos.y)**2) : -1
const dist2 = aPos2 && tPos2 ? Math.sqrt((aPos2.x - tPos2.x)**2 + (aPos2.y - tPos2.y)**2) : -1
const range = getSkillRange(anim.weaponId, anim.skillName)
const msg = anim.actor === 'player' const msg = anim.actor === 'player'
? 'Your attack misses!' ? `Your attack misses! (distance combatRef=${dist.toFixed(1)} refs=${dist2.toFixed(1)} range=${range})`
: `${liveCc?.characters[anim.charId]?.name ?? 'Enemy'}'s attack misses!` : `${liveCc?.characters[anim.charId]?.name ?? 'Enemy'}'s attack misses!`
setCombat(prev => ({ ...prev, log: [...prev.log, msg] })) setCombat(prev => ({ ...prev, log: [...prev.log, msg] }))
return return
@@ -1003,12 +1034,12 @@ class MoveAction {
const tick = () => { const tick = () => {
rafId = requestAnimationFrame(tick) rafId = requestAnimationFrame(tick)
const now = performance.now() const now = performance.now()
const rawDt = (now - lastTime) / 1000
const dt = rawDt * speedRef.current
lastTime = now
// ── Replay mode ── // ── Replay mode ──
if (replayActive) { if (replayActive) {
const rawDt = (now - lastTime) / 1000
const dt = rawDt * speedRef.current
lastTime = now
const rec = fightRecord const rec = fightRecord
if (rec) { if (rec) {
replayElapsedRef.current += dt replayElapsedRef.current += dt
@@ -1036,11 +1067,42 @@ class MoveAction {
return (performance.now() - prev.start) / 1000 >= 0.3 ? null : prev return (performance.now() - prev.start) / 1000 >= 0.3 ? null : prev
}) })
// ── Auto timer on busy state transitions; manual toggle via spacebar ──
{
const _pId = combatRef.current?.playerEntityId
const _busy = _pId ? characterActionsRef.current[_pId] !== null : false
const wasBusy = prevBusyRef.current
prevBusyRef.current = _busy
setBusy(_busy)
// Auto-start when player queues an action
if (_busy && !wasBusy && !timerActiveRef.current) {
timerActiveRef.current = true
setTimerRunning(true)
}
// Auto-stop when player's action completes
if (!_busy && wasBusy && timerActiveRef.current) {
timerActiveRef.current = false
setTimerRunning(false)
}
}
// ── Stop timer if player dies ──
if (timerActiveRef.current && isDead(playerRef.current.integrity)) {
stopTimer()
}
// dt is zero when the timer is off — animations don't play
if (!timerActiveRef.current) { if (!timerActiveRef.current) {
lastTime = now
setAnimations([]) setAnimations([])
return return
} }
const rawDt = (now - lastTime) / 1000
const dt = rawDt * speedRef.current
lastTime = now
setCombatTime(t => t + dt) setCombatTime(t => t + dt)
setDisplayTime(t => t + dt) setDisplayTime(t => t + dt)
@@ -1070,11 +1132,6 @@ class MoveAction {
if (cp) { rawAnim.cx = cp.x; rawAnim.cy = cp.y } if (cp) { rawAnim.cx = cp.x; rawAnim.cy = cp.y }
} }
// Track elapsed for attack anims
if (ANIM_CFG[rawAnim.type]) {
if (rawAnim.elapsed !== undefined) rawAnim.elapsed += dt
}
const done = rawAnim.step(dt) const done = rawAnim.step(dt)
if (ANIM_CFG[rawAnim.type] && rawAnim.elapsed >= ANIM_CFG[rawAnim.type].hitAt && !rawAnim.hitFired) { if (ANIM_CFG[rawAnim.type] && rawAnim.elapsed >= ANIM_CFG[rawAnim.type].hitAt && !rawAnim.hitFired) {
rawAnim.hitFired = true rawAnim.hitFired = true
@@ -1083,8 +1140,9 @@ class MoveAction {
const dir = characterDirsRef.current[cid] ?? { dx: 0, dy: 1 } const dir = characterDirsRef.current[cid] ?? { dx: 0, dy: 1 }
const offset = 5 const offset = 5
const spawnPos = { x: pp.x + dir.dx * offset, y: pp.y + dir.dy * offset } const spawnPos = { x: pp.x + dir.dx * offset, y: pp.y + dir.dy * offset }
if (cid === 'player' && magazineRef.current.pistol > 0) { if (cid === 'player' && magazineRef.current.pistol.length > 0) {
const newMag = { pistol: magazineRef.current.pistol - 1 } const bullet = magazineRef.current.pistol[0]
const newMag = { pistol: magazineRef.current.pistol.slice(1) }
magazineRef.current = newMag magazineRef.current = newMag
setPlayer(p => ({ ...p, weaponMagazines: { ...p.weaponMagazines, ...newMag } })) setPlayer(p => ({ ...p, weaponMagazines: { ...p.weaponMagazines, ...newMag } }))
const ddx = rawAnim.endPos.x - spawnPos.x const ddx = rawAnim.endPos.x - spawnPos.x
@@ -1098,7 +1156,8 @@ class MoveAction {
const cos = Math.cos(deviation), sin = Math.sin(deviation) const cos = Math.cos(deviation), sin = Math.sin(deviation)
const spreadDir = { x: dir.x * cos - dir.y * sin, y: dir.x * sin + dir.y * cos } const spreadDir = { x: dir.x * cos - dir.y * sin, y: dir.x * sin + dir.y * cos }
const finalDir = { x: isNaN(spreadDir.x) ? dir.x : spreadDir.x, y: isNaN(spreadDir.y) ? dir.y : spreadDir.y } const finalDir = { x: isNaN(spreadDir.x) ? dir.x : spreadDir.x, y: isNaN(spreadDir.y) ? dir.y : spreadDir.y }
setProjectiles(prev => [...prev, createProjectile(spawnPos, { x: spawnPos.x + finalDir.x * d, y: spawnPos.y + finalDir.y * d }, 'player', 'pistol', 'Shoot')]) const ammoType = bullet.ammoType
setProjectiles(prev => [...prev, createProjectile(spawnPos, { x: spawnPos.x + finalDir.x * d, y: spawnPos.y + finalDir.y * d }, 'player', 'pistol', 'Shoot', ammoType)])
} else if (cid === 'player') { } else if (cid === 'player') {
setCombat(c => ({ ...c, log: [...c.log, 'Click! The pistol is empty.'] })) setCombat(c => ({ ...c, log: [...c.log, 'Click! The pistol is empty.'] }))
} }
@@ -1116,12 +1175,12 @@ class MoveAction {
const dist = playerChar ? Math.sqrt( const dist = playerChar ? Math.sqrt(
(playerChar.pos.x - finalPos.x) ** 2 + (playerChar.pos.y - finalPos.y) ** 2 (playerChar.pos.x - finalPos.x) ** 2 + (playerChar.pos.y - finalPos.y) ** 2
) : 999 ) : 999
if (dist <= 15) {
const wpn = e.weapon ?? 'fists' const wpn = e.weapon ?? 'fists'
const base = weaponMap[wpn] const base = weaponMap[wpn]
const skill = (base?.skills ?? [{ name: 'Attack', mult: 1.0, damageType: 'blunt' }])[ const skill = (base?.skills ?? [{ name: 'Attack', mult: 1.0, damageType: 'blunt', range: 15 }])[
Math.floor(Math.random() * (base?.skills?.length ?? 1)) Math.floor(Math.random() * (base?.skills?.length ?? 1))
] ]
if (dist <= (skill.range ?? 15)) {
const { dmg, severity, injType, injLabel } = computeHit({ weaponId: wpn, skill, weaponConditions: {} }) const { dmg, severity, injType, injLabel } = computeHit({ weaponId: wpn, skill, weaponConditions: {} })
const targetPart = randomPart() const targetPart = randomPart()
const action = { const action = {
@@ -1154,7 +1213,18 @@ class MoveAction {
if (act.type === 'attack' && !characterActionsRef.current[act.charId]) { if (act.type === 'attack' && !characterActionsRef.current[act.charId]) {
characterActionsRef.current[act.charId] = createWeaponStrikeAnim(act, fresh) characterActionsRef.current[act.charId] = createWeaponStrikeAnim(act, fresh)
} else if (act.type === 'move' && !characterActionsRef.current[act.charId]) { } else if (act.type === 'move' && !characterActionsRef.current[act.charId]) {
characterActionsRef.current[act.charId] = new MoveAction({ const dx = act.toX - act.fromX, dy = act.toY - act.fromY
const d = Math.sqrt(dx * dx + dy * dy)
const dir = d > 0 ? { dx: dx / d, dy: dy / d } : { dx: 0, dy: 1 }
characterActionsRef.current[act.charId] = new SeqAction({
type: 'move',
actions: [
new TurnAction({
targetDir: dir,
getDir: () => characterDirsRef.current[act.charId] ?? { dx: 0, dy: 1 },
setDir: d2 => { characterDirsRef.current[act.charId] = d2 }
}),
new MoveAction({
...act, ...act,
setPos: p => { setPos: p => {
setCombat(c => ({ setCombat(c => ({
@@ -1164,6 +1234,8 @@ class MoveAction {
characterPositionsRef.current[act.charId] = p characterPositionsRef.current[act.charId] = p
} }
}) })
]
})
} }
} }
} }
@@ -1174,15 +1246,6 @@ class MoveAction {
// ── Update rendering state for active animations ── // ── Update rendering state for active animations ──
setAnimations(activeAnims) setAnimations(activeAnims)
// ── Auto-stop timer when ALL actions complete ──
const anyBusy = Object.values(characterActionsRef.current).some(a => a !== null)
if (wasBusyRef.current && !anyBusy) {
timerActiveRef.current = false; setTimerRunning(false)
}
wasBusyRef.current = anyBusy
setBusy(anyBusy)
} }
rafId = requestAnimationFrame(tick) rafId = requestAnimationFrame(tick)
@@ -1217,17 +1280,6 @@ class MoveAction {
// KEYBINDS // KEYBINDS
// ───────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────
useEffect(() => {
const handleKeyDown = (e) => {
if (e.code === 'Space' && combat) {
e.preventDefault()
toggleTimer()
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [combat])
// ───────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────
// SNAPSHOT (recording) // SNAPSHOT (recording)
// ───────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────
@@ -1369,7 +1421,7 @@ class MoveAction {
log: [`You engage ${enemies.map(e => e.name).join(' and ')} in combat!`], log: [`You engage ${enemies.map(e => e.name).join(' and ')} in combat!`],
} }
setCombat(initCombat) setCombat(initCombat)
timerActiveRef.current = false; setTimerRunning(false); setBusy(false) stopTimer(); setBusy(false); prevBusyRef.current = false
setCombatTime(0); setDisplayTime(0) setCombatTime(0); setDisplayTime(0)
characterActionsRef.current = {} characterActionsRef.current = {}
characterPositionsRef.current = {} characterPositionsRef.current = {}
@@ -1392,7 +1444,7 @@ class MoveAction {
const endFight = () => { const endFight = () => {
if (recordingRef.current) { pushSnapshot(); setFightRecord(recordingRef.current); recordingRef.current = null } if (recordingRef.current) { pushSnapshot(); setFightRecord(recordingRef.current); recordingRef.current = null }
timerActiveRef.current = false; setTimerRunning(false) stopTimer()
setCombatTime(0); setDisplayTime(0) setCombatTime(0); setDisplayTime(0)
characterActionsRef.current = {} characterActionsRef.current = {}
setProjectiles([]) setProjectiles([])
@@ -1404,17 +1456,29 @@ class MoveAction {
/** Execute a player attack; queue it for execution. */ /** Execute a player attack; queue it for execution. */
const executeAttack = () => { const executeAttack = () => {
if (!limbSelect || !selectedTarget) return if (!limbSelect || !selectedTarget) {
setCombat(c => ({ ...c, log: [...c.log, `DBG: early return limbSelect=${limbSelect} target=${selectedTarget}`] }))
return
}
const cc = combatRef.current const cc = combatRef.current
if (!cc) return if (!cc) { setCombat(c => ({ ...c, log: [...c.log, 'DBG: no combat'] })); return }
if (isDead(playerRef.current.integrity)) return if (isDead(playerRef.current.integrity)) { setCombat(c => ({ ...c, log: [...c.log, 'DBG: player dead'] })); return }
const ei = attackEnemy ?? selectedEnemy const ei = attackEnemy ?? selectedEnemy
const enemyId = cc.enemyIds[ei] const enemyId = cc.enemyIds[ei]
const enemy = cc.characters[enemyId] const enemy = cc.characters[enemyId]
if (!enemy || isDead(enemy.integrity)) return if (!enemy || isDead(enemy.integrity)) {
setCombat(c => ({ ...c, log: [...c.log, `DBG: bad enemy ei=${ei} id=${enemyId} enemy=${!!enemy} dead=${enemy ? isDead(enemy.integrity) : '?'}`] }))
return
}
const weaponId = limbSelect const weaponId = limbSelect
if (!canReach(cc.characters['player'].pos, enemy.pos, weaponId, selectedSkill)) return if (!canReach(cc.characters['player'].pos, enemy.pos, weaponId, selectedSkill)) {
const d = Math.sqrt(
(cc.characters['player'].pos.x - enemy.pos.x)**2 + (cc.characters['player'].pos.y - enemy.pos.y)**2
)
setCombat(c => ({ ...c, log: [...c.log, `Too far (${d.toFixed(0)} > ${getSkillRange(weaponId, selectedSkill)})`] }))
return
}
const action = buildPlayerAttack({ const action = buildPlayerAttack({
weaponId, skillName: selectedSkill, targetPart: selectedTarget, weaponId, skillName: selectedSkill, targetPart: selectedTarget,
@@ -1426,7 +1490,6 @@ class MoveAction {
const enemy2 = cc.characters[action.enemyCharId] const enemy2 = cc.characters[action.enemyCharId]
if (!enemy2 || isDead(enemy2.integrity)) return if (!enemy2 || isDead(enemy2.integrity)) return
const { pos, actualPush } = computeShove(cc.characters['player'].pos, enemy2.pos) const { pos, actualPush } = computeShove(cc.characters['player'].pos, enemy2.pos)
if (!timerActiveRef.current) { timerActiveRef.current = true; setTimerRunning(true) }
setCombat(c => ({ setCombat(c => ({
...c, ...c,
characters: { ...c.characters, [action.enemyCharId]: { ...enemy2, pos } }, characters: { ...c.characters, [action.enemyCharId]: { ...enemy2, pos } },
@@ -1436,7 +1499,6 @@ class MoveAction {
return return
} }
if (!timerActiveRef.current) { timerActiveRef.current = true; setTimerRunning(true) }
const anim = createWeaponStrikeAnim(action, combatRef.current) const anim = createWeaponStrikeAnim(action, combatRef.current)
characterActionsRef.current['player'] = anim characterActionsRef.current['player'] = anim
setLimbSelect(null); setSelectedSkill(null); setSelectedTarget(null); setAttackEnemy(null) setLimbSelect(null); setSelectedSkill(null); setSelectedTarget(null); setAttackEnemy(null)
@@ -1447,17 +1509,29 @@ class MoveAction {
setAimMode(false) setAimMode(false)
} }
const startTimer = () => {
if (!timerActiveRef.current) {
timerActiveRef.current = true
setTimerRunning(true)
}
}
const stopTimer = () => {
timerActiveRef.current = false
setTimerRunning(false)
}
const toggleTimer = () => { const toggleTimer = () => {
timerActiveRef.current = !timerActiveRef.current if (timerActiveRef.current) { stopTimer() } else { startTimer() }
setTimerRunning(timerActiveRef.current)
} }
// Escape cancels aim mode // Spacebar toggles timer, Escape cancels aim mode
useEffect(() => { useEffect(() => {
const onKey = (e) => { if (e.key === 'Escape' && aimMode) { setAimMode(false); setSelectedSkill(null) } } const onKey = (e) => {
if (e.code === 'Space' && combat) { e.preventDefault(); toggleTimer() }
if (e.key === 'Escape' && aimMode) { setAimMode(false); setSelectedSkill(null) }
}
window.addEventListener('keydown', onKey) window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey) return () => window.removeEventListener('keydown', onKey)
}, [aimMode]) }, [aimMode, combat])
/** Execute a player move; queue it for execution. */ /** Execute a player move; queue it for execution. */
const startMove = (targetX, targetY) => { const startMove = (targetX, targetY) => {
@@ -1465,16 +1539,15 @@ class MoveAction {
const curPos = characterPositionsRef.current['player'] ?? combat.characters['player'].pos const curPos = characterPositionsRef.current['player'] ?? combat.characters['player'].pos
const dist = Math.sqrt((targetX - curPos.x) ** 2 + (targetY - curPos.y) ** 2) const dist = Math.sqrt((targetX - curPos.x) ** 2 + (targetY - curPos.y) ** 2)
if (dist < 1) return if (dist < 1) return
const isRun = moveMode === 'run' const maxSpd = playerRef.current.maxSpeed ?? 10
const speed = isRun ? 1.5 : 1 const pct = moveMode === 'run' ? 1.0 : 0.5
if (!timerActiveRef.current) { timerActiveRef.current = true; setTimerRunning(true) } const speed = maxSpd * pct
const moveAction = new MoveAction({ type: 'move', fromX: curPos.x, fromY: curPos.y, toX: targetX, toY: targetY, speed, const moveAction = new MoveAction({ type: 'move', fromX: curPos.x, fromY: curPos.y, toX: targetX, toY: targetY, speed,
setPos: p => { setPos: p => {
setCombat(c => ({ ...c, characters: { ...c.characters, player: { ...c.characters.player, pos: { ...p } } } })) setCombat(c => ({ ...c, characters: { ...c.characters, player: { ...c.characters.player, pos: { ...p } } } }))
characterPositionsRef.current['player'] = p characterPositionsRef.current['player'] = p
} }
}) })
if (isRun) {
const dx = targetX - curPos.x, dy = targetY - curPos.y const dx = targetX - curPos.x, dy = targetY - curPos.y
const d = Math.sqrt(dx * dx + dy * dy) const d = Math.sqrt(dx * dx + dy * dy)
const dir = d > 0 ? { dx: dx / d, dy: dy / d } : { dx: 0, dy: 1 } const dir = d > 0 ? { dx: dx / d, dy: dy / d } : { dx: 0, dy: 1 }
@@ -1484,37 +1557,46 @@ class MoveAction {
moveAction moveAction
] ]
}) })
} else {
characterActionsRef.current['player'] = moveAction
}
setMoveMode(null) setMoveMode(null)
} }
const reloadPistol = () => { /** Show ammo selection UI based on what's in inventory */
const startReload = () => {
if (!combat) return if (!combat) return
const cur = magazineRef.current.pistol const cur = magazineRef.current.pistol.length
if (cur >= PISTOL_MAG) { if (cur >= PISTOL_MAG) {
setCombat(c => ({ ...c, log: [...c.log, 'The pistol is already fully loaded.'] })) setCombat(c => ({ ...c, log: [...c.log, 'The pistol is already fully loaded.'] }))
return return
} }
const reserve = playerRef.current.itemCounts?.pistol_ammo ?? 0 const available = Object.entries(playerRef.current.itemCounts ?? {})
if (reserve <= 0) { .filter(([id, qty]) => qty > 0 && (id === 'pistol_ammo' || id === 'bouncing_ammo'))
setCombat(c => ({ ...c, log: [...c.log, 'No pistol ammo left!'] })) if (available.length === 0) {
setCombat(c => ({ ...c, log: [...c.log, 'No ammo in inventory!'] }))
return return
} }
const newMag = { pistol: cur + 1 } setSelectedSkill('Reload'); setAttackEnemy(null); setSelectedTarget(null)
}
/** Load one round of the given ammo type into the weapon */
const loadAmmoType = (ammoId) => {
const cur = magazineRef.current.pistol.length
const reserve = playerRef.current.itemCounts?.[ammoId] ?? 0
if (reserve <= 0) return
const newMag = { pistol: [...magazineRef.current.pistol, { ammoType: ammoId }] }
magazineRef.current = newMag magazineRef.current = newMag
const ammoName = itemMap[ammoId]?.name ?? ammoId
setPlayer(p => { setPlayer(p => {
const newCount = p.itemCounts.pistol_ammo - 1 const newCount = (p.itemCounts[ammoId] ?? 0) - 1
const counts = { ...p.itemCounts, pistol_ammo: newCount } const counts = { ...p.itemCounts, [ammoId]: newCount }
if (newCount <= 0) delete counts.pistol_ammo if (newCount <= 0) delete counts[ammoId]
return { return {
...p, ...p,
weaponMagazines: { ...p.weaponMagazines, ...newMag }, weaponMagazines: { ...p.weaponMagazines, ...newMag },
itemCounts: counts, itemCounts: counts,
} }
}) })
setCombat(c => ({ ...c, log: [...c.log, 'Reloaded 1 round into the pistol.'] })) setCombat(c => ({ ...c, log: [...c.log, `Loaded 1 round of ${ammoName} into the pistol.`] }))
setLimbSelect(null); setSelectedSkill(null); setSelectedTarget(null); setAttackEnemy(null)
} }
// ───────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────
@@ -1572,12 +1654,13 @@ class MoveAction {
const buyItem = (item) => { const buyItem = (item) => {
if (player.money < item.price) return if (player.money < item.price) return
if (item.weaponId) { setBuyArmChoice({ item }) } if (item.weaponId) { setBuyArmChoice({ item }) }
else if (item.itemId === 'pistol_ammo') { else if (item.itemId === 'pistol_ammo' || item.itemId === 'bouncing_ammo') {
const id = item.itemId
setPlayer(p => ({ setPlayer(p => ({
...p, ...p,
money: p.money - item.price, money: p.money - item.price,
inventory: p.inventory.includes('pistol_ammo') ? p.inventory : [...p.inventory, 'pistol_ammo'], inventory: p.inventory.includes(id) ? p.inventory : [...p.inventory, id],
itemCounts: { ...p.itemCounts, pistol_ammo: (p.itemCounts?.pistol_ammo ?? 0) + 1 }, itemCounts: { ...p.itemCounts, [id]: (p.itemCounts?.[id] ?? 0) + 1 },
})) }))
} }
else { setPlayer(p => ({ ...p, money: p.money - item.price, inventory: [...p.inventory, item.itemId] })) } else { setPlayer(p => ({ ...p, money: p.money - item.price, inventory: [...p.inventory, item.itemId] })) }
@@ -1735,11 +1818,17 @@ class MoveAction {
<div className="tree-children"> <div className="tree-children">
{player.inventory.length === 0 {player.inventory.length === 0
? <div className="move-hint">No items</div> ? <div className="move-hint">No items</div>
: player.inventory.map((itemId, i) => ( : player.inventory.map((itemId, i) => {
const qty = player.itemCounts?.[itemId]
if (qty !== undefined && qty <= 0) return null
return (
<div key={i} className="tree-node leaf" onClick={() => { setShowInventory(true); setPanelTarget('player') }}> <div key={i} className="tree-node leaf" onClick={() => { setShowInventory(true); setPanelTarget('player') }}>
<span className="tree-bullet"></span><span className="tree-label">{itemMap[itemId]?.name ?? itemId}</span> <span className="tree-bullet"></span>
<span className="tree-label">{itemMap[itemId]?.name ?? itemId}</span>
{qty !== undefined && <span style={{ marginLeft: 6, color: '#9ca3af', fontSize: 12 }}>×{qty}</span>}
</div> </div>
)) )
})
} }
</div> </div>
)} )}
@@ -1821,7 +1910,6 @@ class MoveAction {
const dy = ty - pp.y const dy = ty - pp.y
const d = Math.sqrt(dx * dx + dy * dy) const d = Math.sqrt(dx * dx + dy * dy)
if (d > 0) { if (d > 0) {
if (!timerActiveRef.current) { timerActiveRef.current = true; setTimerRunning(true) }
characterActionsRef.current['player'] = new TurnAction({ targetDir: { dx: dx / d, dy: dy / d }, getDir: () => characterDirsRef.current['player'] ?? { dx: 0, dy: 1 }, setDir: d => { characterDirsRef.current['player'] = d } }) characterActionsRef.current['player'] = new TurnAction({ targetDir: { dx: dx / d, dy: dy / d }, getDir: () => characterDirsRef.current['player'] ?? { dx: 0, dy: 1 }, setDir: d => { characterDirsRef.current['player'] = d } })
setMoveMode(null) setMoveMode(null)
} }
@@ -2014,7 +2102,7 @@ class MoveAction {
<div style={{ position: 'relative', minHeight: 520 }}> <div style={{ position: 'relative', minHeight: 520 }}>
<div className="inv-parts-title limb-select-title" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> <div className="inv-parts-title limb-select-title" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span>{weaponName}</span> <span>{weaponName}</span>
{limbSelect === 'pistol' && <span style={{ fontSize: 13, color: '#9ca3af', fontWeight: 'normal' }}>Ammo: {player.weaponMagazines.pistol}/{PISTOL_MAG}</span>} {limbSelect === 'pistol' && <span style={{ fontSize: 13, color: '#9ca3af', fontWeight: 'normal' }}>Ammo: {player.weaponMagazines.pistol?.length ?? 0}/{PISTOL_MAG} <span style={{ color: '#fbbf24' }}>({itemMap[player.weaponMagazines.pistol?.[0]?.ammoType]?.name ?? 'none'})</span></span>}
</div> </div>
<div style={{ display: 'flex', gap: 16, alignItems: 'flex-start', paddingRight: 250 }}> <div style={{ display: 'flex', gap: 16, alignItems: 'flex-start', paddingRight: 250 }}>
{/* skill list */} {/* skill list */}
@@ -2025,7 +2113,7 @@ class MoveAction {
{skills.map((skill, i) => ( {skills.map((skill, i) => (
<button key={i} className={'limb-target-btn' + (selectedSkill === skill.name ? ' limb-target-selected' : '')} <button key={i} className={'limb-target-btn' + (selectedSkill === skill.name ? ' limb-target-selected' : '')}
onClick={() => { onClick={() => {
if (skill.name === 'Reload') { reloadPistol(); setLimbSelect(null); setSelectedSkill(null); return } if (skill.name === 'Reload') { startReload(); return }
setSelectedSkill(skill.name); setAttackEnemy(null) setSelectedSkill(skill.name); setAttackEnemy(null)
if (limbSelect === 'pistol') { setAimMode(true); setLimbSelect(null) } if (limbSelect === 'pistol') { setAimMode(true); setLimbSelect(null) }
}} }}
@@ -2036,8 +2124,32 @@ class MoveAction {
</div> </div>
</div> </div>
)} )}
{/* enemy list */} {/* reload: ammo selection */}
{selectedSkill && ( {selectedSkill === 'Reload' && (
<div>
<div className="inv-parts-title" style={{ marginBottom: 8 }}>Select Ammo Type</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{Object.entries(playerRef.current.itemCounts ?? {})
.filter(([id, qty]) => qty > 0 && (id === 'pistol_ammo' || id === 'bouncing_ammo'))
.length === 0
? <div style={{ color: '#6b7280', padding: 12 }}>No ammo available</div>
: Object.entries(playerRef.current.itemCounts ?? {})
.filter(([id, qty]) => qty > 0 && (id === 'pistol_ammo' || id === 'bouncing_ammo'))
.map(([id, qty]) => (
<div key={id} className="tree-node leaf" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '10px 12px', cursor: 'pointer', borderRadius: 3, marginBottom: 4 }}
onMouseEnter={e => e.currentTarget.style.background = '#1e1e30'}
onMouseLeave={e => e.currentTarget.style.background = 'none'}
onClick={() => loadAmmoType(id)}>
<span style={{ fontWeight: 'bold' }}>{itemMap[id]?.name ?? id}</span>
<span style={{ color: '#9ca3af' }}>×{qty}</span>
</div>
))
}
</div>
</div>
)}
{/* enemy list (hidden during reload) */}
{selectedSkill && selectedSkill !== 'Reload' && (
<div> <div>
<div className="inv-parts-title" style={{ marginBottom: 8 }}>Select Enemy</div> <div className="inv-parts-title" style={{ marginBottom: 8 }}>Select Enemy</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
@@ -2056,8 +2168,8 @@ class MoveAction {
</div> </div>
</div> </div>
)} )}
{/* limb selector */} {/* limb selector (hidden during reload) */}
{attackEnemy !== null && ( {selectedSkill !== 'Reload' && attackEnemy !== null && (
<div> <div>
<div className="inv-parts-title" style={{ marginBottom: 8 }}>Limb</div> <div className="inv-parts-title" style={{ marginBottom: 8 }}>Limb</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>