explodorama / files / src / render / animate.ts
animate.ts
33a0256b 2026-10-24 Pavel Safronov | 1 | import type { step } from '../sim/turn' |
| 2 | import type { Action, GameState, Vec } from '../sim/types' | |
| 3 | import { FLAME_SECONDS, type Flame } from './board' | |
| 4 | import { clearEffects, newEffects, playerTransition, pruneEffects, startEffects, type Effects, type PlayerMove, type PlayerPose } from './effects' | |
| 5 | ||
| 6 | /** | |
| 7 | * Everything that animates a board between turns: what the last turns changed (effects.ts), the | |
| 8 | * flame of a blast, the player's move and pose and which way they face, and each enemy's latest | |
| 9 | * step. One for the game (and the demo, which plays on its board), one for each dialog's clip | |
| 10 | * (ui/clip.ts), so a clip can play beside the game (PLAN.md §13c). | |
| 11 | */ | |
| 12 | export interface Animation { | |
| 13 | fx: Effects | |
| 14 | flames: Flame[] | |
| 15 | playerMove: PlayerMove | null | |
| 16 | playerPose: PlayerPose | |
| 17 | /** The way the player last stepped. The character sprite faces right; this mirrors it. */ | |
| 18 | facingLeft: boolean | |
| 19 | /** Each enemy's latest step, by id: where from, and when (LittleJS `time`). */ | |
| 20 | enemySteps: Map<number, { from: Vec; at: number }> | |
| 21 | } | |
| 22 | ||
| 23 | export function newAnimation(): Animation { | |
| 24 | return { fx: newEffects(), flames: [], playerMove: null, playerPose: { kind: 'none', at: 0 }, facingLeft: false, enemySteps: new Map() } | |
| 25 | } | |
| 26 | ||
| 27 | /** A fresh board: nothing under way. */ | |
| 28 | export function clearAnimation(a: Animation): void { | |
| 29 | clearEffects(a.fx) | |
| 30 | a.flames = [] | |
| 31 | a.playerMove = null | |
| 32 | a.playerPose = { kind: 'none', at: 0 } | |
| 33 | a.enemySteps.clear() | |
| 34 | } | |
| 35 | ||
| 36 | /** Each frame: what has finished playing, dropped. */ | |
| 37 | export function pruneAnimation(a: Animation, now: number): void { | |
| 38 | a.flames = a.flames.filter((f) => now - f.born < FLAME_SECONDS) | |
| 39 | pruneEffects(a.fx, now) | |
| 40 | } | |
| 41 | ||
| 42 | /** | |
| 43 | * A turn played, from `prev` to `result.state`: everything that animates it starts at `now`. | |
| 44 | * True if a level started or restarted (the board is new: nothing walks over from the old one). | |
| 45 | */ | |
| 46 | export function animateTurn(a: Animation, prev: GameState, result: ReturnType<typeof step>, action: Action, now: number): boolean { | |
| 47 | const next = result.state | |
| 48 | startEffects(a.fx, prev, next, result.events, now) | |
| 49 | // The player's move comes from their change of tile, the pose from what happened; the pose | |
| 50 | // waits for the move, so nothing can skip it (effects.ts `playerTransition`). | |
| 51 | const player = playerTransition(prev, next, result.events, action.id === 'teleport', now) | |
| 52 | if (player.move) a.playerMove = player.move | |
e78b7781 2026-10-25 Pavel Safronov | 53 | // Hit by a blast that shows later (an ICBM's, when it lands): the hit pose waits for it too. |
| 54 | if (player.pose.kind === 'hit') player.pose.at = Math.max(player.pose.at, a.fx.blastAt) | |
33a0256b 2026-10-24 Pavel Safronov | 55 | if (player.move || player.pose.kind !== 'none') a.playerPose = player.pose |
| 56 | if (player.move && player.move.from.x !== next.player.x) a.facingLeft = next.player.x < player.move.from.x | |
| 57 | const newLevel = result.events.some((e) => e.type === 'levelStarted' || e.type === 'levelRestarted') | |
| 58 | if (newLevel) { | |
| 59 | a.playerMove = null | |
| 60 | a.playerPose = player.pose | |
| 61 | a.enemySteps.clear() | |
| 62 | } | |
e78b7781 2026-10-25 Pavel Safronov | 63 | // Flame shows when the blast does (an ICBM's, when it lands). |
5f066960 2026-10-26 Pavel Safronov | 64 | // Flame spread over oil or through bushes shows with it. |
| 65 | for (const e of result.events) if (e.type === 'exploded' || e.type === 'fireSpread') a.flames.push({ tiles: e.tiles, born: Math.max(now, a.fx.blastAt) }) | |
33a0256b 2026-10-24 Pavel Safronov | 66 | // Every enemy that changed tile steps from where it was: from the state, like the player. |
| 67 | if (!newLevel) { | |
| 68 | for (const enemy of next.enemies) { | |
| 69 | const was = prev.enemies.find((o) => o.id === enemy.id) | |
| 70 | if (was && (was.pos.x !== enemy.pos.x || was.pos.y !== enemy.pos.y)) a.enemySteps.set(enemy.id, { from: was.pos, at: now }) | |
| 71 | } | |
| 72 | } | |
| 73 | return newLevel | |
| 74 | } |