gun stuff combat stuff
This commit is contained in:
+141
-23
@@ -2,6 +2,7 @@ import { useState, useRef, useCallback, useEffect, Fragment } from 'react'
|
|||||||
import './App.css'
|
import './App.css'
|
||||||
import maleBody from './assets/body/malebody.png'
|
import maleBody from './assets/body/malebody.png'
|
||||||
import pistolShotMp3 from './assets/pistolshot.mp3'
|
import pistolShotMp3 from './assets/pistolshot.mp3'
|
||||||
|
import chamberBulletMp3 from './assets/chamberbullet.mp3'
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
// WORLD DATA
|
// WORLD DATA
|
||||||
@@ -96,7 +97,7 @@ function getItemValue(id) {
|
|||||||
|
|
||||||
const INJURY_DECAY = { cut: 0.3, laceration: 0.6, stab: 0.8 }
|
const INJURY_DECAY = { cut: 0.3, laceration: 0.6, stab: 0.8 }
|
||||||
const TURN_SPEED = 6
|
const TURN_SPEED = 6
|
||||||
const PISTOL_MAG = 2
|
const PISTOL_MAG = 7
|
||||||
|
|
||||||
const bodyPartLabels = {
|
const bodyPartLabels = {
|
||||||
head: 'Head', torso: 'Torso',
|
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
|
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) {
|
function partColor(hp, max) {
|
||||||
const r = hp / max
|
const r = hp / max
|
||||||
if (r > 0.6) return '#22c55e'
|
if (r > 0.6) return '#22c55e'
|
||||||
@@ -465,12 +509,12 @@ export default function App() {
|
|||||||
// ── world state
|
// ── world state
|
||||||
const [player, setPlayer] = useState(createCharacter({
|
const [player, setPlayer] = useState(createCharacter({
|
||||||
id: 'player', name: 'John', age: 24, location: 'A', money: 50,
|
id: 'player', name: 'John', age: 24, location: 'A', money: 50,
|
||||||
inventory: ['pistol_ammo', 'bouncing_ammo'],
|
inventory: [],
|
||||||
equipped: { left: 'pistol', right: 'rusty_sword' },
|
equipped: { left: 'fists', right: 'fists' },
|
||||||
weaponConditions: { rusty_sword: 65, pistol: 100 },
|
weaponConditions: {},
|
||||||
size: 1.3,
|
size: 1.3,
|
||||||
weaponMagazines: { pistol: [{ ammoType: 'pistol_ammo' }, { ammoType: 'pistol_ammo' }] },
|
weaponMagazines: {},
|
||||||
itemCounts: { pistol_ammo: 20, bouncing_ammo: 10 },
|
itemCounts: {},
|
||||||
}))
|
}))
|
||||||
const [gameTime, setGameTime] = useState({ day: 1, hour: 8, minute: 0 })
|
const [gameTime, setGameTime] = useState({ day: 1, hour: 8, minute: 0 })
|
||||||
const [weather, setWeather] = useState('clear')
|
const [weather, setWeather] = useState('clear')
|
||||||
@@ -534,6 +578,7 @@ export default function App() {
|
|||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
|
|
||||||
// ── aiming mode (pistol skill selected, waiting for minimap click)
|
// ── aiming mode (pistol skill selected, waiting for minimap click)
|
||||||
|
const [minimapHover, setMinimapHover] = useState(null)
|
||||||
const [aimMode, setAimMode] = useState(false)
|
const [aimMode, setAimMode] = useState(false)
|
||||||
|
|
||||||
// ── reload ammo picker removed; uses selectedSkill === 'Load' inline
|
// ── 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 */
|
/** 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 = {}
|
const characters = {}
|
||||||
|
|
||||||
characters['player'] = createCharacter({
|
characters['player'] = createCharacter({
|
||||||
id: 'player', name: player.name,
|
id: 'player', name: pd.name,
|
||||||
pos: { x: 20, y: 75 },
|
pos: { x: 20, y: 75 },
|
||||||
integrity: { ...player.integrity },
|
integrity: { ...pd.integrity },
|
||||||
injuries: JSON.parse(JSON.stringify(player.injuries)),
|
injuries: JSON.parse(JSON.stringify(pd.injuries)),
|
||||||
equipped: { ...player.equipped },
|
equipped: { ...pd.equipped },
|
||||||
weaponConditions: { ...player.weaponConditions },
|
weaponConditions: { ...pd.weaponConditions },
|
||||||
weaponMagazines: { ...player.weaponMagazines },
|
weaponMagazines: { ...pd.weaponMagazines },
|
||||||
itemCounts: { ...player.itemCounts },
|
itemCounts: { ...pd.itemCounts },
|
||||||
})
|
})
|
||||||
|
|
||||||
const partyCharIds = ['player']
|
const partyCharIds = ['player']
|
||||||
@@ -1700,7 +1746,7 @@ class MoveAction {
|
|||||||
id: lc.id, name: lc.name,
|
id: lc.id, name: lc.name,
|
||||||
pos: { x: xOff, y: 130 },
|
pos: { x: xOff, y: 130 },
|
||||||
integrity: { ...lc.integrity },
|
integrity: { ...lc.integrity },
|
||||||
injuries: JSON.parse(JSON.stringify(lc.injuries ?? player.injuries)),
|
injuries: JSON.parse(JSON.stringify(lc.injuries ?? pd.injuries)),
|
||||||
equipped: { ...lc.equipped },
|
equipped: { ...lc.equipped },
|
||||||
weaponConditions: { ...lc.weaponConditions },
|
weaponConditions: { ...lc.weaponConditions },
|
||||||
weaponMagazines: { ...lc.weaponMagazines },
|
weaponMagazines: { ...lc.weaponMagazines },
|
||||||
@@ -1740,8 +1786,11 @@ class MoveAction {
|
|||||||
setCombatTime(0); setDisplayTime(0)
|
setCombatTime(0); setDisplayTime(0)
|
||||||
characterActionsRef.current = {}
|
characterActionsRef.current = {}
|
||||||
characterPositionsRef.current = {}
|
characterPositionsRef.current = {}
|
||||||
|
characterDirsRef.current = {}
|
||||||
for (const cid of Object.keys(characters)) {
|
for (const cid of Object.keys(characters)) {
|
||||||
|
characterActionsRef.current[cid] = null
|
||||||
characterPositionsRef.current[cid] = { ...characters[cid].pos }
|
characterPositionsRef.current[cid] = { ...characters[cid].pos }
|
||||||
|
characterDirsRef.current[cid] = { dx: 0, dy: 1 }
|
||||||
}
|
}
|
||||||
recordingRef.current = null; setFightRecord(null); setProjectiles([]); setHitShake(null)
|
recordingRef.current = null; setFightRecord(null); setProjectiles([]); setHitShake(null)
|
||||||
speedRef.current = 1; setSpeedMult(1)
|
speedRef.current = 1; setSpeedMult(1)
|
||||||
@@ -1935,12 +1984,13 @@ class MoveAction {
|
|||||||
const actorId = currentActor ?? 'player'
|
const actorId = currentActor ?? 'player'
|
||||||
const cc = combatRef.current
|
const cc = combatRef.current
|
||||||
const actorChar = cc?.characters[actorId]
|
const actorChar = cc?.characters[actorId]
|
||||||
const mag = actorId === 'player' ? magazineRef.current : (actorChar?.weaponMagazines ?? { pistol: [] })
|
|
||||||
const itemCounts = actorChar?.itemCounts ?? (actorId === 'player' ? playerRef.current.itemCounts : {})
|
const itemCounts = actorChar?.itemCounts ?? (actorId === 'player' ? playerRef.current.itemCounts : {})
|
||||||
const cur = mag.pistol?.length ?? 0
|
|
||||||
const reserve = itemCounts[ammoId] ?? 0
|
const reserve = itemCounts[ammoId] ?? 0
|
||||||
if (reserve <= 0) return
|
if (reserve <= 0) return
|
||||||
const ammoName = itemMap[ammoId]?.name ?? ammoId
|
const ammoName = itemMap[ammoId]?.name ?? ammoId
|
||||||
|
new Audio(chamberBulletMp3).play().catch(() => {})
|
||||||
|
setLimbSelect(null); setSelectedSkill(null); setSelectedTarget(null); setAttackEnemy(null)
|
||||||
|
setTimeout(() => {
|
||||||
if (actorId === 'player') {
|
if (actorId === 'player') {
|
||||||
const newMag = { pistol: [...magazineRef.current.pistol, { ammoType: ammoId }] }
|
const newMag = { pistol: [...magazineRef.current.pistol, { ammoType: ammoId }] }
|
||||||
magazineRef.current = newMag
|
magazineRef.current = newMag
|
||||||
@@ -1986,7 +2036,7 @@ class MoveAction {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
setLimbSelect(null); setSelectedSkill(null); setSelectedTarget(null); setAttackEnemy(null)
|
}, 500)
|
||||||
}
|
}
|
||||||
|
|
||||||
const unloadPistol = () => {
|
const unloadPistol = () => {
|
||||||
@@ -2003,6 +2053,9 @@ class MoveAction {
|
|||||||
const top = mag.pistol[cur - 1]
|
const top = mag.pistol[cur - 1]
|
||||||
const ammoId = top.ammoType
|
const ammoId = top.ammoType
|
||||||
const ammoName = itemMap[ammoId]?.name ?? ammoId
|
const ammoName = itemMap[ammoId]?.name ?? ammoId
|
||||||
|
new Audio(chamberBulletMp3).play().catch(() => {})
|
||||||
|
setLimbSelect(null); setSelectedSkill(null); setSelectedTarget(null); setAttackEnemy(null)
|
||||||
|
setTimeout(() => {
|
||||||
if (actorId === 'player') {
|
if (actorId === 'player') {
|
||||||
const newMag = { pistol: mag.pistol.slice(0, -1) }
|
const newMag = { pistol: mag.pistol.slice(0, -1) }
|
||||||
magazineRef.current = newMag
|
magazineRef.current = newMag
|
||||||
@@ -2011,7 +2064,18 @@ class MoveAction {
|
|||||||
weaponMagazines: { ...p.weaponMagazines, ...newMag },
|
weaponMagazines: { ...p.weaponMagazines, ...newMag },
|
||||||
itemCounts: { ...p.itemCounts, [ammoId]: (p.itemCounts[ammoId] ?? 0) + 1 },
|
itemCounts: { ...p.itemCounts, [ammoId]: (p.itemCounts[ammoId] ?? 0) + 1 },
|
||||||
}))
|
}))
|
||||||
setCombat(c => ({ ...c, log: [...c.log, `Unloaded 1 round of ${ammoName} from the pistol.`] }))
|
setCombat(prev => ({
|
||||||
|
...prev,
|
||||||
|
characters: {
|
||||||
|
...prev.characters,
|
||||||
|
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, `Unloaded 1 round of ${ammoName} from the pistol.`],
|
||||||
|
}))
|
||||||
} else {
|
} else {
|
||||||
setCombat(prev => {
|
setCombat(prev => {
|
||||||
const ch = prev.characters[actorId]
|
const ch = prev.characters[actorId]
|
||||||
@@ -2032,7 +2096,7 @@ class MoveAction {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
setLimbSelect(null); setSelectedSkill(null); setSelectedTarget(null); setAttackEnemy(null)
|
}, 250)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
@@ -2163,7 +2227,7 @@ class MoveAction {
|
|||||||
if (showCharCreate) {
|
if (showCharCreate) {
|
||||||
return <CharacterCreator onConfirm={(opts) => {
|
return <CharacterCreator onConfirm={(opts) => {
|
||||||
const { name, weaponId, fillColor, locationId } = opts
|
const { name, weaponId, fillColor, locationId } = opts
|
||||||
setPlayer(createCharacter({
|
const newPlayer = createCharacter({
|
||||||
id: 'player', name, age: 24, location: locationId, money: 50,
|
id: 'player', name, age: 24, location: locationId, money: 50,
|
||||||
inventory: weaponId === 'pistol' ? ['pistol_ammo', 'bouncing_ammo'] : [],
|
inventory: weaponId === 'pistol' ? ['pistol_ammo', 'bouncing_ammo'] : [],
|
||||||
equipped: { left: weaponId === 'pistol' ? 'pistol' : 'fists', right: weaponId === 'pistol' ? 'fists' : weaponId },
|
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' }] } : {},
|
weaponMagazines: weaponId === 'pistol' ? { pistol: [{ ammoType: 'pistol_ammo' }, { ammoType: 'pistol_ammo' }] } : {},
|
||||||
itemCounts: weaponId === 'pistol' ? { pistol_ammo: 20, bouncing_ammo: 10 } : {},
|
itemCounts: weaponId === 'pistol' ? { pistol_ammo: 20, bouncing_ammo: 10 } : {},
|
||||||
fillColor,
|
fillColor,
|
||||||
}))
|
})
|
||||||
|
setPlayer(newPlayer)
|
||||||
|
magazineRef.current = newPlayer.weaponMagazines
|
||||||
setShowCharCreate(false)
|
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) }
|
const onUp = () => { minimapPanRef.current = false; window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp) }
|
||||||
window.addEventListener('mousemove', onMove); window.addEventListener('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 => {
|
onWheel={e => {
|
||||||
if (limbSelect) return; e.preventDefault()
|
if (limbSelect) return; e.preventDefault()
|
||||||
const d = e.deltaY > 0 ? 0.9 : 1.1
|
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
|
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" />
|
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 */}
|
{/* Party member circles */}
|
||||||
{combat && (() => {
|
{combat && (() => {
|
||||||
const partyCircles = combat.partyIds ?? [combat.playerEntityId].filter(Boolean)
|
const partyCircles = combat.partyIds ?? [combat.playerEntityId].filter(Boolean)
|
||||||
|
|||||||
Binary file not shown.
Reference in New Issue
Block a user