combat flow fix
This commit is contained in:
+350
-296
@@ -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"
|
||||
{(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>
|
||||
|
||||
Reference in New Issue
Block a user