diff --git a/src/App.jsx b/src/App.jsx index 2bea77a..1e5b05a 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -242,7 +242,7 @@ function canReach(attackerPos, targetPos, weaponId, skillName) { * Build a player attack action for the action queue. * Returns an action object that actionHandlers.attack can process. */ -function buildPlayerAttack({ weaponId, skillName, targetPart, enemyIndex, equippedWeapons, weaponConditions, bodyParts, bodyInjuries, combatLog }) { +function buildPlayerAttack({ weaponId, skillName, targetPart, enemyIndex, equippedWeapons, weaponConditions, bodyParts, bodyInjuries, combat }) { const base = weaponMap[weaponId] const isArm = weaponId === 'left_arm' || weaponId === 'right_arm' const skill = isArm @@ -256,13 +256,17 @@ function buildPlayerAttack({ weaponId, skillName, targetPart, enemyIndex, equipp const bothMult = (!isArm && equippedWeapons.left === weaponId && equippedWeapons.right === weaponId) ? 1.5 : 1.0 const { dmg, severity, injType, injLabel } = computeHit({ weaponId, skill, weaponConditions, bothMult }) + const enemy = combat.enemies[enemyIndex] return { type: 'attack', actor: 'player', weaponId, skillName, targetPart, enemyIndex, dmg, severity, injType, injLabel, - combatLog, bodyParts, bodyInjuries, + attackerParts: { ...bodyParts }, + attackerInjuries: { ...bodyInjuries }, + targetParts: enemy ? { ...enemy.integrity } : {}, + targetInjuries: enemy ? { ...enemy.injuries } : {}, } } @@ -270,10 +274,9 @@ 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) { +function runEnemyAI(combat, playerParts, playerInjuries) { const actions = [] const moveTargets = {} - const hits = [] for (let i = 0; i < combat.enemies.length; i++) { const e = combat.enemies[i] @@ -291,7 +294,20 @@ function runEnemyAI(combat) { Math.floor(Math.random() * (base?.skills?.length ?? 1)) ] const { dmg, severity, injType, injLabel } = computeHit({ weaponId, skill, weaponConditions: {} }) - hits.push({ name: e.name, dmg, severity, part: randomPart(), type: injType, label: injLabel }) + const targetPart = randomPart() + actions.push({ + type: 'attack', + actor: 'enemy', + enemyIndex: i, + targetPart, + weaponId, + skillName: skill.name, + dmg, severity, injType, injLabel, + attackerParts: { ...e.integrity }, + attackerInjuries: { ...e.injuries }, + targetParts: { ...playerParts }, + targetInjuries: { ...playerInjuries }, + }) } else { // ── out of range → move toward player const speed = e.speed > 12 ? 3 : 1.5 @@ -303,87 +319,9 @@ function runEnemyAI(combat) { } if (Object.keys(moveTargets).length > 0) actions.push({ type: 'enemyMove', targets: moveTargets }) - if (hits.length > 0) actions.push({ type: 'enemyAttack', hits }) return actions } -// ───────────────────────────────────────────────────────────────────────────── -// ACTION HANDLERS (called from the animation / timer loop) -// ───────────────────────────────────────────────────────────────────────────── - -const actionHandlers = { - /** - * Player melee attack — triggers the attack animation state. - */ - attack: (action, { setAttackAnim, bodyParts, bodyInjuries }) => { - setAttackAnim({ - actor: 'player', - targetPart: action.targetPart, - weaponId: action.weaponId, - skillName: action.skillName, - dmg: action.dmg, - severity: action.severity, - injType: action.injType, - injLabel: action.injLabel, - enemyIndex: action.enemyIndex, - combatLog: action.combatLog, - bodyParts, - bodyInjuries, - startTs: null, - }) - }, - - /** - * Shove action — resolved here using the shared computeShove helper. - */ - shove_pending: (action, { combat, setCombat }) => { - const enemy = combat.enemies[action.enemyIndex] - if (!enemy || enemy.integrity.head <= 0) return - const { pos, actualPush } = computeShove(combat.playerPos, enemy.pos) - setCombat(c => ({ - ...c, - enemies: c.enemies.map((e, i) => i === action.enemyIndex ? { ...e, pos } : e), - log: [...c.log, `You shove ${enemy.name} back ${Math.round(actualPush)} units!`], - })) - }, - - /** - * Player movement — uses the shared computeMove helper via the moveAnim loop. - */ - move: (action, { setMoveAnim }) => { - setMoveAnim({ fromX: action.fromX, fromY: action.fromY, toX: action.toX, toY: action.toY, speed: action.speed }) - }, - - /** - * Enemy movement batch. - */ - enemyMove: (action, { setEnemyMoveAnims }) => { - setEnemyMoveAnims(action.targets) - }, - - /** - * Enemy melee hits — resolved immediately (no animation frame for enemies yet). - */ - enemyAttack: (action, { setCombat, setBodyParts, setBodyInjuries, combat, combatTimeRef }) => { - const newLog = [...combat.log] - for (const entry of action.hits) { - newLog.push(`${entry.name} lands a ${entry.label} on your ${bodyPartLabels[entry.part]} (severity ${entry.severity}, integrity -${entry.dmg}).`) - } - setCombat({ ...combat, log: newLog }) - for (const entry of action.hits) { - setBodyParts(p => ({ ...p, [entry.part]: Math.max(0, p[entry.part] - entry.dmg) })) - } - setBodyInjuries(prev => { - const injuries = { ...prev } - for (const entry of action.hits) { - const key = `${entry.type}_${entry.severity}_${entry.part}_${Date.now()}_${Math.random()}` - injuries[key] = { type: entry.type, severity: entry.severity, part: entry.part } - } - return injuries - }) - }, -} - // ───────────────────────────────────────────────────────────────────────────── // BODY IMAGE COMPONENT // ───────────────────────────────────────────────────────────────────────────── @@ -469,14 +407,13 @@ export default function App() { const [moveMode, setMoveMode] = useState(null) // ── animation state - const [actionQueue, setActionQueue] = useState([]) - const [moveAnim, setMoveAnim] = useState(null) - const [enemyMoveAnims, setEnemyMoveAnims] = useState(null) - const [attackAnim, setAttackAnim] = useState(null) const [attackLerp, setAttackLerp] = useState(null) const [hitShake, setHitShake] = useState(null) const [minimapView, setMinimapView] = useState({ x: 0, y: 0, scale: 1 }) + // ── ui busy (mirrors ref-based action state for re-renders) + const [busy, setBusy] = useState(false) + // ── replay const [fightRecord, setFightRecord] = useState(null) const [replayActive, setReplayActive] = useState(false) @@ -490,12 +427,10 @@ export default function App() { const timerActiveRef = useRef(false) // timerRunning is the React state mirror used only for the UI play icon. const [timerRunning, setTimerRunning] = useState(false) - const actionQueueRef = useRef([]); actionQueueRef.current = actionQueue const bodyPartsRef = useRef(null); bodyPartsRef.current = bodyParts const bodyInjuriesRef = useRef(null); bodyInjuriesRef.current = bodyInjuries - const moveAnimRef = useRef(null); moveAnimRef.current = moveAnim - const enemyMoveAnimsRef = useRef(null); enemyMoveAnimsRef.current = enemyMoveAnims - const attackAnimRef = useRef(null); attackAnimRef.current = attackAnim + const playerActionRef = useRef(null) + const enemyActionsRef = useRef([]) const replayFrameRef = useRef(null) const replayIdxRef = useRef(0) const recordingRef = useRef(null) @@ -507,6 +442,8 @@ export default function App() { const minimapPanStart = useRef({ x: 0, y: 0 }) const minimapMovedRef = useRef(false) const equippedWeaponsRef = useRef(null); equippedWeaponsRef.current = equippedWeapons + // Tracks the latest enemy positions so AI reads fresh positions (not stale combatRef) + const positionsRef = useRef({}) // ───────────────────────────────────────────────────────────────────────── // DERIVED @@ -532,6 +469,193 @@ export default function App() { // TIMER — only ticks while timerActive is true // ───────────────────────────────────────────────────────────────────────── + const LUNGE_DIST = 15 + + function makeAttackAnim(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 + ? { ...(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, + } + return { + type: 'attack', + actor: action.actor, + enemyIndex: action.enemyIndex, + targetPart: action.targetPart, + weaponId: action.weaponId, + skillName: action.skillName, + dmg: action.dmg, + severity: action.severity, + injType: action.injType, + injLabel: action.injLabel, + attackerParts: action.attackerParts, + 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. + */ + function tickAttack(anim, dt) { + anim.elapsed += dt + const hitAt = 0.12 + + const t = anim.elapsed < hitAt + ? anim.elapsed / hitAt + : anim.elapsed < 0.25 + ? 1 - (anim.elapsed - hitAt) / (0.25 - hitAt) + : 0 + setAttackLerp({ actor: anim.actor, from: anim.startPos, to: anim.lungeTarget, t, enemyIndex: anim.enemyIndex }) + + if (anim.elapsed >= hitAt && !anim.hitFired) { + anim.hitFired = true + + 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 + } + } + } + + setHitShake({ enemyIndex: anim.enemyIndex, start: performance.now() }) + + 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 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))) })) + } + + const decayedPlayer = calcIntegrity(anim.attackerParts, anim.attackerInjuries, hitAt) + + if (liveCc) { + const enemy = liveCc.enemies[anim.enemyIndex] + if (enemy) { + const decayedEnemy = calcIntegrity(anim.targetParts, anim.targetInjuries, hitAt) + const hitEnemy = { ...decayedEnemy, [anim.targetPart]: Math.max(0, decayedEnemy[anim.targetPart] - anim.dmg) } + const newInjuries = applyInjury(anim.targetInjuries, anim.targetPart, anim.injType, anim.severity) + + const log = [ + `You ${anim.skillName?.toLowerCase() ?? 'attack'} ${enemy.name}'s ${bodyPartLabels[anim.targetPart]} — ${anim.injLabel} (severity ${anim.severity}, -${anim.dmg} integrity).`, + ] + if (hitEnemy.head <= 0) { + log.push(`You crush ${enemy.name}'s head!`) + log.push(`${enemy.name} is defeated!`) + } + + setCombat(prev => ({ + ...prev, + enemies: prev.enemies.map((e, i) => i === anim.enemyIndex ? { ...e, integrity: hitEnemy, injuries: newInjuries } : e), + log: [...prev.log, ...log], + })) + } + } + + setBodyParts(decayedPlayer) + setBodyInjuries(anim.attackerInjuries) + } else { + // ── Enemy attacking player ── + const decayedEnemy = calcIntegrity(anim.attackerParts, anim.attackerInjuries, hitAt) + + const hitPlayer = { ...anim.targetParts, [anim.targetPart]: Math.max(0, (anim.targetParts[anim.targetPart] ?? 100) - anim.dmg) } + const newInjuries = applyInjury(anim.targetInjuries, anim.targetPart, anim.injType, anim.severity) + + 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: [...prev.log, `${enemyName} lands a ${anim.injLabel} on your ${bodyPartLabels[anim.targetPart]} (severity ${anim.severity}, integrity -${anim.dmg}).`], + })) + } + + setBodyParts(hitPlayer) + setBodyInjuries(newInjuries) + } + } + + if (anim.elapsed >= 0.35) { + setAttackLerp(null) + setHitShake(null) + return true + } + return false + } + + /** + * Advance a player move by dt seconds. Returns true when done/blocked. + */ + function tickPlayerMove(anim, dt) { + 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) + if (p.x === anim.toX && p.y === anim.toY) { + setCombat(c => ({ ...c, playerPos: p })); positionsRef.current.player = p; return true + } + if (p.x === cf.x && p.y === cf.y) return true + cf = p + setCombat(c => ({ ...c, playerPos: p })) + } + anim.fromX = cf.x; anim.fromY = cf.y + positionsRef.current.player = cf + return false + } + + /** + * Advance an enemy move by dt seconds. Returns true when done. + */ + function tickEnemyMove(anim, dt) { + const DURATION = 0.2 + anim.elapsed += dt + const t = Math.min(anim.elapsed / DURATION, 1) + const ea = anim.targets[anim.enemyIndex] + const pos = { x: ea.fromX + (ea.toX - ea.fromX) * t, y: ea.fromY + (ea.toY - ea.fromY) * t } + setCombat(c => { + const enemies = [...c.enemies] + enemies[anim.enemyIndex] = { ...enemies[anim.enemyIndex], pos } + return { ...c, enemies } + }) + positionsRef.current[anim.enemyIndex] = pos + return t >= 1 + } + + // ───────────────────────────────────────────────────────────────────────── + // TIMER — ticks while timerActive is true, updates all character actions + // ───────────────────────────────────────────────────────────────────────── + useEffect(() => { if (!combat || replayActive) { setDisplayTime(0); return } let lastTime = performance.now() @@ -543,203 +667,118 @@ export default function App() { const dt = (now - lastTime) / 1000 lastTime = now - if (!timerActiveRef.current) return // ← timer only advances during actions + if (!timerActiveRef.current) return setCombatTime(t => t + dt) setDisplayTime(t => t + dt) - // ── dispatch queued actions once no animation is running - let dispatchedThisTick = false - if (!enemyMoveAnimsRef.current && !attackAnimRef.current && !moveAnimRef.current) { - const q = actionQueueRef.current - if (q.length > 0) { - dispatchedThisTick = true - const ctx = { - combat: combatRef.current, - setAttackAnim, setMoveAnim, setCombat, - setEnemyMoveAnims, setBodyParts, setBodyInjuries, - bodyParts: bodyPartsRef.current, - bodyInjuries: bodyInjuriesRef.current, - combatTimeRef, - } - for (const action of q) { - const handler = actionHandlers[action.type] - if (handler) handler(action, ctx) - } - actionQueueRef.current = [] - setActionQueue([]) + // ── Auto-dismiss hitShake after 0.3s ── + setHitShake(prev => { + if (!prev) return prev + return (performance.now() - prev.start) / 1000 >= 0.3 ? null : prev + }) - // ── enemy reacts after player commits - const enemyActions = runEnemyAI(combatRef.current) - for (const action of enemyActions) { - const handler = actionHandlers[action.type] - if (handler) handler(action, ctx) + // ── 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 + } + + // ── Update enemy actions ── + const cc = combatRef.current + const fresh = cc ? (() => { + const pp = positionsRef.current.player + return { + ...cc, + playerPos: pp ?? cc.playerPos, + enemies: cc.enemies.map((e, i) => { + const p = positionsRef.current[i] + return p ? { ...e, pos: p } : e + }) + } + })() : 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 + if (done) { + if (anim.type === 'enemyMove') { + const e = fresh?.enemies[i] ?? cc.enemies[i] + const finalPos = { x: anim.targets[i].toX, y: anim.targets[i].toY } + if (e && e.integrity.head > 0) { + const dist = Math.sqrt( + ((fresh ?? cc).playerPos.x - finalPos.x) ** 2 + ((fresh ?? cc).playerPos.y - finalPos.y) ** 2 + ) + if (dist <= 15) { + const wpn = e.weapon ?? 'fists' + const base = weaponMap[wpn] + const skill = (base?.skills ?? [{ name: 'Attack', mult: 1.0, damageType: 'blunt' }])[ + Math.floor(Math.random() * (base?.skills?.length ?? 1)) + ] + const { dmg, severity, injType, injLabel } = computeHit({ weaponId: wpn, skill, weaponConditions: {} }) + const targetPart = randomPart() + const action = { + type: 'attack', actor: 'enemy', enemyIndex: i, targetPart, + weaponId: wpn, skillName: skill.name, + dmg, severity, injType, injLabel, + attackerParts: { ...e.integrity }, + attackerInjuries: { ...e.injuries }, + targetParts: { ...bodyPartsRef.current }, + targetInjuries: { ...bodyInjuriesRef.current }, + } + enemyActionsRef.current[i] = makeAttackAnim(action, fresh) + continue + } + } + } + enemyActionsRef.current[i] = null } } } - // ── pause timer once everything settles (skip on ticks that dispatched - // actions, since animation refs won't update until React re-renders) - if ( - !dispatchedThisTick && - !enemyMoveAnimsRef.current && - !attackAnimRef.current && - !moveAnimRef.current && - actionQueueRef.current.length === 0 - ) { + // ── Run enemy AI every frame for idle enemies ── + if (fresh) { + const actions = runEnemyAI(fresh, bodyPartsRef.current, bodyInjuriesRef.current) + for (const act of actions) { + if (act.type === 'attack' && !enemyActionsRef.current[act.enemyIndex]) { + enemyActionsRef.current[act.enemyIndex] = makeAttackAnim(act, fresh) + } else if (act.type === 'enemyMove') { + for (const idxStr of Object.keys(act.targets)) { + const idx = parseInt(idxStr) + if (!enemyActionsRef.current[idx]) { + enemyActionsRef.current[idx] = { + type: 'enemyMove', enemyIndex: idx, + targets: act.targets, elapsed: 0 + } + } + } + } + } + } + + // ── Pause timer when player is idle; enemy actions freeze mid-state ── + const playerBusy = playerActionRef.current !== null + if (!playerBusy) { timerActiveRef.current = false setTimerRunning(false) } + setBusy(playerBusy) } rafId = requestAnimationFrame(tick) return () => cancelAnimationFrame(rafId) }, [combat, replayActive]) - // ───────────────────────────────────────────────────────────────────────── - // ATTACK ANIMATION (lerp player toward enemy, apply hit at peak) - // ───────────────────────────────────────────────────────────────────────── - - useEffect(() => { - if (replayActive) return - const anim = attackAnimRef.current - if (!anim) return - const startTs = performance.now() - const hitAt = 0.12 - let hit = false - const startPos = combatRef.current?.playerPos ?? { x: 20, y: 75 } - const targetE = anim.enemyIndex !== undefined ? combatRef.current?.enemies[anim.enemyIndex] : null - const targetPos= targetE?.pos ?? startPos - - const frame = requestAnimationFrame(function tick() { - const elapsed = (performance.now() - startTs) / 1000 - const lerp = elapsed < hitAt - ? elapsed / hitAt - : elapsed < 0.25 - ? 1 - (elapsed - hitAt) / (0.25 - hitAt) - : 0 - setAttackLerp({ from: startPos, to: targetPos, t: lerp }) - - if (elapsed >= hitAt && !hit) { - hit = true - setHitShake({ enemyIndex: anim.enemyIndex, startTs: performance.now() }) - - // degrade weapon condition - 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 decayedPlayer = calcIntegrity(anim.bodyParts, anim.bodyInjuries, hitAt) - let log = [...anim.combatLog] - const cc = combatRef.current - - if (anim.enemyIndex !== undefined && cc) { - const enemy = cc.enemies[anim.enemyIndex] - if (enemy) { - const decayedEnemy = calcIntegrity(enemy.integrity, enemy.injuries, hitAt) - const hitEnemy = { ...decayedEnemy, [anim.targetPart]: Math.max(0, decayedEnemy[anim.targetPart] - anim.dmg) } - const newInjuries = applyInjury(enemy.injuries, anim.targetPart, anim.injType, anim.severity) - - log.push(`You ${anim.skillName?.toLowerCase() ?? 'attack'} ${enemy.name}'s ${bodyPartLabels[anim.targetPart]} — ${anim.injLabel} (severity ${anim.severity}, -${anim.dmg} integrity).`) - if (hitEnemy.head <= 0) { - log.push(`You crush ${enemy.name}'s head!`) - log.push(`${enemy.name} is defeated!`) - } - - setCombat(prev => ({ - ...prev, - enemies: prev.enemies.map((e, i) => i === anim.enemyIndex ? { ...e, integrity: hitEnemy, injuries: newInjuries } : e), - log, - })) - } - } - - setBodyParts(decayedPlayer) - setBodyInjuries(anim.bodyInjuries) - } - - if (elapsed >= 0.35) { - setAttackAnim(null) - setAttackLerp(null) - setHitShake(null) - } else { - requestAnimationFrame(tick) - } - }) - return () => cancelAnimationFrame(frame) - }, [attackAnim]) - - // ───────────────────────────────────────────────────────────────────────── - // MOVE ANIMATION (player) - // Uses the shared computeMove helper. - // ───────────────────────────────────────────────────────────────────────── - - useEffect(() => { - if (replayActive) return - const anim = moveAnimRef.current - if (!anim) return - const frame = requestAnimationFrame(() => { - const newPos = computeMove( - { x: anim.fromX, y: anim.fromY }, - { x: anim.toX, y: anim.toY }, - anim.speed - ) - if (newPos.x === anim.toX && newPos.y === anim.toY) { - setCombat(c => ({ ...c, playerPos: newPos })) - setMoveAnim(null) - } else if (newPos.x === anim.fromX && newPos.y === anim.fromY) { - setMoveAnim(null) // blocked - } else { - setMoveAnim({ fromX: newPos.x, fromY: newPos.y, toX: anim.toX, toY: anim.toY, speed: anim.speed }) - setCombat(c => ({ ...c, playerPos: newPos })) - } - }) - return () => cancelAnimationFrame(frame) - }, [moveAnim]) - - // ───────────────────────────────────────────────────────────────────────── - // ENEMY MOVE ANIMATION - // ───────────────────────────────────────────────────────────────────────── - - useEffect(() => { - if (replayActive) return - const anims = enemyMoveAnimsRef.current - if (!anims || Object.keys(anims).length === 0) return - const startTs = performance.now() - const DURATION = 200 - const frame = requestAnimationFrame(function tick() { - const t = Math.min((performance.now() - startTs) / DURATION, 1) - setCombat(c => { - const enemies = [...c.enemies] - for (const idxStr of Object.keys(anims)) { - const idx = parseInt(idxStr) - const a = anims[idx] - enemies[idx] = { ...enemies[idx], pos: { x: a.fromX + (a.toX - a.fromX) * t, y: a.fromY + (a.toY - a.fromY) * t } } - } - return { ...c, enemies } - }) - if (t >= 1) setEnemyMoveAnims(null) - else requestAnimationFrame(tick) - }) - return () => cancelAnimationFrame(frame) - }, [enemyMoveAnims]) - - // ───────────────────────────────────────────────────────────────────────── - // HIT SHAKE - // ───────────────────────────────────────────────────────────────────────── - - useEffect(() => { - if (replayActive || !hitShake) return - const frame = requestAnimationFrame(function tick() { - if (!hitShake) return - if (performance.now() - hitShake.startTs >= 300) setHitShake(null) - else requestAnimationFrame(tick) - }) - return () => cancelAnimationFrame(frame) - }, [hitShake]) - // ───────────────────────────────────────────────────────────────────────── // TRAVEL ANIMATION (world map) // ───────────────────────────────────────────────────────────────────────── @@ -776,7 +815,6 @@ export default function App() { bodyParts: JSON.parse(JSON.stringify(bodyParts)), bodyInjuries: JSON.parse(JSON.stringify(bodyInjuries)), attackLerp: attackLerp ? { ...attackLerp } : null, - moveAnim: moveAnim ? { ...moveAnim } : null, hitShake: hitShake ? { ...hitShake } : null, }) } @@ -856,8 +894,8 @@ export default function App() { const rec = fightRecord const f0 = rec.frames[0] setCombat(f0.combat); setBodyParts(f0.bodyParts); setBodyInjuries(f0.bodyInjuries) - setAttackLerp(f0.attackLerp ?? null); setMoveAnim(f0.moveAnim ?? null); setHitShake(f0.hitShake ?? null) - setAttackAnim(null); setCombatTime(0); setDisplayTime(0) + setAttackLerp(f0.attackLerp ?? null); setHitShake(f0.hitShake ?? null) + setCombatTime(0); setDisplayTime(0) replayIdxRef.current = 0 const startTs = performance.now() @@ -868,7 +906,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 ?? null); setMoveAnim(f.moveAnim ?? null); setHitShake(f.hitShake ?? null) + setAttackLerp(f.attackLerp ?? null); setHitShake(f.hitShake ?? null) setCombatTime(f.time) } if (idx >= rec.frames.length - 1) return @@ -906,13 +944,16 @@ export default function App() { const enemies = list.map((npc, i) => makeEnemy(npc, i)) const initCombat = { playerPos: { x: 20, y: 75 }, log: [`You engage ${enemies.map(e => e.name).join(' and ')} in combat!`], enemies } setCombat(initCombat) - timerActiveRef.current = false; setTimerRunning(false) + timerActiveRef.current = false; setTimerRunning(false); setBusy(false) setCombatTime(0); setDisplayTime(0) + playerActionRef.current = null + enemyActionsRef.current = initCombat.enemies.map(() => null) + positionsRef.current = {} setSelectedEnemy(0); setReplayActive(false) setExpandedWeapon('ROOT') recordingRef.current = { initialCombat: JSON.parse(JSON.stringify(initCombat)), - frames: [{ time: 0, combat: JSON.parse(JSON.stringify(initCombat)), bodyParts: freshBody(), bodyInjuries: freshInjuries(), attackLerp: null, moveAnim: null, hitShake: null }], + frames: [{ time: 0, combat: JSON.parse(JSON.stringify(initCombat)), bodyParts: freshBody(), bodyInjuries: freshInjuries(), attackLerp: null, hitShake: null }], } } @@ -922,11 +963,11 @@ export default function App() { setCombatTime(0); setDisplayTime(0) } - /** Enqueue a player attack; timer starts immediately. */ + /** Execute a player attack; timer starts immediately. */ const executeAttack = () => { if (!limbSelect || !selectedTarget) return if (!combat) return - if (attackAnim || moveAnim || enemyMoveAnims) return + if (playerActionRef.current) return const ei = attackEnemy ?? selectedEnemy const enemy = combat.enemies[ei] if (!enemy || enemy.integrity.head <= 0) return @@ -937,14 +978,24 @@ export default function App() { const action = buildPlayerAttack({ weaponId, skillName: selectedSkill, targetPart: selectedTarget, enemyIndex: ei, equippedWeapons, weaponConditions, - bodyParts, bodyInjuries, combatLog: combat.log, + bodyParts, bodyInjuries, combat, }) - // Write to the ref immediately so the rAF loop sees it on the very next - // frame — before React has flushed the setActionQueue state update. - actionQueueRef.current = [...actionQueueRef.current, action] - setActionQueue(q => [...q, action]) - // Activate the timer via the ref (synchronous) AND the state (for the UI icon). + if (action.type === 'shove_pending') { + const enemy2 = combat.enemies[action.enemyIndex] + if (!enemy2 || enemy2.integrity.head <= 0) return + const { pos, actualPush } = computeShove(combat.playerPos, enemy2.pos) + setCombat(c => ({ + ...c, + enemies: c.enemies.map((e, i) => i === action.enemyIndex ? { ...e, pos } : e), + log: [...c.log, `You shove ${enemy2.name} back ${Math.round(actualPush)} units!`], + })) + timerActiveRef.current = true; setTimerRunning(true) + setLimbSelect(null); setSelectedSkill(null); setSelectedTarget(null); setAttackEnemy(null) + return + } + + playerActionRef.current = makeAttackAnim(action, combatRef.current) timerActiveRef.current = true; setTimerRunning(true) setLimbSelect(null); setSelectedSkill(null); setSelectedTarget(null); setAttackEnemy(null) } @@ -953,17 +1004,14 @@ export default function App() { setLimbSelect(null); setSelectedSkill(null); setSelectedTarget(null); setAttackEnemy(null) } - /** Enqueue a player move; timer starts immediately. */ + /** Execute a player move; timer starts immediately. */ const startMove = (targetX, targetY) => { - if (!combat || moveAnim || attackAnim) return + if (!combat || playerActionRef.current) return const fromX = combat.playerPos.x, fromY = combat.playerPos.y const dist = Math.sqrt((targetX - fromX) ** 2 + (targetY - fromY) ** 2) if (dist < 1) return const speed = moveMode === 'walk' ? 1 : 3 - const action = { type: 'move', actor: 'player', fromX, fromY, toX: targetX, toY: targetY, speed } - // Same pattern: ref-first so the loop sees it synchronously. - actionQueueRef.current = [...actionQueueRef.current, action] - setActionQueue(q => [...q, action]) + playerActionRef.current = { type: 'move', fromX, fromY, toX: targetX, toY: targetY, speed } timerActiveRef.current = true; setTimerRunning(true) setMoveMode(null) } @@ -1045,7 +1093,7 @@ export default function App() { const displayDay = skipTotalHours !== null ? Math.floor(skipTotalHours / 24) : gameTime.day const displayHour = skipTotalHours !== null ? Math.floor(skipTotalHours % 24) : gameTime.hour - const busy = !!(moveAnim || attackAnim || enemyMoveAnims) + // busy is React state, updated in the timer loop to reflect refs // ───────────────────────────────────────────────────────────────────────── // RENDER @@ -1209,20 +1257,26 @@ export default function App() { {MAP_BLOCKS.map((block, i) => )} - {(combat?.enemies || []).map((enemy, i) => ( - { e.stopPropagation(); setHoverInfo({ type: 'enemy', name: enemy.name, attack: enemy.attack, speed: enemy.speed }) }} - onMouseMove={e => setMousePos({ x: e.clientX, y: e.clientY })} - onMouseLeave={() => setHoverInfo(null)} - onClick={e => { e.stopPropagation(); setShowInventory(true); setPanelTarget(i) }} /> - ))} + {(combat?.enemies || []).map((enemy, i) => { + const eLerp = attackLerp && attackLerp.actor === 'enemy' && attackLerp.enemyIndex === i + return ( + { e.stopPropagation(); setHoverInfo({ type: 'enemy', name: enemy.name, attack: enemy.attack, speed: enemy.speed }) }} + onMouseMove={e => setMousePos({ x: e.clientX, y: e.clientY })} + onMouseLeave={() => setHoverInfo(null)} + onClick={e => { e.stopPropagation(); setShowInventory(true); setPanelTarget(i) }} /> + ) + })} {combat && selectedSkill && limbSelect && ( )} {combat && ( )}