From 56fd57efe5ce19407bc93d965c167b490b150b07 Mon Sep 17 00:00:00 2001 From: johnruina Date: Thu, 4 Jun 2026 20:36:26 -0400 Subject: [PATCH] attack animations --- src/App.jsx | 590 +++++++++++++++++++++++++++++++++++----------------- 1 file changed, 400 insertions(+), 190 deletions(-) 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 ( -
+
setMousePos({ x: e.clientX, y: e.clientY })}> {/* ═══════════════════════════════════════════════════════ COMBAT PANEL */} {combat && (
- {timerRunning && ( - - - - )} + Time: {Math.floor(displayTime / 60)}:{String(Math.floor(displayTime) % 60).padStart(2,'0')}.{String(Math.floor((displayTime % 1) * 100)).padStart(2,'0')}
@@ -1237,7 +1382,11 @@ export default function App() {
{ if (!busy) setMoveMode(m => m === 'run' ? null : 'run') }}> Run
- {moveMode &&
Click on the mini-map to move
} +
{ if (!busy) setMoveMode(m => m === 'turn' ? null : 'turn') }}> + Turn +
+ {moveMode && moveMode !== 'turn' &&
Click on the mini-map to move
} + {moveMode === 'turn' &&
Click on the mini-map to face a direction
}
)}
@@ -1285,8 +1434,21 @@ export default function App() { if (minimapMovedRef.current) { minimapMovedRef.current = false; return } if (limbSelect || !moveMode) return const r = e.currentTarget.getBoundingClientRect() - startMove(((e.clientX - r.left) / r.width) * 160 / minimapView.scale + minimapView.x, - ((e.clientY - r.top) / r.height) * 160 / minimapView.scale + minimapView.y) + const tx = ((e.clientX - r.left) / r.width) * 160 / minimapView.scale + minimapView.x + 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) }}> )} {(combat?.enemies || []).map((enemy, i) => { const dead = isDead(enemy.integrity) - const eLerp = attackLerp.enemies[i] const isShaking = hitShake && hitShake.enemyIndex === i && hitShake.actor === 'enemy' return ( { 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)} - onClick={e => { e.stopPropagation(); setShowInventory(true); setPanelTarget(i) }} /> + onMouseLeave={() => setHoverInfo(null)} + onClick={e => { e.stopPropagation(); setShowInventory(true); setPanelTarget(i) }} /> ) })} {combat && selectedSkill && limbSelect && ( @@ -1315,10 +1475,68 @@ export default function App() { )} {combat && ( )} + {/* 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( + + ) + } else { + if (lw !== 'fists' && lw !== 'left_arm') { + circles.push( + + ) + } + if (rw !== 'fists' && rw !== 'right_arm') { + circles.push( + + ) + } + } + 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 ( + + ) + })}
@@ -1485,7 +1703,7 @@ export default function App() { <> setMousePos({ x: e.clientX, y: e.clientY })}> + > setSelected(selected === l.id ? null : l.id)} onMouseEnter={() => setHovered(l.id)} - onMouseMove={e => setMousePos({ x: e.clientX, y: e.clientY })} onMouseLeave={() => setHovered(null)} /> {l.id} {shops.some(s => s.locationId === l.id) && ( @@ -1577,13 +1794,6 @@ export default function App() { }}>Fight 2 random enemies - {hovered && ( -
-
{locMap[hovered].name}
-
{locMap[hovered].description}
-
- )} - {selected && (
{locMap[selected].name}
@@ -1670,7 +1880,7 @@ export default function App() { .map(id => characters.find(c => c.id === id)) .filter(c => c && c.location === showLocationModal) return corpsesHere.length === 0 - ?
No corpses here.
+ ?
Nothing of interest.
: corpsesHere.map(c =>
{c.name}'s corpse
) })()}
@@ -1712,7 +1922,7 @@ export default function App() {
{ setShowInventory(false); setPanelTarget('player'); setHoverInfo(null); setContextMenu(null) }} onContextMenu={e => { e.preventDefault(); setContextMenu(null) }} - onMouseMove={e => setMousePos({ x: e.clientX, y: e.clientY })}> + >
e.stopPropagation()}>