diff --git a/src/App.jsx b/src/App.jsx index 0d2075b..0565788 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -94,6 +94,7 @@ const MAP_BLOCKS = [ ] const INJURY_DECAY = { cut: 0.3, laceration: 0.6, stab: 0.8 } +const TURN_SPEED = 6 const bodyPartLabels = { head: 'Head', torso: 'Torso', @@ -166,11 +167,13 @@ function computeMove(from, to, speed) { if (dist <= speed) return { x: to.x, y: to.y } const step = 0.5 - const steps = Math.floor(speed / step) + const totalSteps = Math.ceil(speed / step) let cx = from.x, cy = from.y - for (let i = 0; i < steps; i++) { - const nx = cx + (dx / dist) * step - const ny = cy + (dy / dist) * step + for (let i = 0; i < totalSteps; i++) { + const remaining = speed - i * 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 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. * Uses the same computeMove / computeHit as the player. */ -function runEnemyAI(combat, playerParts, playerInjuries) { +function runEnemyAI(combat, playerParts, playerInjuries, enemyDirs) { const actions = [] const moveTargets = {} @@ -316,6 +319,12 @@ function runEnemyAI(combat, playerParts, playerInjuries) { const newPos = computeMove(e.pos, combat.playerPos, speed) 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 } + 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) // ── animation state - const [attackLerp, setAttackLerp] = useState({ player: null, enemies: {} }) + const [animations, setAnimations] = useState([]) const [hitShake, setHitShake] = useState(null) 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 // Tracks the latest enemy positions so AI reads fresh positions (not stale combatRef) 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 @@ -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 startPos = isPlayer ? { ...(cc?.playerPos ?? { 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?.playerPos ?? startPos) } - const dx = targetPos.x - startPos.x - const dy = targetPos.y - startPos.y - const dist = Math.sqrt(dx * dx + dy * dy) || 1 - const lungeDist = Math.min(LUNGE_DIST, dist) - const lungeTarget = { - x: startPos.x + (dx / dist) * lungeDist, - y: startPos.y + (dy / dist) * lungeDist, - } + + const weapon = weaponMap[action.weaponId] + const skill = weapon?.skills?.find(s => s.name === action.skillName) + const damageType = skill?.damageType ?? 'blunt' + const type = damageType === 'slash' ? ANIM_TYPES.SLASH : ANIM_TYPES.PIERCE_STRIKE + return { - type: 'attack', + id: `${action.actor}-${action.enemyIndex}-${Date.now()}`, + type, actor: action.actor, enemyIndex: action.enemyIndex, + startPos, + endPos, + elapsed: 0, + hitFired: false, targetPart: action.targetPart, weaponId: action.weaponId, skillName: action.skillName, @@ -505,149 +540,193 @@ export default function App() { attackerInjuries: action.attackerInjuries, targetParts: action.targetParts, 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) { - const liveCc = combatRef.current - 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 - + function getSlashPos(anim) { + const { hitAt, duration } = ANIM_CFG[ANIM_TYPES.SLASH] const t = anim.elapsed < hitAt ? anim.elapsed / hitAt - : anim.elapsed < returnAt - ? 1 - (anim.elapsed - hitAt) / (returnAt - hitAt) + : anim.elapsed < duration + ? 1 - (anim.elapsed - hitAt) / (duration - hitAt) : 0 - setAttackLerp(prev => anim.actor === 'player' - ? { ...prev, player: { from: anim.startPos, to: anim.lungeTarget, t } } - : { ...prev, enemies: { ...prev.enemies, [anim.enemyIndex]: { from: anim.startPos, to: anim.lungeTarget, t } } }) + const dx = anim.endPos.x - anim.startPos.x + const dy = anim.endPos.y - anim.startPos.y + 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, + } + } - let inRange = false - if (liveCc) { - if (anim.actor === 'player') { - const e = liveCc.enemies[anim.enemyIndex] - if (e) inRange = canReach(liveCc.playerPos, e.pos, anim.weaponId, anim.skillName) - } else { - const e = liveCc.enemies[anim.enemyIndex] - if (e) { - const ddx = liveCc.playerPos.x - e.pos.x - const ddy = liveCc.playerPos.y - e.pos.y - inRange = Math.sqrt(ddx * ddx + ddy * ddy) <= 15 - } + /** + * 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 + if (liveCc) { + if (anim.actor === 'player') { + const e = liveCc.enemies[anim.enemyIndex] + if (e) inRange = canReach(liveCc.playerPos, e.pos, anim.weaponId, anim.skillName) + } else { + const e = liveCc.enemies[anim.enemyIndex] + if (e) { + const ddx = liveCc.playerPos.x - e.pos.x + const ddy = liveCc.playerPos.y - e.pos.y + inRange = Math.sqrt(ddx * ddx + ddy * ddy) <= 15 } } + } - if (!inRange) { - const msg = anim.actor === 'player' - ? 'Your attack misses!' - : `${liveCc?.enemies[anim.enemyIndex]?.name ?? 'Enemy'}'s attack misses!` - setCombat(prev => ({ ...prev, log: [...prev.log, msg] })) - } else { - setHitShake({ actor: anim.actor, enemyIndex: anim.enemyIndex, start: performance.now() }) - if (anim.actor === 'player') { - // ── Player attacking enemy ── - const wid = anim.weaponId - if (wid !== 'fists' && wid !== 'left_arm' && wid !== 'right_arm') { - const base = weaponMap[wid] - setWeaponConditions(prev => ({ ...prev, [wid]: Math.max(0, (prev[wid] ?? base.maxCondition) - (1 + Math.floor(Math.random() * 2))) })) - } + if (!inRange) { + const msg = anim.actor === 'player' + ? 'Your attack misses!' + : `${liveCc?.enemies[anim.enemyIndex]?.name ?? 'Enemy'}'s attack misses!` + setCombat(prev => ({ ...prev, log: [...prev.log, msg] })) + return + } - const decayedPlayer = calcIntegrity(bodyPartsRef.current, bodyInjuriesRef.current, hitAt) + setHitShake({ actor: anim.actor, enemyIndex: anim.enemyIndex, start: performance.now() }) - if (liveCc) { - const enemy = liveCc.enemies[anim.enemyIndex] - if (enemy) { - const decayedEnemy = calcIntegrity(enemy.integrity, enemy.injuries, hitAt) - const prevHp = decayedEnemy[anim.targetPart] - const hitEnemy = { ...decayedEnemy, [anim.targetPart]: Math.max(0, prevHp - anim.dmg) } - const newInjuries = prevHp > 0 && anim.severity > 0 - ? applyInjury(enemy.injuries, anim.targetPart, anim.injType, anim.severity) - : enemy.injuries + if (anim.actor === 'player') { + const wid = anim.weaponId + if (wid !== 'fists' && wid !== 'left_arm' && wid !== 'right_arm') { + const base = weaponMap[wid] + setWeaponConditions(prev => ({ ...prev, [wid]: Math.max(0, (prev[wid] ?? base.maxCondition) - (1 + Math.floor(Math.random() * 2))) })) + } - const log = anim.dmg > 0 - ? [`You ${anim.skillName?.toLowerCase() ?? 'attack'} ${enemy.name}'s ${bodyPartLabels[anim.targetPart]} — ${anim.injLabel} (severity ${anim.severity}, -${anim.dmg} integrity).`] - : [] - if (isDead(hitEnemy)) { - if (anim.targetPart === 'head') log.push(`You crush ${enemy.name}'s head!`) - log.push(`${enemy.name} is defeated!`) - } + const decayedPlayer = calcIntegrity(bodyPartsRef.current, bodyInjuriesRef.current, ANIM_CFG[anim.type].hitAt) - setCombat(prev => ({ - ...prev, - enemies: prev.enemies.map((e, i) => i === anim.enemyIndex ? { ...e, integrity: hitEnemy, injuries: newInjuries } : e), - log: [...prev.log, ...log], - })) + if (liveCc) { + const enemy = liveCc.enemies[anim.enemyIndex] + if (enemy) { + const decayedEnemy = calcIntegrity(enemy.integrity, enemy.injuries, ANIM_CFG[anim.type].hitAt) + const prevHp = decayedEnemy[anim.targetPart] + const hitEnemy = { ...decayedEnemy, [anim.targetPart]: Math.max(0, prevHp - anim.dmg) } + const newInjuries = prevHp > 0 && anim.severity > 0 + ? applyInjury(enemy.injuries, anim.targetPart, anim.injType, anim.severity) + : enemy.injuries + + const log = anim.dmg > 0 + ? [`You ${anim.skillName?.toLowerCase() ?? 'attack'} ${enemy.name}'s ${bodyPartLabels[anim.targetPart]} — ${anim.injLabel} (severity ${anim.severity}, -${anim.dmg} integrity).`] + : [] + if (isDead(hitEnemy)) { + if (anim.targetPart === 'head') log.push(`You crush ${enemy.name}'s head!`) + log.push(`${enemy.name} is defeated!`) } - } - - setBodyParts(decayedPlayer) - setBodyInjuries(bodyInjuriesRef.current) - } else { - // ── Enemy attacking player ── - const currentEnemy = liveCc?.enemies[anim.enemyIndex] - const decayedEnemy = currentEnemy - ? calcIntegrity(currentEnemy.integrity, currentEnemy.injuries, hitAt) - : calcIntegrity(anim.attackerParts, anim.attackerInjuries, hitAt) - const decayedPlayer = calcIntegrity(bodyPartsRef.current, bodyInjuriesRef.current, hitAt) - const prevHp = decayedPlayer[anim.targetPart] - const hitPlayer = { ...decayedPlayer, [anim.targetPart]: Math.max(0, prevHp - anim.dmg) } - const newInjuries = prevHp > 0 && anim.severity > 0 - ? applyInjury(bodyInjuriesRef.current, anim.targetPart, anim.injType, anim.severity) - : bodyInjuriesRef.current - - if (liveCc) { - const enemy = liveCc.enemies[anim.enemyIndex] - const enemyName = enemy?.name ?? 'Enemy' setCombat(prev => ({ ...prev, - enemies: prev.enemies.map((e, i) => i === anim.enemyIndex ? { ...e, integrity: decayedEnemy } : e), - log: anim.dmg > 0 - ? [...prev.log, `${enemyName} lands a ${anim.injLabel} on your ${bodyPartLabels[anim.targetPart]} (severity ${anim.severity}, integrity -${anim.dmg}).`] - : prev.log, + enemies: prev.enemies.map((e, i) => i === anim.enemyIndex ? { ...e, integrity: hitEnemy, injuries: newInjuries } : e), + log: [...prev.log, ...log], })) } - - setBodyParts(hitPlayer) - 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 + setBodyParts(decayedPlayer) + setBodyInjuries(bodyInjuriesRef.current) + } else { + const currentEnemy = liveCc?.enemies[anim.enemyIndex] + const decayedEnemy = currentEnemy + ? calcIntegrity(currentEnemy.integrity, currentEnemy.injuries, ANIM_CFG[anim.type].hitAt) + : calcIntegrity(anim.attackerParts, anim.attackerInjuries, ANIM_CFG[anim.type].hitAt) + const decayedPlayer = calcIntegrity(bodyPartsRef.current, bodyInjuriesRef.current, ANIM_CFG[anim.type].hitAt) + const prevHp = decayedPlayer[anim.targetPart] + const hitPlayer = { ...decayedPlayer, [anim.targetPart]: Math.max(0, prevHp - anim.dmg) } + const newInjuries = prevHp > 0 && anim.severity > 0 + ? applyInjury(bodyInjuriesRef.current, anim.targetPart, anim.injType, anim.severity) + : bodyInjuriesRef.current + + if (liveCc) { + const enemy = liveCc.enemies[anim.enemyIndex] + const enemyName = enemy?.name ?? 'Enemy' + + setCombat(prev => ({ + ...prev, + enemies: prev.enemies.map((e, i) => i === anim.enemyIndex ? { ...e, integrity: decayedEnemy } : e), + log: anim.dmg > 0 + ? [...prev.log, `${enemyName} lands a ${anim.injLabel} on your ${bodyPartLabels[anim.targetPart]} (severity ${anim.severity}, integrity -${anim.dmg}).`] + : prev.log, + })) + } + + setBodyParts(hitPlayer) + setBodyInjuries(newInjuries) } - return false } /** * 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) { + 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)) let cf = { x: anim.fromX, y: anim.fromY } 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) { setCombat(c => ({ ...c, playerPos: p })); positionsRef.current.player = p; return true } @@ -693,29 +772,60 @@ export default function App() { const dt = (now - lastTime) / 1000 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) - setDisplayTime(t => t + dt) - - // ── Auto-dismiss hitShake after 0.3s ── + // ── Effects (run every frame, independent of timer) ── setHitShake(prev => { if (!prev) return prev return (performance.now() - prev.start) / 1000 >= 0.3 ? null : prev }) - // ── Update player action ── - if (playerActionRef.current) { - const anim = playerActionRef.current - const done = anim.type === 'attack' - ? tickAttack(anim, dt) - : anim.type === 'move' - ? tickPlayerMove(anim, dt) - : true - if (done) playerActionRef.current = null + if (!timerActiveRef.current) { + setAnimations([]) + return } - // ── 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 fresh = cc ? (() => { const pp = positionsRef.current.player @@ -730,17 +840,19 @@ export default function App() { })() : null if (cc) { for (let i = 0; i < cc.enemies.length; i++) { - const anim = enemyActionsRef.current[i] - if (!anim) continue - const done = anim.type === 'attack' - ? tickAttack(anim, dt) - : anim.type === 'enemyMove' - ? tickEnemyMove(anim, dt) - : true + const rawAnim = enemyActionsRef.current[i] + if (!rawAnim) continue + const done = rawAnim.type === 'enemyMove' + ? tickEnemyMove(rawAnim, dt) + : 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) { - if (anim.type === 'enemyMove') { + if (rawAnim.type === 'enemyMove') { 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)) { const dist = Math.sqrt( ((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 }, targetInjuries: { ...bodyInjuriesRef.current }, } - enemyActionsRef.current[i] = makeAttackAnim(action, fresh) + enemyActionsRef.current[i] = createWeaponStrikeAnim(action, fresh) continue } } } enemyActionsRef.current[i] = null + } else { + activeAnims.push(rawAnim) } } } // ── Run enemy AI every frame for idle enemies ── 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) { 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') { for (const idxStr of Object.keys(act.targets)) { 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 - if (!playerBusy) { + if (wasBusyRef.current && !playerBusy) { timerActiveRef.current = false setTimerRunning(false) } + wasBusyRef.current = playerBusy setBusy(playerBusy) } @@ -829,6 +947,21 @@ export default function App() { if (isDead(bodyParts) || !combat.enemies.some(e => !isDead(e.integrity))) endFight() }, [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) // ───────────────────────────────────────────────────────────────────────── @@ -840,7 +973,7 @@ export default function App() { combat: JSON.parse(JSON.stringify(combat)), bodyParts: JSON.parse(JSON.stringify(bodyParts)), bodyInjuries: JSON.parse(JSON.stringify(bodyInjuries)), - attackLerp: JSON.parse(JSON.stringify(attackLerp)), + animations: JSON.parse(JSON.stringify(animations)), hitShake: hitShake ? { ...hitShake } : null, }) } @@ -920,7 +1053,7 @@ export default function App() { const rec = fightRecord const f0 = rec.frames[0] 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) replayIdxRef.current = 0 @@ -932,7 +1065,7 @@ export default function App() { replayIdxRef.current = idx const f = rec.frames[idx] 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) } if (idx >= rec.frames.length - 1) return @@ -976,10 +1109,10 @@ export default function App() { enemyActionsRef.current = initCombat.enemies.map(() => null) positionsRef.current = {} setSelectedEnemy(0); setReplayActive(false) - setExpandedWeapon('ROOT'); setAttackLerp({ player: null, enemies: {} }); setHitShake(null); setMoveMode(null) + setExpandedWeapon('ROOT'); setAnimations([]); setHitShake(null); setMoveMode(null) recordingRef.current = { 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 } - playerActionRef.current = makeAttackAnim(action, combatRef.current) + playerActionRef.current = createWeaponStrikeAnim(action, combatRef.current) timerActiveRef.current = true; setTimerRunning(true) setLimbSelect(null); setSelectedSkill(null); setSelectedTarget(null); setAttackEnemy(null) } @@ -1038,6 +1171,11 @@ export default function App() { setLimbSelect(null); setSelectedSkill(null); setSelectedTarget(null); setAttackEnemy(null) } + const toggleTimer = () => { + timerActiveRef.current = !timerActiveRef.current + setTimerRunning(timerActiveRef.current) + } + /** Execute a player move; timer starts immediately. */ const startMove = (targetX, targetY) => { if (!combat || playerActionRef.current) return @@ -1046,6 +1184,7 @@ export default function App() { if (dist < 1) return const speed = moveMode === 'walk' ? 1 : 3 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) setMoveMode(null) } @@ -1134,18 +1273,24 @@ export default function App() { // ───────────────────────────────────────────────────────────────────────── return ( -