fuzzyworld

explodorama / files / src / render / animate.ts

animate.ts

3.5 KB · history · blame

1import type { step } from '../sim/turn'
2import type { Action, GameState, Vec } from '../sim/types'
3import { FLAME_SECONDS, type Flame } from './board'
4import { 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 */
12export 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
23export 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. */
28export 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. */
37export 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 */
46export 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
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)
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 }
63 // Flame shows when the blast does (an ICBM's, when it lands).
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) })
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}