diff --git a/src/App.jsx b/src/App.jsx index 2409394..329ea28 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -2,6 +2,7 @@ import { useState, useRef, useCallback, useEffect, Fragment } from 'react' import './App.css' import maleBody from './assets/body/malebody.png' import pistolShotMp3 from './assets/pistolshot.mp3' +import chamberBulletMp3 from './assets/chamberbullet.mp3' // ───────────────────────────────────────────────────────────────────────────── // WORLD DATA @@ -96,7 +97,7 @@ function getItemValue(id) { const INJURY_DECAY = { cut: 0.3, laceration: 0.6, stab: 0.8 } const TURN_SPEED = 6 -const PISTOL_MAG = 2 +const PISTOL_MAG = 7 const bodyPartLabels = { head: 'Head', torso: 'Torso', @@ -112,6 +113,49 @@ function rectCollides(px, py, block) { return px >= block.x && px <= block.x + block.w && py >= block.y && py <= block.y + block.h } +function simulateBulletTrajectory(startX, startY, endX, endY, blocks, maxBounces = 5, maxDist) { + const dx = endX - startX + const dy = endY - startY + const dirLen = Math.sqrt(dx * dx + dy * dy) || 1 + const dir = { x: dx / dirLen, y: dy / dirLen } + const points = [{ x: startX, y: startY }] + let px = startX, py = startY + let cx = dir.x, cy = dir.y + let remaining = maxDist ?? dirLen + let bounces = 0 + while (remaining > 0) { + const step = Math.min(2, remaining) + const nx = px + cx * step + const ny = py + cy * step + const hitBlock = blocks.find(b => rectCollides(nx, ny, b)) + if (hitBlock) { + points.push({ x: nx, y: ny }) + bounces++ + if (bounces > maxBounces) break + const dl = nx - hitBlock.x + const dr = (hitBlock.x + hitBlock.w) - nx + const dt = ny - hitBlock.y + const db = (hitBlock.y + hitBlock.h) - ny + if (Math.min(dl, dr) < Math.min(dt, db)) { + cx = -cx + } else { + cy = -cy + } + remaining -= step + px = nx + cx + py = ny + cy + continue + } + px = nx + py = ny + remaining -= step + } + if (points[points.length - 1].x !== px || points[points.length - 1].y !== py) { + points.push({ x: px, y: py }) + } + return points +} + function partColor(hp, max) { const r = hp / max if (r > 0.6) return '#22c55e' @@ -465,12 +509,12 @@ export default function App() { // ── world state const [player, setPlayer] = useState(createCharacter({ id: 'player', name: 'John', age: 24, location: 'A', money: 50, - inventory: ['pistol_ammo', 'bouncing_ammo'], - equipped: { left: 'pistol', right: 'rusty_sword' }, - weaponConditions: { rusty_sword: 65, pistol: 100 }, + inventory: [], + equipped: { left: 'fists', right: 'fists' }, + weaponConditions: {}, size: 1.3, - weaponMagazines: { pistol: [{ ammoType: 'pistol_ammo' }, { ammoType: 'pistol_ammo' }] }, - itemCounts: { pistol_ammo: 20, bouncing_ammo: 10 }, + weaponMagazines: {}, + itemCounts: {}, })) const [gameTime, setGameTime] = useState({ day: 1, hour: 8, minute: 0 }) const [weather, setWeather] = useState('clear') @@ -534,6 +578,7 @@ export default function App() { const [busy, setBusy] = useState(false) // ── aiming mode (pistol skill selected, waiting for minimap click) + const [minimapHover, setMinimapHover] = useState(null) const [aimMode, setAimMode] = useState(false) // ── reload ammo picker removed; uses selectedSkill === 'Load' inline @@ -1675,18 +1720,19 @@ class MoveAction { } /** Enter a location — creates a combat view with all NPCs present as non-hostile */ - const enterLocation = (locationId) => { + const enterLocation = (locationId, playerData) => { + const pd = playerData ?? player const characters = {} characters['player'] = createCharacter({ - id: 'player', name: player.name, + id: 'player', name: pd.name, pos: { x: 20, y: 75 }, - integrity: { ...player.integrity }, - injuries: JSON.parse(JSON.stringify(player.injuries)), - equipped: { ...player.equipped }, - weaponConditions: { ...player.weaponConditions }, - weaponMagazines: { ...player.weaponMagazines }, - itemCounts: { ...player.itemCounts }, + integrity: { ...pd.integrity }, + injuries: JSON.parse(JSON.stringify(pd.injuries)), + equipped: { ...pd.equipped }, + weaponConditions: { ...pd.weaponConditions }, + weaponMagazines: { ...pd.weaponMagazines }, + itemCounts: { ...pd.itemCounts }, }) const partyCharIds = ['player'] @@ -1700,7 +1746,7 @@ class MoveAction { id: lc.id, name: lc.name, pos: { x: xOff, y: 130 }, integrity: { ...lc.integrity }, - injuries: JSON.parse(JSON.stringify(lc.injuries ?? player.injuries)), + injuries: JSON.parse(JSON.stringify(lc.injuries ?? pd.injuries)), equipped: { ...lc.equipped }, weaponConditions: { ...lc.weaponConditions }, weaponMagazines: { ...lc.weaponMagazines }, @@ -1740,8 +1786,11 @@ class MoveAction { setCombatTime(0); setDisplayTime(0) characterActionsRef.current = {} characterPositionsRef.current = {} + characterDirsRef.current = {} for (const cid of Object.keys(characters)) { + characterActionsRef.current[cid] = null characterPositionsRef.current[cid] = { ...characters[cid].pos } + characterDirsRef.current[cid] = { dx: 0, dy: 1 } } recordingRef.current = null; setFightRecord(null); setProjectiles([]); setHitShake(null) speedRef.current = 1; setSpeedMult(1) @@ -1935,58 +1984,59 @@ class MoveAction { const actorId = currentActor ?? 'player' const cc = combatRef.current const actorChar = cc?.characters[actorId] - const mag = actorId === 'player' ? magazineRef.current : (actorChar?.weaponMagazines ?? { pistol: [] }) const itemCounts = actorChar?.itemCounts ?? (actorId === 'player' ? playerRef.current.itemCounts : {}) - const cur = mag.pistol?.length ?? 0 const reserve = itemCounts[ammoId] ?? 0 if (reserve <= 0) return const ammoName = itemMap[ammoId]?.name ?? ammoId - if (actorId === 'player') { - const newMag = { pistol: [...magazineRef.current.pistol, { ammoType: ammoId }] } - magazineRef.current = newMag - setPlayer(p => { - 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(prev => { - const ch = prev.characters['player'] - const newCount = (ch?.itemCounts?.[ammoId] ?? 0) - 1 - const counts = { ...(ch?.itemCounts ?? {}), [ammoId]: newCount } - if (newCount <= 0) delete counts[ammoId] - return { - ...prev, - characters: { - ...prev.characters, - player: { ...ch, itemCounts: counts }, - }, - log: [...prev.log, `Loaded 1 round of ${ammoName} into the pistol.`], - } - }) - } else { - setCombat(prev => { - const ch = prev.characters[actorId] - if (!ch) return prev - const chMag = ch.weaponMagazines ?? { pistol: [] } - const newMag = { pistol: [...(chMag.pistol ?? []), { ammoType: ammoId }] } - const newCounts = { ...ch.itemCounts, [ammoId]: (ch.itemCounts?.[ammoId] ?? 0) - 1 } - if (newCounts[ammoId] <= 0) delete newCounts[ammoId] - return { - ...prev, - characters: { - ...prev.characters, - [actorId]: { ...ch, weaponMagazines: { ...ch.weaponMagazines, ...newMag }, itemCounts: newCounts }, - }, - log: [...prev.log, `${ch.name ?? 'Party member'} loaded 1 round of ${ammoName} into the pistol.`], - } - }) - } + new Audio(chamberBulletMp3).play().catch(() => {}) setLimbSelect(null); setSelectedSkill(null); setSelectedTarget(null); setAttackEnemy(null) + setTimeout(() => { + if (actorId === 'player') { + const newMag = { pistol: [...magazineRef.current.pistol, { ammoType: ammoId }] } + magazineRef.current = newMag + setPlayer(p => { + 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(prev => { + const ch = prev.characters['player'] + const newCount = (ch?.itemCounts?.[ammoId] ?? 0) - 1 + const counts = { ...(ch?.itemCounts ?? {}), [ammoId]: newCount } + if (newCount <= 0) delete counts[ammoId] + return { + ...prev, + characters: { + ...prev.characters, + player: { ...ch, itemCounts: counts }, + }, + log: [...prev.log, `Loaded 1 round of ${ammoName} into the pistol.`], + } + }) + } else { + setCombat(prev => { + const ch = prev.characters[actorId] + if (!ch) return prev + const chMag = ch.weaponMagazines ?? { pistol: [] } + const newMag = { pistol: [...(chMag.pistol ?? []), { ammoType: ammoId }] } + const newCounts = { ...ch.itemCounts, [ammoId]: (ch.itemCounts?.[ammoId] ?? 0) - 1 } + if (newCounts[ammoId] <= 0) delete newCounts[ammoId] + return { + ...prev, + characters: { + ...prev.characters, + [actorId]: { ...ch, weaponMagazines: { ...ch.weaponMagazines, ...newMag }, itemCounts: newCounts }, + }, + log: [...prev.log, `${ch.name ?? 'Party member'} loaded 1 round of ${ammoName} into the pistol.`], + } + }) + } + }, 500) } const unloadPistol = () => { @@ -2003,36 +2053,50 @@ class MoveAction { const top = mag.pistol[cur - 1] const ammoId = top.ammoType const ammoName = itemMap[ammoId]?.name ?? ammoId - if (actorId === 'player') { - const newMag = { pistol: mag.pistol.slice(0, -1) } - magazineRef.current = newMag - setPlayer(p => ({ - ...p, - weaponMagazines: { ...p.weaponMagazines, ...newMag }, - itemCounts: { ...p.itemCounts, [ammoId]: (p.itemCounts[ammoId] ?? 0) + 1 }, - })) - setCombat(c => ({ ...c, log: [...c.log, `Unloaded 1 round of ${ammoName} from the pistol.`] })) - } else { - setCombat(prev => { - const ch = prev.characters[actorId] - if (!ch) return prev - const chMag = ch.weaponMagazines ?? { pistol: [] } - const newMag = { pistol: (chMag.pistol ?? []).slice(0, -1) } - return { + new Audio(chamberBulletMp3).play().catch(() => {}) + setLimbSelect(null); setSelectedSkill(null); setSelectedTarget(null); setAttackEnemy(null) + setTimeout(() => { + if (actorId === 'player') { + const newMag = { pistol: mag.pistol.slice(0, -1) } + magazineRef.current = newMag + setPlayer(p => ({ + ...p, + weaponMagazines: { ...p.weaponMagazines, ...newMag }, + itemCounts: { ...p.itemCounts, [ammoId]: (p.itemCounts[ammoId] ?? 0) + 1 }, + })) + setCombat(prev => ({ ...prev, characters: { ...prev.characters, - [actorId]: { - ...ch, - weaponMagazines: { ...ch.weaponMagazines, ...newMag }, - itemCounts: { ...ch.itemCounts, [ammoId]: (ch.itemCounts?.[ammoId] ?? 0) + 1 }, + player: { + ...prev.characters.player, + weaponMagazines: { ...prev.characters.player?.weaponMagazines, ...newMag }, + itemCounts: { ...(prev.characters.player?.itemCounts ?? {}), [ammoId]: ((prev.characters.player?.itemCounts?.[ammoId] ?? 0) + 1) }, }, }, - log: [...prev.log, `${ch.name ?? 'Party member'} unloaded 1 round of ${ammoName} from the pistol.`], - } - }) - } - setLimbSelect(null); setSelectedSkill(null); setSelectedTarget(null); setAttackEnemy(null) + log: [...prev.log, `Unloaded 1 round of ${ammoName} from the pistol.`], + })) + } else { + setCombat(prev => { + const ch = prev.characters[actorId] + if (!ch) return prev + const chMag = ch.weaponMagazines ?? { pistol: [] } + const newMag = { pistol: (chMag.pistol ?? []).slice(0, -1) } + return { + ...prev, + characters: { + ...prev.characters, + [actorId]: { + ...ch, + weaponMagazines: { ...ch.weaponMagazines, ...newMag }, + itemCounts: { ...ch.itemCounts, [ammoId]: (ch.itemCounts?.[ammoId] ?? 0) + 1 }, + }, + }, + log: [...prev.log, `${ch.name ?? 'Party member'} unloaded 1 round of ${ammoName} from the pistol.`], + } + }) + } + }, 250) } // ───────────────────────────────────────────────────────────────────────── @@ -2163,7 +2227,7 @@ class MoveAction { if (showCharCreate) { return { const { name, weaponId, fillColor, locationId } = opts - setPlayer(createCharacter({ + const newPlayer = createCharacter({ id: 'player', name, age: 24, location: locationId, money: 50, inventory: weaponId === 'pistol' ? ['pistol_ammo', 'bouncing_ammo'] : [], equipped: { left: weaponId === 'pistol' ? 'pistol' : 'fists', right: weaponId === 'pistol' ? 'fists' : weaponId }, @@ -2172,9 +2236,11 @@ class MoveAction { weaponMagazines: weaponId === 'pistol' ? { pistol: [{ ammoType: 'pistol_ammo' }, { ammoType: 'pistol_ammo' }] } : {}, itemCounts: weaponId === 'pistol' ? { pistol_ammo: 20, bouncing_ammo: 10 } : {}, fillColor, - })) + }) + setPlayer(newPlayer) + magazineRef.current = newPlayer.weaponMagazines setShowCharCreate(false) - setTimeout(() => enterLocation(locationId), 50) + setTimeout(() => enterLocation(locationId, newPlayer), 50) }} /> } @@ -2398,6 +2464,14 @@ class MoveAction { const onUp = () => { minimapPanRef.current = false; window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp) } window.addEventListener('mousemove', onMove); window.addEventListener('mouseup', onUp) }} + onPointerMove={e => { + if (minimapPanRef.current) return + const r = e.currentTarget.getBoundingClientRect() + const mx = ((e.clientX - r.left) / r.width) * 160 / minimapView.scale + minimapView.x + const my = ((e.clientY - r.top) / r.height) * 160 / minimapView.scale + minimapView.y + setMinimapHover({ x: mx, y: my }) + }} + onPointerLeave={() => setMinimapHover(null)} onWheel={e => { if (limbSelect) return; e.preventDefault() const d = e.deltaY > 0 ? 0.9 : 1.1 @@ -2470,6 +2544,50 @@ class MoveAction { const rp = combat.characters[rangeActor]?.pos ?? combat.characters['player'].pos return })()} + {moveMode && moveMode !== 'turn' && minimapHover && combat && (() => { + const actorId = currentActor ?? 'player' + const ch = combat.characters[actorId] + if (!ch?.pos) return null + return ( + + ) + })()} + {aimMode && minimapHover && combat && (() => { + const actorId = currentActor ?? 'player' + const ch = combat.characters[actorId] + if (!ch?.pos) return null + const charMag = actorId === 'player' ? magazineRef.current : (ch.weaponMagazines ?? {}) + const currentAmmo = charMag.pistol?.[0]?.ammoType + const dx = minimapHover.x - ch.pos.x + const dy = minimapHover.y - ch.pos.y + const cursorDist = Math.sqrt(dx * dx + dy * dy) || 1 + const range = weaponMap.pistol?.skills?.find(s => s.name === 'Shoot')?.range ?? 60 + const travelDist = Math.max(cursorDist, range) + const maxBounces = currentAmmo === 'bouncing_ammo' ? 5 : 0 + const facing = characterDirsRef.current[actorId] ?? { dx: 0, dy: 1 } + const dot = Math.max(-1, Math.min(1, facing.dx * (dx / cursorDist) + facing.dy * (dy / cursorDist))) + const maxDeviation = (1 - dot) / 2 * (Math.PI / 4) + const cosA = Math.cos(maxDeviation), sinA = Math.sin(maxDeviation) + const spreadX1 = (dx / cursorDist) * cosA - (dy / cursorDist) * sinA + const spreadY1 = (dx / cursorDist) * sinA + (dy / cursorDist) * cosA + const spreadX2 = (dx / cursorDist) * cosA + (dy / cursorDist) * sinA + const spreadY2 = -(dx / cursorDist) * sinA + (dy / cursorDist) * cosA + const blocks = combat.blocks ?? [] + const trajectoryPts = simulateBulletTrajectory(ch.pos.x, ch.pos.y, minimapHover.x, minimapHover.y, blocks, maxBounces, travelDist) + const last1 = simulateBulletTrajectory(ch.pos.x, ch.pos.y, ch.pos.x + spreadX1 * travelDist, ch.pos.y + spreadY1 * travelDist, blocks, maxBounces, travelDist).at(-1) + const last2 = simulateBulletTrajectory(ch.pos.x, ch.pos.y, ch.pos.x + spreadX2 * travelDist, ch.pos.y + spreadY2 * travelDist, blocks, maxBounces, travelDist).at(-1) + const pathD = trajectoryPts.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`).join(' ') + return ( + <> + + + + + ) + })()} {/* Party member circles */} {combat && (() => { const partyCircles = combat.partyIds ?? [combat.playerEntityId].filter(Boolean) diff --git a/src/assets/chamberbullet.mp3 b/src/assets/chamberbullet.mp3 new file mode 100644 index 0000000..d80c7df Binary files /dev/null and b/src/assets/chamberbullet.mp3 differ