attack animations

This commit is contained in:
2026-06-04 20:36:26 -04:00
parent 4eca9df6cc
commit 56fd57efe5
+331 -121
View File
@@ -94,6 +94,7 @@ const MAP_BLOCKS = [
] ]
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 bodyPartLabels = { const bodyPartLabels = {
head: 'Head', torso: 'Torso', head: 'Head', torso: 'Torso',
@@ -166,11 +167,13 @@ function computeMove(from, to, speed) {
if (dist <= speed) return { x: to.x, y: to.y } if (dist <= speed) return { x: to.x, y: to.y }
const step = 0.5 const step = 0.5
const steps = Math.floor(speed / step) const totalSteps = Math.ceil(speed / step)
let cx = from.x, cy = from.y let cx = from.x, cy = from.y
for (let i = 0; i < steps; i++) { for (let i = 0; i < totalSteps; i++) {
const nx = cx + (dx / dist) * step const remaining = speed - i * step
const ny = cy + (dy / dist) * step const thisStep = Math.min(step, remaining)
const nx = cx + (dx / dist) * thisStep
const ny = cy + (dy / dist) * thisStep
if (MAP_BLOCKS.some(b => rectCollides(nx, ny, b))) break if (MAP_BLOCKS.some(b => rectCollides(nx, ny, b))) break
cx = nx; cy = ny cx = nx; cy = ny
} }
@@ -276,7 +279,7 @@ function buildPlayerAttack({ weaponId, skillName, targetPart, enemyIndex, equipp
* Run enemy AI. Returns an array of raw action objects. * Run enemy AI. Returns an array of raw action objects.
* Uses the same computeMove / computeHit as the player. * Uses the same computeMove / computeHit as the player.
*/ */
function runEnemyAI(combat, playerParts, playerInjuries) { function runEnemyAI(combat, playerParts, playerInjuries, enemyDirs) {
const actions = [] const actions = []
const moveTargets = {} const moveTargets = {}
@@ -316,6 +319,12 @@ function runEnemyAI(combat, playerParts, playerInjuries) {
const newPos = computeMove(e.pos, combat.playerPos, speed) const newPos = computeMove(e.pos, combat.playerPos, speed)
if (newPos.x !== e.pos.x || newPos.y !== e.pos.y) { if (newPos.x !== e.pos.x || newPos.y !== e.pos.y) {
moveTargets[i] = { fromX: e.pos.x, fromY: e.pos.y, toX: newPos.x, toY: newPos.y } moveTargets[i] = { fromX: e.pos.x, fromY: e.pos.y, toX: newPos.x, toY: newPos.y }
if (enemyDirs) {
const dx = newPos.x - e.pos.x
const dy = newPos.y - e.pos.y
const d = Math.sqrt(dx * dx + dy * dy)
if (d > 0) enemyDirs[i] = { dx: dx / d, dy: dy / d }
}
} }
} }
} }
@@ -410,7 +419,7 @@ export default function App() {
const [moveMode, setMoveMode] = useState(null) const [moveMode, setMoveMode] = useState(null)
// ── animation state // ── animation state
const [attackLerp, setAttackLerp] = useState({ player: null, enemies: {} }) const [animations, setAnimations] = useState([])
const [hitShake, setHitShake] = useState(null) const [hitShake, setHitShake] = useState(null)
const [minimapView, setMinimapView] = useState({ x: 0, y: 0, scale: 1 }) const [minimapView, setMinimapView] = useState({ x: 0, y: 0, scale: 1 })
@@ -447,6 +456,10 @@ export default function App() {
const equippedWeaponsRef = useRef(null); equippedWeaponsRef.current = equippedWeapons const equippedWeaponsRef = useRef(null); equippedWeaponsRef.current = equippedWeapons
// Tracks the latest enemy positions so AI reads fresh positions (not stale combatRef) // Tracks the latest enemy positions so AI reads fresh positions (not stale combatRef)
const positionsRef = useRef({}) const positionsRef = useRef({})
const playerDirRef = useRef({ dx: 0, dy: 1 })
const playerTargetDirRef = useRef({ dx: 0, dy: 1 })
const enemyDirsRef = useRef({})
const wasBusyRef = useRef(false)
// ───────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────
// DERIVED // DERIVED
@@ -469,31 +482,53 @@ export default function App() {
} }
// ───────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────
// TIMER — only ticks while timerActive is true // ANIMATION SYSTEM — modular & extensible
// ─────────────────────────────────────────────────────────────────────────
// To add a new animation type:
// 1. Add an entry to ANIM_TYPES
// 2. Add a config to ANIM_CFG { duration, hitAt }
// 3. Add a factory function that creates the animation object
// 4. Add a case to getAnimPos() for rendering
// 5. Call the factory when creating the animation
// ───────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────
const LUNGE_DIST = 18 const ANIM_TYPES = {
SLASH: 'slash',
PIERCE_STRIKE: 'pierce_strike',
}
function makeAttackAnim(action, cc) { const ANIM_CFG = {
[ANIM_TYPES.SLASH]: { duration: 0.3, hitAt: 0.15 },
[ANIM_TYPES.PIERCE_STRIKE]: { duration: 0.3, hitAt: 0.15 },
}
/**
* Factory: create a weapon animation based on the skill's damage type.
* slash → arcing motion, pierce → straight thrust, blunt → straight strike.
*/
function createWeaponStrikeAnim(action, cc) {
const isPlayer = action.actor === 'player' const isPlayer = action.actor === 'player'
const startPos = isPlayer const startPos = isPlayer
? { ...(cc?.playerPos ?? { x: 20, y: 75 }) } ? { ...(cc?.playerPos ?? { x: 20, y: 75 }) }
: { ...(cc?.enemies[action.enemyIndex]?.pos ?? { x: 20, y: 75 }) } : { ...(cc?.enemies[action.enemyIndex]?.pos ?? { x: 20, y: 75 }) }
const targetPos = isPlayer const endPos = isPlayer
? { ...(cc?.enemies[action.enemyIndex]?.pos ?? startPos) } ? { ...(cc?.enemies[action.enemyIndex]?.pos ?? startPos) }
: { ...(cc?.playerPos ?? startPos) } : { ...(cc?.playerPos ?? startPos) }
const dx = targetPos.x - startPos.x
const dy = targetPos.y - startPos.y const weapon = weaponMap[action.weaponId]
const dist = Math.sqrt(dx * dx + dy * dy) || 1 const skill = weapon?.skills?.find(s => s.name === action.skillName)
const lungeDist = Math.min(LUNGE_DIST, dist) const damageType = skill?.damageType ?? 'blunt'
const lungeTarget = { const type = damageType === 'slash' ? ANIM_TYPES.SLASH : ANIM_TYPES.PIERCE_STRIKE
x: startPos.x + (dx / dist) * lungeDist,
y: startPos.y + (dy / dist) * lungeDist,
}
return { return {
type: 'attack', id: `${action.actor}-${action.enemyIndex}-${Date.now()}`,
type,
actor: action.actor, actor: action.actor,
enemyIndex: action.enemyIndex, enemyIndex: action.enemyIndex,
startPos,
endPos,
elapsed: 0,
hitFired: false,
targetPart: action.targetPart, targetPart: action.targetPart,
weaponId: action.weaponId, weaponId: action.weaponId,
skillName: action.skillName, skillName: action.skillName,
@@ -505,38 +540,74 @@ export default function App() {
attackerInjuries: action.attackerInjuries, attackerInjuries: action.attackerInjuries,
targetParts: action.targetParts, targetParts: action.targetParts,
targetInjuries: action.targetInjuries, targetInjuries: action.targetInjuries,
startPos, lungeTarget,
elapsed: 0,
hitFired: false,
} }
} }
/** /**
* Advance an attack animation by dt seconds. Returns true when done. * Slash: arc toward the target (perpendicular offset).
*/ */
function tickAttack(anim, dt) { function getSlashPos(anim) {
const liveCc = combatRef.current const { hitAt, duration } = ANIM_CFG[ANIM_TYPES.SLASH]
if (anim.actor === 'player' && isDead(bodyPartsRef.current)) return true
if (anim.actor === 'enemy') {
const e = liveCc?.enemies[anim.enemyIndex]
if (!e || isDead(e.integrity)) { enemyActionsRef.current[anim.enemyIndex] = null; return true }
}
anim.elapsed += dt
const hitAt = 0.15
const returnAt = 0.30
const t = anim.elapsed < hitAt const t = anim.elapsed < hitAt
? anim.elapsed / hitAt ? anim.elapsed / hitAt
: anim.elapsed < returnAt : anim.elapsed < duration
? 1 - (anim.elapsed - hitAt) / (returnAt - hitAt) ? 1 - (anim.elapsed - hitAt) / (duration - hitAt)
: 0 : 0
setAttackLerp(prev => anim.actor === 'player' const dx = anim.endPos.x - anim.startPos.x
? { ...prev, player: { from: anim.startPos, to: anim.lungeTarget, t } } const dy = anim.endPos.y - anim.startPos.y
: { ...prev, enemies: { ...prev.enemies, [anim.enemyIndex]: { from: anim.startPos, to: anim.lungeTarget, t } } }) const dist = Math.sqrt(dx * dx + dy * dy) || 1
const nx = -dy / dist
const ny = dx / dist
const arcHeight = Math.sin(t * Math.PI) * 8
return {
x: anim.startPos.x + dx * t + nx * arcHeight,
y: anim.startPos.y + dy * t + ny * arcHeight,
}
}
if (anim.elapsed >= hitAt && !anim.hitFired) { /**
anim.hitFired = true * Pierce / blunt: straight thrust toward the target.
*/
function getPierceStrikePos(anim) {
const { hitAt, duration } = ANIM_CFG[ANIM_TYPES.PIERCE_STRIKE]
const t = anim.elapsed < hitAt
? anim.elapsed / hitAt
: anim.elapsed < duration
? 1 - (anim.elapsed - hitAt) / (duration - hitAt)
: 0
return {
x: anim.startPos.x + (anim.endPos.x - anim.startPos.x) * t,
y: anim.startPos.y + (anim.endPos.y - anim.startPos.y) * t,
}
}
/**
* Dispatch: get the current position of any animation type.
* Returns { x, y } or null if the type is unknown.
*/
function getAnimPos(anim) {
switch (anim?.type) {
case ANIM_TYPES.SLASH: return getSlashPos(anim)
case ANIM_TYPES.PIERCE_STRIKE: return getPierceStrikePos(anim)
default: return null
}
}
/**
* Tick a single animation by dt seconds. Returns true when the animation is done.
*/
function tickAnimation(anim, dt) {
const cfg = ANIM_CFG[anim?.type]
if (!cfg) return true
anim.elapsed += dt
return anim.elapsed >= cfg.duration
}
/**
* Apply the hit/damage logic when an animation reaches its hitAt time.
*/
function applyAnimHit(anim) {
const liveCc = combatRef.current
let inRange = false let inRange = false
if (liveCc) { if (liveCc) {
@@ -558,22 +629,24 @@ export default function App() {
? 'Your attack misses!' ? 'Your attack misses!'
: `${liveCc?.enemies[anim.enemyIndex]?.name ?? 'Enemy'}'s attack misses!` : `${liveCc?.enemies[anim.enemyIndex]?.name ?? 'Enemy'}'s attack misses!`
setCombat(prev => ({ ...prev, log: [...prev.log, msg] })) setCombat(prev => ({ ...prev, log: [...prev.log, msg] }))
} else { return
}
setHitShake({ actor: anim.actor, enemyIndex: anim.enemyIndex, start: performance.now() }) setHitShake({ actor: anim.actor, enemyIndex: anim.enemyIndex, start: performance.now() })
if (anim.actor === 'player') { if (anim.actor === 'player') {
// ── Player attacking enemy ──
const wid = anim.weaponId const wid = anim.weaponId
if (wid !== 'fists' && wid !== 'left_arm' && wid !== 'right_arm') { if (wid !== 'fists' && wid !== 'left_arm' && wid !== 'right_arm') {
const base = weaponMap[wid] const base = weaponMap[wid]
setWeaponConditions(prev => ({ ...prev, [wid]: Math.max(0, (prev[wid] ?? base.maxCondition) - (1 + Math.floor(Math.random() * 2))) })) setWeaponConditions(prev => ({ ...prev, [wid]: Math.max(0, (prev[wid] ?? base.maxCondition) - (1 + Math.floor(Math.random() * 2))) }))
} }
const decayedPlayer = calcIntegrity(bodyPartsRef.current, bodyInjuriesRef.current, hitAt) const decayedPlayer = calcIntegrity(bodyPartsRef.current, bodyInjuriesRef.current, ANIM_CFG[anim.type].hitAt)
if (liveCc) { if (liveCc) {
const enemy = liveCc.enemies[anim.enemyIndex] const enemy = liveCc.enemies[anim.enemyIndex]
if (enemy) { if (enemy) {
const decayedEnemy = calcIntegrity(enemy.integrity, enemy.injuries, hitAt) const decayedEnemy = calcIntegrity(enemy.integrity, enemy.injuries, ANIM_CFG[anim.type].hitAt)
const prevHp = decayedEnemy[anim.targetPart] const prevHp = decayedEnemy[anim.targetPart]
const hitEnemy = { ...decayedEnemy, [anim.targetPart]: Math.max(0, prevHp - anim.dmg) } const hitEnemy = { ...decayedEnemy, [anim.targetPart]: Math.max(0, prevHp - anim.dmg) }
const newInjuries = prevHp > 0 && anim.severity > 0 const newInjuries = prevHp > 0 && anim.severity > 0
@@ -599,12 +672,11 @@ export default function App() {
setBodyParts(decayedPlayer) setBodyParts(decayedPlayer)
setBodyInjuries(bodyInjuriesRef.current) setBodyInjuries(bodyInjuriesRef.current)
} else { } else {
// ── Enemy attacking player ──
const currentEnemy = liveCc?.enemies[anim.enemyIndex] const currentEnemy = liveCc?.enemies[anim.enemyIndex]
const decayedEnemy = currentEnemy const decayedEnemy = currentEnemy
? calcIntegrity(currentEnemy.integrity, currentEnemy.injuries, hitAt) ? calcIntegrity(currentEnemy.integrity, currentEnemy.injuries, ANIM_CFG[anim.type].hitAt)
: calcIntegrity(anim.attackerParts, anim.attackerInjuries, hitAt) : calcIntegrity(anim.attackerParts, anim.attackerInjuries, ANIM_CFG[anim.type].hitAt)
const decayedPlayer = calcIntegrity(bodyPartsRef.current, bodyInjuriesRef.current, hitAt) const decayedPlayer = calcIntegrity(bodyPartsRef.current, bodyInjuriesRef.current, ANIM_CFG[anim.type].hitAt)
const prevHp = decayedPlayer[anim.targetPart] const prevHp = decayedPlayer[anim.targetPart]
const hitPlayer = { ...decayedPlayer, [anim.targetPart]: Math.max(0, prevHp - anim.dmg) } const hitPlayer = { ...decayedPlayer, [anim.targetPart]: Math.max(0, prevHp - anim.dmg) }
const newInjuries = prevHp > 0 && anim.severity > 0 const newInjuries = prevHp > 0 && anim.severity > 0
@@ -628,26 +700,33 @@ export default function App() {
setBodyInjuries(newInjuries) setBodyInjuries(newInjuries)
} }
} }
}
if (anim.elapsed >= 0.45) {
setAttackLerp(prev => anim.actor === 'player'
? { ...prev, player: null }
: { ...prev, enemies: { ...prev.enemies, [anim.enemyIndex]: undefined } })
setHitShake(null)
return true
}
return false
}
/** /**
* Advance a player move by dt seconds. Returns true when done/blocked. * Advance a player move by dt seconds. Returns true when done/blocked.
*/ */
/**
* Advance a player turn by dt. Returns true when facing the target.
*/
function tickTurn() {
const dir = playerDirRef.current
const tgt = playerTargetDirRef.current
return dir.dx === tgt.dx && dir.dy === tgt.dy
}
function tickPlayerMove(anim, dt) { function tickPlayerMove(anim, dt) {
const mdx = anim.toX - anim.fromX
const mdy = anim.toY - anim.fromY
const mdist = Math.sqrt(mdx * mdx + mdy * mdy)
const moveDir = mdist > 0 ? { dx: mdx / mdist, dy: mdy / mdist } : { dx: 0, dy: 1 }
const facing = playerDirRef.current
const dot = Math.max(-1, Math.min(1, facing.dx * moveDir.dx + facing.dy * moveDir.dy))
const speedMult = 0.7 + 0.3 * dot
const speed = anim.speed * speedMult
const steps = Math.max(1, Math.round(dt * 60)) const steps = Math.max(1, Math.round(dt * 60))
let cf = { x: anim.fromX, y: anim.fromY } let cf = { x: anim.fromX, y: anim.fromY }
for (let i = 0; i < steps; i++) { for (let i = 0; i < steps; i++) {
const p = computeMove(cf, { x: anim.toX, y: anim.toY }, anim.speed) const p = computeMove(cf, { x: anim.toX, y: anim.toY }, speed)
if (p.x === anim.toX && p.y === anim.toY) { if (p.x === anim.toX && p.y === anim.toY) {
setCombat(c => ({ ...c, playerPos: p })); positionsRef.current.player = p; return true setCombat(c => ({ ...c, playerPos: p })); positionsRef.current.player = p; return true
} }
@@ -693,29 +772,60 @@ export default function App() {
const dt = (now - lastTime) / 1000 const dt = (now - lastTime) / 1000
lastTime = now lastTime = now
if (!timerActiveRef.current) return // ── Smooth turn player toward target direction ──
const curDir = playerDirRef.current
const tgtDir = playerTargetDirRef.current
if (curDir.dx !== tgtDir.dx || curDir.dy !== tgtDir.dy) {
const curAngle = Math.atan2(curDir.dy, curDir.dx)
const tgtAngle = Math.atan2(tgtDir.dy, tgtDir.dx)
let delta = tgtAngle - curAngle
while (delta > Math.PI) delta -= 2 * Math.PI
while (delta < -Math.PI) delta += 2 * Math.PI
const maxStep = TURN_SPEED * dt
if (Math.abs(delta) <= maxStep) {
playerDirRef.current = { dx: tgtDir.dx, dy: tgtDir.dy }
} else {
const newAngle = curAngle + Math.sign(delta) * maxStep
playerDirRef.current = { dx: Math.cos(newAngle), dy: Math.sin(newAngle) }
}
}
setCombatTime(t => t + dt) // ── Effects (run every frame, independent of timer) ──
setDisplayTime(t => t + dt)
// ── Auto-dismiss hitShake after 0.3s ──
setHitShake(prev => { setHitShake(prev => {
if (!prev) return prev if (!prev) return prev
return (performance.now() - prev.start) / 1000 >= 0.3 ? null : prev return (performance.now() - prev.start) / 1000 >= 0.3 ? null : prev
}) })
// ── Update player action ── if (!timerActiveRef.current) {
if (playerActionRef.current) { setAnimations([])
const anim = playerActionRef.current return
const done = anim.type === 'attack'
? tickAttack(anim, dt)
: anim.type === 'move'
? tickPlayerMove(anim, dt)
: true
if (done) playerActionRef.current = null
} }
// ── Update enemy actions ── setCombatTime(t => t + dt)
setDisplayTime(t => t + dt)
// ── Tick player action ──
const activeAnims = []
if (playerActionRef.current) {
const rawAnim = playerActionRef.current
const done = rawAnim.type === 'move'
? tickPlayerMove(rawAnim, dt)
: rawAnim.type === 'turn'
? tickTurn()
: tickAnimation(rawAnim, dt)
if (!done && ANIM_CFG[rawAnim.type] && rawAnim.elapsed >= ANIM_CFG[rawAnim.type].hitAt && !rawAnim.hitFired) {
rawAnim.hitFired = true
applyAnimHit(rawAnim)
}
if (done) {
playerActionRef.current = null
setHitShake(null)
} else if (ANIM_CFG[rawAnim.type]) {
activeAnims.push(rawAnim)
}
}
// ── Tick enemy actions ──
const cc = combatRef.current const cc = combatRef.current
const fresh = cc ? (() => { const fresh = cc ? (() => {
const pp = positionsRef.current.player const pp = positionsRef.current.player
@@ -730,17 +840,19 @@ export default function App() {
})() : null })() : null
if (cc) { if (cc) {
for (let i = 0; i < cc.enemies.length; i++) { for (let i = 0; i < cc.enemies.length; i++) {
const anim = enemyActionsRef.current[i] const rawAnim = enemyActionsRef.current[i]
if (!anim) continue if (!rawAnim) continue
const done = anim.type === 'attack' const done = rawAnim.type === 'enemyMove'
? tickAttack(anim, dt) ? tickEnemyMove(rawAnim, dt)
: anim.type === 'enemyMove' : tickAnimation(rawAnim, dt)
? tickEnemyMove(anim, dt) if (!done && ANIM_CFG[rawAnim.type] && rawAnim.elapsed >= ANIM_CFG[rawAnim.type].hitAt && !rawAnim.hitFired) {
: true rawAnim.hitFired = true
applyAnimHit(rawAnim)
}
if (done) { if (done) {
if (anim.type === 'enemyMove') { if (rawAnim.type === 'enemyMove') {
const e = fresh?.enemies[i] ?? cc.enemies[i] const e = fresh?.enemies[i] ?? cc.enemies[i]
const finalPos = { x: anim.targets[i].toX, y: anim.targets[i].toY } const finalPos = { x: rawAnim.targets[i].toX, y: rawAnim.targets[i].toY }
if (e && !isDead(e.integrity)) { if (e && !isDead(e.integrity)) {
const dist = Math.sqrt( const dist = Math.sqrt(
((fresh ?? cc).playerPos.x - finalPos.x) ** 2 + ((fresh ?? cc).playerPos.y - finalPos.y) ** 2 ((fresh ?? cc).playerPos.x - finalPos.x) ** 2 + ((fresh ?? cc).playerPos.y - finalPos.y) ** 2
@@ -762,22 +874,24 @@ export default function App() {
targetParts: { ...bodyPartsRef.current }, targetParts: { ...bodyPartsRef.current },
targetInjuries: { ...bodyInjuriesRef.current }, targetInjuries: { ...bodyInjuriesRef.current },
} }
enemyActionsRef.current[i] = makeAttackAnim(action, fresh) enemyActionsRef.current[i] = createWeaponStrikeAnim(action, fresh)
continue continue
} }
} }
} }
enemyActionsRef.current[i] = null enemyActionsRef.current[i] = null
} else {
activeAnims.push(rawAnim)
} }
} }
} }
// ── Run enemy AI every frame for idle enemies ── // ── Run enemy AI every frame for idle enemies ──
if (fresh) { if (fresh) {
const actions = runEnemyAI(fresh, bodyPartsRef.current, bodyInjuriesRef.current) const actions = runEnemyAI(fresh, bodyPartsRef.current, bodyInjuriesRef.current, enemyDirsRef.current)
for (const act of actions) { for (const act of actions) {
if (act.type === 'attack' && !enemyActionsRef.current[act.enemyIndex]) { if (act.type === 'attack' && !enemyActionsRef.current[act.enemyIndex]) {
enemyActionsRef.current[act.enemyIndex] = makeAttackAnim(act, fresh) enemyActionsRef.current[act.enemyIndex] = createWeaponStrikeAnim(act, fresh)
} else if (act.type === 'enemyMove') { } else if (act.type === 'enemyMove') {
for (const idxStr of Object.keys(act.targets)) { for (const idxStr of Object.keys(act.targets)) {
const idx = parseInt(idxStr) const idx = parseInt(idxStr)
@@ -792,12 +906,16 @@ export default function App() {
} }
} }
// ── Pause timer when player is idle; enemy actions freeze mid-state ── // ── Update rendering state for active animations ──
setAnimations(activeAnims)
// ── Auto-stop timer when an action just completed ──
const playerBusy = playerActionRef.current !== null const playerBusy = playerActionRef.current !== null
if (!playerBusy) { if (wasBusyRef.current && !playerBusy) {
timerActiveRef.current = false timerActiveRef.current = false
setTimerRunning(false) setTimerRunning(false)
} }
wasBusyRef.current = playerBusy
setBusy(playerBusy) setBusy(playerBusy)
} }
@@ -829,6 +947,21 @@ export default function App() {
if (isDead(bodyParts) || !combat.enemies.some(e => !isDead(e.integrity))) endFight() if (isDead(bodyParts) || !combat.enemies.some(e => !isDead(e.integrity))) endFight()
}, [combat, bodyParts, playerHp]) }, [combat, bodyParts, playerHp])
// ─────────────────────────────────────────────────────────────────────────
// 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)
// ───────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────
@@ -840,7 +973,7 @@ export default function App() {
combat: JSON.parse(JSON.stringify(combat)), combat: JSON.parse(JSON.stringify(combat)),
bodyParts: JSON.parse(JSON.stringify(bodyParts)), bodyParts: JSON.parse(JSON.stringify(bodyParts)),
bodyInjuries: JSON.parse(JSON.stringify(bodyInjuries)), bodyInjuries: JSON.parse(JSON.stringify(bodyInjuries)),
attackLerp: JSON.parse(JSON.stringify(attackLerp)), animations: JSON.parse(JSON.stringify(animations)),
hitShake: hitShake ? { ...hitShake } : null, hitShake: hitShake ? { ...hitShake } : null,
}) })
} }
@@ -920,7 +1053,7 @@ export default function App() {
const rec = fightRecord const rec = fightRecord
const f0 = rec.frames[0] const f0 = rec.frames[0]
setCombat(f0.combat); setBodyParts(f0.bodyParts); setBodyInjuries(f0.bodyInjuries) setCombat(f0.combat); setBodyParts(f0.bodyParts); setBodyInjuries(f0.bodyInjuries)
setAttackLerp(f0.attackLerp && 'player' in f0.attackLerp ? f0.attackLerp : { player: null, enemies: {} }); setHitShake(f0.hitShake ?? null) setAnimations(Array.isArray(f0.animations) ? f0.animations : []); setHitShake(f0.hitShake ?? null)
setCombatTime(0); setDisplayTime(0) setCombatTime(0); setDisplayTime(0)
replayIdxRef.current = 0 replayIdxRef.current = 0
@@ -932,7 +1065,7 @@ export default function App() {
replayIdxRef.current = idx replayIdxRef.current = idx
const f = rec.frames[idx] const f = rec.frames[idx]
setCombat(f.combat); setBodyParts(f.bodyParts); setBodyInjuries(f.bodyInjuries) setCombat(f.combat); setBodyParts(f.bodyParts); setBodyInjuries(f.bodyInjuries)
setAttackLerp(f.attackLerp && 'player' in f.attackLerp ? f.attackLerp : { player: null, enemies: {} }); setHitShake(f.hitShake ?? null) setAnimations(Array.isArray(f.animations) ? f.animations : []); setHitShake(f.hitShake ?? null)
setCombatTime(f.time) setCombatTime(f.time)
} }
if (idx >= rec.frames.length - 1) return if (idx >= rec.frames.length - 1) return
@@ -976,10 +1109,10 @@ export default function App() {
enemyActionsRef.current = initCombat.enemies.map(() => null) enemyActionsRef.current = initCombat.enemies.map(() => null)
positionsRef.current = {} positionsRef.current = {}
setSelectedEnemy(0); setReplayActive(false) setSelectedEnemy(0); setReplayActive(false)
setExpandedWeapon('ROOT'); setAttackLerp({ player: null, enemies: {} }); setHitShake(null); setMoveMode(null) setExpandedWeapon('ROOT'); setAnimations([]); setHitShake(null); setMoveMode(null)
recordingRef.current = { recordingRef.current = {
initialCombat: JSON.parse(JSON.stringify(initCombat)), initialCombat: JSON.parse(JSON.stringify(initCombat)),
frames: [{ time: 0, combat: JSON.parse(JSON.stringify(initCombat)), bodyParts: freshBody(), bodyInjuries: freshInjuries(), attackLerp: null, hitShake: null }], frames: [{ time: 0, combat: JSON.parse(JSON.stringify(initCombat)), bodyParts: freshBody(), bodyInjuries: freshInjuries(), animations: [], hitShake: null }],
} }
} }
@@ -1029,7 +1162,7 @@ export default function App() {
return return
} }
playerActionRef.current = makeAttackAnim(action, combatRef.current) playerActionRef.current = createWeaponStrikeAnim(action, combatRef.current)
timerActiveRef.current = true; setTimerRunning(true) timerActiveRef.current = true; setTimerRunning(true)
setLimbSelect(null); setSelectedSkill(null); setSelectedTarget(null); setAttackEnemy(null) setLimbSelect(null); setSelectedSkill(null); setSelectedTarget(null); setAttackEnemy(null)
} }
@@ -1038,6 +1171,11 @@ export default function App() {
setLimbSelect(null); setSelectedSkill(null); setSelectedTarget(null); setAttackEnemy(null) setLimbSelect(null); setSelectedSkill(null); setSelectedTarget(null); setAttackEnemy(null)
} }
const toggleTimer = () => {
timerActiveRef.current = !timerActiveRef.current
setTimerRunning(timerActiveRef.current)
}
/** Execute a player move; timer starts immediately. */ /** Execute a player move; timer starts immediately. */
const startMove = (targetX, targetY) => { const startMove = (targetX, targetY) => {
if (!combat || playerActionRef.current) return if (!combat || playerActionRef.current) return
@@ -1046,6 +1184,7 @@ export default function App() {
if (dist < 1) return if (dist < 1) return
const speed = moveMode === 'walk' ? 1 : 3 const speed = moveMode === 'walk' ? 1 : 3
playerActionRef.current = { type: 'move', fromX, fromY, toX: targetX, toY: targetY, speed } playerActionRef.current = { type: 'move', fromX, fromY, toX: targetX, toY: targetY, speed }
playerTargetDirRef.current = { dx: (targetX - fromX) / dist, dy: (targetY - fromY) / dist }
timerActiveRef.current = true; setTimerRunning(true) timerActiveRef.current = true; setTimerRunning(true)
setMoveMode(null) setMoveMode(null)
} }
@@ -1134,18 +1273,24 @@ export default function App() {
// ───────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────
return ( return (
<div className="game-map"> <div className="game-map" onMouseMove={e => setMousePos({ x: e.clientX, y: e.clientY })}>
{/* ═══════════════════════════════════════════════════════ COMBAT PANEL */} {/* ═══════════════════════════════════════════════════════ COMBAT PANEL */}
{combat && ( {combat && (
<div className="combat-panel"> <div className="combat-panel">
<div className="combat-top-bar"> <div className="combat-top-bar">
<div className="combat-timer"> <div className="combat-timer">
{timerRunning && ( <button className="timer-toggle" onClick={toggleTimer} title={timerRunning ? 'Pause' : 'Play'} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#9ca3af', padding: 0, display: 'flex', alignItems: 'center' }}>
<svg className="timer-icon" viewBox="0 0 16 16" width="14" height="14" fill="currentColor"> {timerRunning ? (
<polygon points="4,2 14,8 4,14" /> <svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor">
<rect x="3" y="2" width="4" height="12" rx="1" /><rect x="9" y="2" width="4" height="12" rx="1" />
</svg>
) : (
<svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor">
<polygon points="3,2 14,8 3,14" />
</svg> </svg>
)} )}
</button>
Time: {Math.floor(displayTime / 60)}:{String(Math.floor(displayTime) % 60).padStart(2,'0')}.{String(Math.floor((displayTime % 1) * 100)).padStart(2,'0')} Time: {Math.floor(displayTime / 60)}:{String(Math.floor(displayTime) % 60).padStart(2,'0')}.{String(Math.floor((displayTime % 1) * 100)).padStart(2,'0')}
</div> </div>
</div> </div>
@@ -1237,7 +1382,11 @@ export default function App() {
<div className="tree-node leaf" onClick={() => { if (!busy) setMoveMode(m => m === 'run' ? null : 'run') }}> <div className="tree-node leaf" onClick={() => { if (!busy) setMoveMode(m => m === 'run' ? null : 'run') }}>
<span className="tree-bullet"></span><span className="tree-label">Run</span> <span className="tree-bullet"></span><span className="tree-label">Run</span>
</div> </div>
{moveMode && <div className="tree-children" style={{ paddingLeft: 0 }}><div className="move-hint">Click on the mini-map to move</div></div>} <div className="tree-node leaf" onClick={() => { if (!busy) setMoveMode(m => m === 'turn' ? null : 'turn') }}>
<span className="tree-bullet"></span><span className="tree-label">Turn</span>
</div>
{moveMode && moveMode !== 'turn' && <div className="tree-children" style={{ paddingLeft: 0 }}><div className="move-hint">Click on the mini-map to move</div></div>}
{moveMode === 'turn' && <div className="tree-children" style={{ paddingLeft: 0 }}><div className="move-hint">Click on the mini-map to face a direction</div></div>}
</div> </div>
)} )}
</div> </div>
@@ -1285,8 +1434,21 @@ export default function App() {
if (minimapMovedRef.current) { minimapMovedRef.current = false; return } if (minimapMovedRef.current) { minimapMovedRef.current = false; return }
if (limbSelect || !moveMode) return if (limbSelect || !moveMode) return
const r = e.currentTarget.getBoundingClientRect() const r = e.currentTarget.getBoundingClientRect()
startMove(((e.clientX - r.left) / r.width) * 160 / minimapView.scale + minimapView.x, const tx = ((e.clientX - r.left) / r.width) * 160 / minimapView.scale + minimapView.x
((e.clientY - r.top) / r.height) * 160 / minimapView.scale + minimapView.y) const ty = ((e.clientY - r.top) / r.height) * 160 / minimapView.scale + minimapView.y
if (moveMode === 'turn') {
const dx = tx - combat.playerPos.x
const dy = ty - combat.playerPos.y
const d = Math.sqrt(dx * dx + dy * dy)
if (d > 0) {
playerTargetDirRef.current = { dx: dx / d, dy: dy / d }
playerActionRef.current = { type: 'turn' }
timerActiveRef.current = true; setTimerRunning(true)
setMoveMode(null)
}
return
}
startMove(tx, ty)
}}> }}>
<g style={{ <g style={{
transform: `translate(${-minimapView.x * minimapView.scale + (hitShake ? (Math.random() - 0.5) * 2 : 0)}px, ${-minimapView.y * minimapView.scale + (hitShake ? (Math.random() - 0.5) * 2 : 0)}px) scale(${minimapView.scale})`, transform: `translate(${-minimapView.x * minimapView.scale + (hitShake ? (Math.random() - 0.5) * 2 : 0)}px, ${-minimapView.y * minimapView.scale + (hitShake ? (Math.random() - 0.5) * 2 : 0)}px) scale(${minimapView.scale})`,
@@ -1297,15 +1459,13 @@ export default function App() {
{MAP_BLOCKS.map((block, i) => <rect key={i} x={block.x} y={block.y} width={block.w} height={block.h} fill="#2e303a" stroke="#1f2028" strokeWidth="1" />)} {MAP_BLOCKS.map((block, i) => <rect key={i} x={block.x} y={block.y} width={block.w} height={block.h} fill="#2e303a" stroke="#1f2028" strokeWidth="1" />)}
{(combat?.enemies || []).map((enemy, i) => { {(combat?.enemies || []).map((enemy, i) => {
const dead = isDead(enemy.integrity) const dead = isDead(enemy.integrity)
const eLerp = attackLerp.enemies[i]
const isShaking = hitShake && hitShake.enemyIndex === i && hitShake.actor === 'enemy' const isShaking = hitShake && hitShake.enemyIndex === i && hitShake.actor === 'enemy'
return ( return (
<circle key={i} <circle key={i}
cx={eLerp ? eLerp.from.x + (eLerp.to.x - eLerp.from.x) * eLerp.t : (isShaking ? enemy.pos.x + (Math.random() - 0.5) * 1.5 : enemy.pos.x)} cx={isShaking ? enemy.pos.x + (Math.random() - 0.5) * 1.5 : enemy.pos.x}
cy={eLerp ? eLerp.from.y + (eLerp.to.y - eLerp.from.y) * eLerp.t : (isShaking ? enemy.pos.y + (Math.random() - 0.5) * 1.5 : enemy.pos.y)} cy={isShaking ? enemy.pos.y + (Math.random() - 0.5) * 1.5 : enemy.pos.y}
r="5" fill={dead ? '#6b7280' : '#e74c3c'} stroke="#000" strokeWidth="0.5" opacity={dead ? 0.5 : 1} r="5" fill={dead ? '#6b7280' : '#e74c3c'} stroke="#000" strokeWidth="0.5" opacity={dead ? 0.5 : 1}
onMouseEnter={e => { e.stopPropagation(); setHoverInfo({ type: 'enemy', name: enemy.name, attack: enemy.attack, speed: enemy.speed, defeated: isDead(enemy.integrity) }) }} onMouseEnter={e => { e.stopPropagation(); setHoverInfo({ type: 'enemy', name: enemy.name, attack: enemy.attack, speed: enemy.speed, defeated: isDead(enemy.integrity) }) }}
onMouseMove={e => setMousePos({ x: e.clientX, y: e.clientY })}
onMouseLeave={() => setHoverInfo(null)} onMouseLeave={() => setHoverInfo(null)}
onClick={e => { e.stopPropagation(); setShowInventory(true); setPanelTarget(i) }} /> onClick={e => { e.stopPropagation(); setShowInventory(true); setPanelTarget(i) }} />
) )
@@ -1315,10 +1475,68 @@ export default function App() {
)} )}
{combat && ( {combat && (
<circle <circle
cx={attackLerp.player ? attackLerp.player.from.x + (attackLerp.player.to.x - attackLerp.player.from.x) * attackLerp.player.t : (hitShake && hitShake.actor === 'player' ? combat.playerPos.x + (Math.random() - 0.5) * 1.5 : combat.playerPos.x)} cx={hitShake && hitShake.actor === 'player' ? combat.playerPos.x + (Math.random() - 0.5) * 1.5 : combat.playerPos.x}
cy={attackLerp.player ? attackLerp.player.from.y + (attackLerp.player.to.y - attackLerp.player.from.y) * attackLerp.player.t : (hitShake && hitShake.actor === 'player' ? combat.playerPos.y + (Math.random() - 0.5) * 1.5 : combat.playerPos.y)} cy={hitShake && hitShake.actor === 'player' ? combat.playerPos.y + (Math.random() - 0.5) * 1.5 : combat.playerPos.y}
r="4" fill={isDead(bodyParts) ? '#6b7280' : '#3498db'} stroke="#000" strokeWidth="0.5" opacity={isDead(bodyParts) ? 0.5 : 1} /> r="4" fill={isDead(bodyParts) ? '#6b7280' : '#3498db'} stroke="#000" strokeWidth="0.5" opacity={isDead(bodyParts) ? 0.5 : 1} />
)} )}
{/* Weapon circles — player */}
{combat && (() => {
const anim = animations.find(a => a.actor === 'player' && (a.type === ANIM_TYPES.SLASH || a.type === ANIM_TYPES.PIERCE_STRIKE))
const pos = anim ? getAnimPos(anim) : null
const px = pos ? pos.x : (hitShake && hitShake.actor === 'player' ? combat.playerPos.x + (Math.random() - 0.5) * 1.5 : combat.playerPos.x)
const py = pos ? pos.y : (hitShake && hitShake.actor === 'player' ? combat.playerPos.y + (Math.random() - 0.5) * 1.5 : combat.playerPos.y)
const dir = playerDirRef.current
const lw = equippedWeapons.left
const rw = equippedWeapons.right
const off = 7
const circles = []
const both = lw === rw && lw !== 'fists' && lw !== 'left_arm' && lw !== 'right_arm'
if (both) {
circles.push(
<circle key="pw-both"
cx={pos ? px : px + dir.dx * off}
cy={pos ? py : py + dir.dy * off}
r="3" fill="#fbbf24" stroke="#000" strokeWidth="0.5" />
)
} else {
if (lw !== 'fists' && lw !== 'left_arm') {
circles.push(
<circle key="pw-left"
cx={pos ? px : px + dir.dy * off}
cy={pos ? py : py + -dir.dx * off}
r="3" fill="#fbbf24" stroke="#000" strokeWidth="0.5" />
)
}
if (rw !== 'fists' && rw !== 'right_arm') {
circles.push(
<circle key="pw-right"
cx={pos ? px : px + -dir.dy * off}
cy={pos ? py : py + dir.dx * off}
r="3" fill="#fbbf24" stroke="#000" strokeWidth="0.5" />
)
}
}
return circles
})()}
{/* Weapon circles — enemies */}
{combat.enemies.map((enemy, i) => {
const dead = isDead(enemy.integrity)
if (dead) return null
const wpn = enemy.weapon
if (!wpn || wpn === 'fists') return null
const enemyAnim = animations.find(a => a.actor === i && (a.type === ANIM_TYPES.SLASH || a.type === ANIM_TYPES.PIERCE_STRIKE))
const ePos = enemyAnim ? getAnimPos(enemyAnim) : null
const isShaking = hitShake && hitShake.enemyIndex === i && hitShake.actor === 'enemy'
const ex = ePos ? ePos.x : (isShaking ? enemy.pos.x + (Math.random() - 0.5) * 1.5 : enemy.pos.x)
const ey = ePos ? ePos.y : (isShaking ? enemy.pos.y + (Math.random() - 0.5) * 1.5 : enemy.pos.y)
const dir = enemyDirsRef.current[i] ?? { dx: 0, dy: 1 }
return (
<circle key={`ew-${i}`}
cx={ePos ? ex : ex + dir.dx * 7}
cy={ePos ? ey : ey + dir.dy * 7}
r="3" fill="#f97316" stroke="#000" strokeWidth="0.5" />
)
})}
</g> </g>
</svg> </svg>
</div> </div>
@@ -1485,7 +1703,7 @@ export default function App() {
<> <>
<svg ref={svgRef} viewBox="0 0 800 600" className="map-svg" <svg ref={svgRef} viewBox="0 0 800 600" className="map-svg"
onWheel={handleWheel} onWheel={handleWheel}
onMouseMove={e => setMousePos({ x: e.clientX, y: e.clientY })}> >
<rect width="800" height="600" fill="transparent" <rect width="800" height="600" fill="transparent"
onPointerDown={handleBgPointerDown} onPointerDown={handleBgPointerDown}
onPointerMove={handleBgPointerMove} onPointerMove={handleBgPointerMove}
@@ -1504,7 +1722,6 @@ export default function App() {
style={{ cursor: 'pointer', transition: 'all 0.2s' }} style={{ cursor: 'pointer', transition: 'all 0.2s' }}
onClick={() => setSelected(selected === l.id ? null : l.id)} onClick={() => setSelected(selected === l.id ? null : l.id)}
onMouseEnter={() => setHovered(l.id)} onMouseEnter={() => setHovered(l.id)}
onMouseMove={e => setMousePos({ x: e.clientX, y: e.clientY })}
onMouseLeave={() => setHovered(null)} /> onMouseLeave={() => setHovered(null)} />
<text x={l.x} y={l.y + 6} textAnchor="middle" fill="#5F71C5" fontSize={16} fontWeight="bold" pointerEvents="none">{l.id}</text> <text x={l.x} y={l.y + 6} textAnchor="middle" fill="#5F71C5" fontSize={16} fontWeight="bold" pointerEvents="none">{l.id}</text>
{shops.some(s => s.locationId === l.id) && ( {shops.some(s => s.locationId === l.id) && (
@@ -1577,13 +1794,6 @@ export default function App() {
}}>Fight 2 random enemies</button> }}>Fight 2 random enemies</button>
</div> </div>
{hovered && (
<div className="tooltip" style={{ left: mousePos.x + 16, top: mousePos.y }}>
<div className="tooltip-name">{locMap[hovered].name}</div>
<div className="tooltip-desc">{locMap[hovered].description}</div>
</div>
)}
{selected && ( {selected && (
<div className="info-panel"> <div className="info-panel">
<div className="info-name">{locMap[selected].name}</div> <div className="info-name">{locMap[selected].name}</div>
@@ -1670,7 +1880,7 @@ export default function App() {
.map(id => characters.find(c => c.id === id)) .map(id => characters.find(c => c.id === id))
.filter(c => c && c.location === showLocationModal) .filter(c => c && c.location === showLocationModal)
return corpsesHere.length === 0 return corpsesHere.length === 0
? <div className="location-empty">No corpses here.</div> ? <div className="location-empty">Nothing of interest.</div>
: corpsesHere.map(c => <div key={c.id} className="location-surrounding-corpse">{c.name}'s corpse</div>) : corpsesHere.map(c => <div key={c.id} className="location-surrounding-corpse">{c.name}'s corpse</div>)
})()} })()}
</div> </div>
@@ -1712,7 +1922,7 @@ export default function App() {
<div className="inventory-overlay" <div className="inventory-overlay"
onClick={() => { setShowInventory(false); setPanelTarget('player'); setHoverInfo(null); setContextMenu(null) }} onClick={() => { setShowInventory(false); setPanelTarget('player'); setHoverInfo(null); setContextMenu(null) }}
onContextMenu={e => { e.preventDefault(); setContextMenu(null) }} onContextMenu={e => { e.preventDefault(); setContextMenu(null) }}
onMouseMove={e => setMousePos({ x: e.clientX, y: e.clientY })}> >
<div className="inventory-panel" onClick={e => e.stopPropagation()}> <div className="inventory-panel" onClick={e => e.stopPropagation()}>
<button className="inv-close" onClick={() => { setShowInventory(false); setPanelTarget('player'); setHoverInfo(null) }}>X</button> <button className="inv-close" onClick={() => { setShowInventory(false); setPanelTarget('player'); setHoverInfo(null) }}>X</button>
<div className="inv-layout"> <div className="inv-layout">