diff --git a/src/App.jsx b/src/App.jsx index d28fe15..0f39000 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -21,13 +21,13 @@ const connections = [ ] 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: '2', name: 'Rook', location: 'A', money: 0, inventory: ['pistol_ammo'], size: 1.2 }, - { id: '3', name: 'Vex', location: 'B', money: 500, inventory: ['pistol_ammo'], size: 0.9 }, + { 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: 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: 'd3', name: 'Sand Viper', location: 'D', money: 8, inventory: ['pistol_ammo'], size: 0.8 }, + { 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: 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 }, ] @@ -40,6 +40,7 @@ const shops = [ { name: 'Iron Shield', price: 50, weaponId: 'iron_shield' }, { name: 'Dagger', price: 30, weaponId: 'dagger' }, { name: 'Pistol Ammo', price: 5, itemId: 'pistol_ammo' }, + { name: 'Bouncing Ammo', price: 8, itemId: 'bouncing_ammo' }, ], }, ] @@ -55,7 +56,8 @@ const weapons = [ ] 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 = {} @@ -132,7 +134,8 @@ 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, - speed: data.speed ?? wpn?.speed ?? 10, + speed: (data.speed ?? wpn?.speed ?? 10) / 2, + maxSpeed: data.maxSpeed ?? 2, attack: data.attack ?? wpn?.damage ?? 30, weaponMagazines: data.weaponMagazines ? { ...data.weaponMagazines } : {}, itemCounts: data.itemCounts ? { ...data.itemCounts } : {}, @@ -244,6 +247,7 @@ function computeHit({ weaponId, skill, weaponConditions, bothMult = 1.0 }) { * Get range for a weapon + skill combo. */ function getSkillRange(weaponId, skillName) { + if (weaponId === 'left_arm' || weaponId === 'right_arm') return 12 const base = weaponMap[weaponId] if (!base) return 15 const skill = (base.skills ?? []).find(s => s.name === skillName) @@ -380,12 +384,12 @@ export default function App() { // ── world state const [player, setPlayer] = useState(createCharacter({ id: 'player', name: 'You', age: 24, location: 'A', money: 50, - inventory: ['pistol_ammo'], + inventory: ['pistol_ammo', 'bouncing_ammo'], equipped: { left: 'pistol', right: 'rusty_sword' }, weaponConditions: { rusty_sword: 65, pistol: 100 }, size: 1.3, - weaponMagazines: { pistol: 2 }, - itemCounts: { pistol_ammo: 20 }, + weaponMagazines: { pistol: [{ ammoType: 'pistol_ammo' }, { ammoType: 'pistol_ammo' }] }, + itemCounts: { pistol_ammo: 20, bouncing_ammo: 10 }, })) const [gameTime, setGameTime] = useState({ day: 1, hour: 8 }) const [weather, setWeather] = useState('clear') @@ -446,6 +450,8 @@ export default function App() { // ── aiming mode (pistol skill selected, waiting for minimap click) const [aimMode, setAimMode] = useState(false) + // ── reload ammo picker removed; uses selectedSkill === 'Reload' inline + // ── projectiles (modular projectile framework) const [projectiles, setProjectiles] = useState([]) @@ -462,10 +468,11 @@ export default function App() { const displayTimeRef = useRef(0); displayTimeRef.current = displayTime const timerActiveRef = useRef(false) const [timerRunning, setTimerRunning] = useState(false) + const prevBusyRef = useRef(false) const speedRef = useRef(1) const [speedMult, setSpeedMult] = useState(1) 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 ── // 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 minimapMovedRef = useRef(false) const replayElapsedRef = useRef(0) - const wasBusyRef = useRef(false) // ───────────────────────────────────────────────────────────────────────── // DERIVED @@ -552,7 +558,7 @@ export default function App() { [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 dy = endPos.y - startPos.y const clickDist = Math.sqrt(dx * dx + dy * dy) || 1 @@ -569,6 +575,7 @@ export default function App() { dir: { x: dx / clickDist, y: dy / clickDist }, weaponId, skillName, + ammoType, hitFired: false, } } @@ -597,6 +604,18 @@ export default function App() { const cy = p.pos.y + p.dir.y * moveDist * t 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) hit = true; break } @@ -819,7 +838,6 @@ export default function App() { /** Queue a shot toward a map coordinate — queues a shoot action. */ const queueShot = (tx, ty) => { if (!combat) return - if (!timerActiveRef.current) { timerActiveRef.current = true; setTimerRunning(true) } 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 }) setLimbSelect(null); setMoveMode(null); setAimMode(false); setSelectedSkill(null) @@ -900,24 +918,37 @@ class MoveAction { function applyAnimHit(anim) { const liveCc = combatRef.current + const attackerId = anim.charId + const targetId = anim.actor === 'player' ? anim.enemyCharId : liveCc?.playerEntityId let inRange = false if (liveCc) { - const attackerId = anim.charId - const targetId = anim.actor === 'player' ? anim.enemyCharId : liveCc.playerEntityId - const attacker = liveCc.characters[attackerId] - const target = liveCc.characters[targetId] - if (attacker && target) { - const range = anim.actor === 'player' - ? getSkillRange(anim.weaponId, anim.skillName) - : 15 - inRange = canReach(attacker.pos, target.pos, anim.weaponId, anim.skillName) + // Primary: live positions from characterPositionsRef (updated by move actions) + const attackerPos = characterPositionsRef.current[attackerId] ?? liveCc.characters[attackerId]?.pos + const targetPos = characterPositionsRef.current[targetId] ?? liveCc.characters[targetId]?.pos + if (attackerPos && targetPos) { + inRange = canReach(attackerPos, targetPos, anim.weaponId, anim.skillName) + } + // Fallback: rendered combatRef positions + if (!inRange) { + 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) { + 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' - ? '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!` setCombat(prev => ({ ...prev, log: [...prev.log, msg] })) return @@ -1003,12 +1034,12 @@ class MoveAction { const tick = () => { rafId = requestAnimationFrame(tick) const now = performance.now() - const rawDt = (now - lastTime) / 1000 - const dt = rawDt * speedRef.current - lastTime = now // ── Replay mode ── if (replayActive) { + const rawDt = (now - lastTime) / 1000 + const dt = rawDt * speedRef.current + lastTime = now const rec = fightRecord if (rec) { replayElapsedRef.current += dt @@ -1036,11 +1067,42 @@ class MoveAction { 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) { + lastTime = now setAnimations([]) return } + const rawDt = (now - lastTime) / 1000 + const dt = rawDt * speedRef.current + lastTime = now + setCombatTime(t => t + dt) setDisplayTime(t => t + dt) @@ -1070,11 +1132,6 @@ class MoveAction { 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) if (ANIM_CFG[rawAnim.type] && rawAnim.elapsed >= ANIM_CFG[rawAnim.type].hitAt && !rawAnim.hitFired) { rawAnim.hitFired = true @@ -1083,8 +1140,9 @@ class MoveAction { const dir = characterDirsRef.current[cid] ?? { dx: 0, dy: 1 } const offset = 5 const spawnPos = { x: pp.x + dir.dx * offset, y: pp.y + dir.dy * offset } - if (cid === 'player' && magazineRef.current.pistol > 0) { - const newMag = { pistol: magazineRef.current.pistol - 1 } + if (cid === 'player' && magazineRef.current.pistol.length > 0) { + const bullet = magazineRef.current.pistol[0] + const newMag = { pistol: magazineRef.current.pistol.slice(1) } magazineRef.current = newMag setPlayer(p => ({ ...p, weaponMagazines: { ...p.weaponMagazines, ...newMag } })) const ddx = rawAnim.endPos.x - spawnPos.x @@ -1098,7 +1156,8 @@ class MoveAction { 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 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') { setCombat(c => ({ ...c, log: [...c.log, 'Click! The pistol is empty.'] })) } @@ -1116,12 +1175,12 @@ class MoveAction { const dist = playerChar ? Math.sqrt( (playerChar.pos.x - finalPos.x) ** 2 + (playerChar.pos.y - finalPos.y) ** 2 ) : 999 - if (dist <= 15) { - const wpn = e.weapon ?? 'fists' - const base = weaponMap[wpn] - const skill = (base?.skills ?? [{ name: 'Attack', mult: 1.0, damageType: 'blunt' }])[ - Math.floor(Math.random() * (base?.skills?.length ?? 1)) - ] + const wpn = e.weapon ?? 'fists' + const base = weaponMap[wpn] + const skill = (base?.skills ?? [{ name: 'Attack', mult: 1.0, damageType: 'blunt', range: 15 }])[ + Math.floor(Math.random() * (base?.skills?.length ?? 1)) + ] + if (dist <= (skill.range ?? 15)) { const { dmg, severity, injType, injLabel } = computeHit({ weaponId: wpn, skill, weaponConditions: {} }) const targetPart = randomPart() const action = { @@ -1154,15 +1213,28 @@ class MoveAction { if (act.type === 'attack' && !characterActionsRef.current[act.charId]) { characterActionsRef.current[act.charId] = createWeaponStrikeAnim(act, fresh) } else if (act.type === 'move' && !characterActionsRef.current[act.charId]) { - characterActionsRef.current[act.charId] = new MoveAction({ - ...act, - setPos: p => { - setCombat(c => ({ - ...c, - characters: { ...c.characters, [act.charId]: { ...c.characters[act.charId], pos: { ...p } } } - })) - characterPositionsRef.current[act.charId] = p - } + 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, + setPos: p => { + setCombat(c => ({ + ...c, + characters: { ...c.characters, [act.charId]: { ...c.characters[act.charId], pos: { ...p } } } + })) + characterPositionsRef.current[act.charId] = p + } + }) + ] }) } } @@ -1174,15 +1246,6 @@ class MoveAction { // ── Update rendering state for active animations ── 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) @@ -1217,17 +1280,6 @@ class MoveAction { // 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) // ───────────────────────────────────────────────────────────────────────── @@ -1369,7 +1421,7 @@ class MoveAction { log: [`You engage ${enemies.map(e => e.name).join(' and ')} in combat!`], } setCombat(initCombat) - timerActiveRef.current = false; setTimerRunning(false); setBusy(false) + stopTimer(); setBusy(false); prevBusyRef.current = false setCombatTime(0); setDisplayTime(0) characterActionsRef.current = {} characterPositionsRef.current = {} @@ -1392,7 +1444,7 @@ class MoveAction { const endFight = () => { if (recordingRef.current) { pushSnapshot(); setFightRecord(recordingRef.current); recordingRef.current = null } - timerActiveRef.current = false; setTimerRunning(false) + stopTimer() setCombatTime(0); setDisplayTime(0) characterActionsRef.current = {} setProjectiles([]) @@ -1404,17 +1456,29 @@ class MoveAction { /** Execute a player attack; queue it for execution. */ 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 - if (!cc) return - if (isDead(playerRef.current.integrity)) return + if (!cc) { setCombat(c => ({ ...c, log: [...c.log, 'DBG: no combat'] })); return } + if (isDead(playerRef.current.integrity)) { setCombat(c => ({ ...c, log: [...c.log, 'DBG: player dead'] })); return } const ei = attackEnemy ?? selectedEnemy const enemyId = cc.enemyIds[ei] 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 - 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({ weaponId, skillName: selectedSkill, targetPart: selectedTarget, @@ -1426,7 +1490,6 @@ class MoveAction { const enemy2 = cc.characters[action.enemyCharId] if (!enemy2 || isDead(enemy2.integrity)) return const { pos, actualPush } = computeShove(cc.characters['player'].pos, enemy2.pos) - if (!timerActiveRef.current) { timerActiveRef.current = true; setTimerRunning(true) } setCombat(c => ({ ...c, characters: { ...c.characters, [action.enemyCharId]: { ...enemy2, pos } }, @@ -1436,7 +1499,6 @@ class MoveAction { return } - if (!timerActiveRef.current) { timerActiveRef.current = true; setTimerRunning(true) } const anim = createWeaponStrikeAnim(action, combatRef.current) characterActionsRef.current['player'] = anim setLimbSelect(null); setSelectedSkill(null); setSelectedTarget(null); setAttackEnemy(null) @@ -1447,17 +1509,29 @@ class MoveAction { setAimMode(false) } + const startTimer = () => { + if (!timerActiveRef.current) { + timerActiveRef.current = true + setTimerRunning(true) + } + } + const stopTimer = () => { + timerActiveRef.current = false + setTimerRunning(false) + } const toggleTimer = () => { - timerActiveRef.current = !timerActiveRef.current - setTimerRunning(timerActiveRef.current) + if (timerActiveRef.current) { stopTimer() } else { startTimer() } } - // Escape cancels aim mode + // Spacebar toggles timer, Escape cancels aim mode 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) return () => window.removeEventListener('keydown', onKey) - }, [aimMode]) + }, [aimMode, combat]) /** Execute a player move; queue it for execution. */ const startMove = (targetX, targetY) => { @@ -1465,56 +1539,64 @@ class MoveAction { const curPos = characterPositionsRef.current['player'] ?? combat.characters['player'].pos const dist = Math.sqrt((targetX - curPos.x) ** 2 + (targetY - curPos.y) ** 2) if (dist < 1) return - const isRun = moveMode === 'run' - const speed = isRun ? 1.5 : 1 - if (!timerActiveRef.current) { timerActiveRef.current = true; setTimerRunning(true) } + const maxSpd = playerRef.current.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, setPos: p => { setCombat(c => ({ ...c, characters: { ...c.characters, player: { ...c.characters.player, pos: { ...p } } } })) characterPositionsRef.current['player'] = p } }) - if (isRun) { - 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 } - characterActionsRef.current['player'] = new SeqAction({ - actions: [ - new TurnAction({ targetDir: dir, getDir: () => characterDirsRef.current['player'] ?? { dx: 0, dy: 1 }, setDir: d => { characterDirsRef.current['player'] = d } }), - moveAction - ] - }) - } else { - characterActionsRef.current['player'] = moveAction - } + 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 } + characterActionsRef.current['player'] = new SeqAction({ + actions: [ + new TurnAction({ targetDir: dir, getDir: () => characterDirsRef.current['player'] ?? { dx: 0, dy: 1 }, setDir: d => { characterDirsRef.current['player'] = d } }), + moveAction + ] + }) setMoveMode(null) } - const reloadPistol = () => { + /** Show ammo selection UI based on what's in inventory */ + const startReload = () => { if (!combat) return - const cur = magazineRef.current.pistol + const cur = magazineRef.current.pistol.length if (cur >= PISTOL_MAG) { setCombat(c => ({ ...c, log: [...c.log, 'The pistol is already fully loaded.'] })) return } - const reserve = playerRef.current.itemCounts?.pistol_ammo ?? 0 - if (reserve <= 0) { - setCombat(c => ({ ...c, log: [...c.log, 'No pistol ammo left!'] })) + const available = Object.entries(playerRef.current.itemCounts ?? {}) + .filter(([id, qty]) => qty > 0 && (id === 'pistol_ammo' || id === 'bouncing_ammo')) + if (available.length === 0) { + setCombat(c => ({ ...c, log: [...c.log, 'No ammo in inventory!'] })) 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 + const ammoName = itemMap[ammoId]?.name ?? ammoId setPlayer(p => { - const newCount = p.itemCounts.pistol_ammo - 1 - const counts = { ...p.itemCounts, pistol_ammo: newCount } - if (newCount <= 0) delete counts.pistol_ammo + const newCount = (p.itemCounts[ammoId] ?? 0) - 1 + const counts = { ...p.itemCounts, [ammoId]: newCount } + if (newCount <= 0) delete counts[ammoId] return { ...p, weaponMagazines: { ...p.weaponMagazines, ...newMag }, 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) => { if (player.money < item.price) return 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 => ({ ...p, money: p.money - item.price, - inventory: p.inventory.includes('pistol_ammo') ? p.inventory : [...p.inventory, 'pistol_ammo'], - itemCounts: { ...p.itemCounts, pistol_ammo: (p.itemCounts?.pistol_ammo ?? 0) + 1 }, + inventory: p.inventory.includes(id) ? p.inventory : [...p.inventory, id], + itemCounts: { ...p.itemCounts, [id]: (p.itemCounts?.[id] ?? 0) + 1 }, })) } else { setPlayer(p => ({ ...p, money: p.money - item.price, inventory: [...p.inventory, item.itemId] })) } @@ -1735,11 +1818,17 @@ class MoveAction {
{player.inventory.length === 0 ?
No items
- : player.inventory.map((itemId, i) => ( -
{ setShowInventory(true); setPanelTarget('player') }}> - {itemMap[itemId]?.name ?? itemId} -
- )) + : player.inventory.map((itemId, i) => { + const qty = player.itemCounts?.[itemId] + if (qty !== undefined && qty <= 0) return null + return ( +
{ setShowInventory(true); setPanelTarget('player') }}> + + {itemMap[itemId]?.name ?? itemId} + {qty !== undefined && ×{qty}} +
+ ) + }) }
)} @@ -1821,7 +1910,6 @@ class MoveAction { const dy = ty - pp.y const d = Math.sqrt(dx * dx + dy * dy) 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 } }) setMoveMode(null) } @@ -2014,7 +2102,7 @@ class MoveAction {
{weaponName} - {limbSelect === 'pistol' && Ammo: {player.weaponMagazines.pistol}/{PISTOL_MAG}} + {limbSelect === 'pistol' && Ammo: {player.weaponMagazines.pistol?.length ?? 0}/{PISTOL_MAG} ({itemMap[player.weaponMagazines.pistol?.[0]?.ammoType]?.name ?? 'none'})}
{/* skill list */} @@ -2025,7 +2113,7 @@ class MoveAction { {skills.map((skill, i) => (
)} - {/* enemy list */} - {selectedSkill && ( + {/* reload: ammo selection */} + {selectedSkill === 'Reload' && ( +
+
Select Ammo Type
+
+ {Object.entries(playerRef.current.itemCounts ?? {}) + .filter(([id, qty]) => qty > 0 && (id === 'pistol_ammo' || id === 'bouncing_ammo')) + .length === 0 + ?
No ammo available
+ : Object.entries(playerRef.current.itemCounts ?? {}) + .filter(([id, qty]) => qty > 0 && (id === 'pistol_ammo' || id === 'bouncing_ammo')) + .map(([id, qty]) => ( +
e.currentTarget.style.background = '#1e1e30'} + onMouseLeave={e => e.currentTarget.style.background = 'none'} + onClick={() => loadAmmoType(id)}> + {itemMap[id]?.name ?? id} + ×{qty} +
+ )) + } +
+
+ )} + {/* enemy list (hidden during reload) */} + {selectedSkill && selectedSkill !== 'Reload' && (
Select Enemy
@@ -2056,8 +2168,8 @@ class MoveAction {
)} - {/* limb selector */} - {attackEnemy !== null && ( + {/* limb selector (hidden during reload) */} + {selectedSkill !== 'Reload' && attackEnemy !== null && (
Limb