combat flow fix

This commit is contained in:
2026-06-02 04:30:31 -04:00
parent e75dc5a136
commit eba2031b99
+354 -300
View File
@@ -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() {
<g style={{ transform: `translate(${-minimapView.x * minimapView.scale}px, ${-minimapView.y * minimapView.scale}px) scale(${minimapView.scale})`, transformOrigin: '0 0' }}>
<rect x="0" y="0" width="160" height="160" fill="#171721" />
{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) => (
<circle key={i} cx={enemy.pos.x} cy={enemy.pos.y} r="5" fill="#e74c3c" stroke="#000" strokeWidth="0.5"
onMouseEnter={e => { 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 (
<circle key={i}
cx={eLerp ? attackLerp.from.x + (attackLerp.to.x - attackLerp.from.x) * attackLerp.t : enemy.pos.x}
cy={eLerp ? attackLerp.from.y + (attackLerp.to.y - attackLerp.from.y) * attackLerp.t : enemy.pos.y}
r="5" fill="#e74c3c" stroke="#000" strokeWidth="0.5"
onMouseEnter={e => { 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 && (
<circle cx={combat.playerPos.x} cy={combat.playerPos.y} r={getSkillRange(limbSelect, selectedSkill)} fill="none" stroke="rgba(52,152,219,0.5)" strokeWidth="1" strokeDasharray="4 4" />
)}
{combat && (
<circle
cx={attackLerp ? attackLerp.from.x + (attackLerp.to.x - attackLerp.from.x) * attackLerp.t : combat.playerPos.x}
cy={attackLerp ? attackLerp.from.y + (attackLerp.to.y - attackLerp.from.y) * attackLerp.t : combat.playerPos.y}
cx={attackLerp && attackLerp.actor === 'player' ? attackLerp.from.x + (attackLerp.to.x - attackLerp.from.x) * attackLerp.t : combat.playerPos.x}
cy={attackLerp && attackLerp.actor === 'player' ? attackLerp.from.y + (attackLerp.to.y - attackLerp.from.y) * attackLerp.t : combat.playerPos.y}
r="4" fill="#3498db" stroke="#000" strokeWidth="0.5" />
)}
</g>