gun stuff combat stuff
This commit is contained in:
+208
-90
@@ -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 <CharacterCreator onConfirm={(opts) => {
|
||||
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 <circle cx={rp.x} cy={rp.y} r={getSkillRange(limbSelect, selectedSkill)} fill="none" stroke="rgba(52,152,219,0.5)" strokeWidth="1" strokeDasharray="4 4" />
|
||||
})()}
|
||||
{moveMode && moveMode !== 'turn' && minimapHover && combat && (() => {
|
||||
const actorId = currentActor ?? 'player'
|
||||
const ch = combat.characters[actorId]
|
||||
if (!ch?.pos) return null
|
||||
return (
|
||||
<line x1={ch.pos.x} y1={ch.pos.y} x2={minimapHover.x} y2={minimapHover.y}
|
||||
stroke="#22c55e" strokeWidth="1.5" strokeDasharray="3 3" opacity="0.7" />
|
||||
)
|
||||
})()}
|
||||
{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 (
|
||||
<>
|
||||
<path d={pathD} stroke="#ef4444" strokeWidth="1" strokeDasharray="3 3" fill="none" />
|
||||
<line x1={ch.pos.x} y1={ch.pos.y} x2={last1.x} y2={last1.y}
|
||||
stroke="#ef4444" strokeWidth="0.5" strokeDasharray="2 4" opacity="0.5" />
|
||||
<line x1={ch.pos.x} y1={ch.pos.y} x2={last2.x} y2={last2.y}
|
||||
stroke="#ef4444" strokeWidth="0.5" strokeDasharray="2 4" opacity="0.5" />
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
{/* Party member circles */}
|
||||
{combat && (() => {
|
||||
const partyCircles = combat.partyIds ?? [combat.playerEntityId].filter(Boolean)
|
||||
|
||||
Binary file not shown.
Reference in New Issue
Block a user