fuzzyworld

explodorama / files / src / render / effects.ts

effects.ts

14.3 KB · history · blame

1import { CHARGE_ICONS, coinSprite, GEM_SPRITES, type BoardSprite } from './sprites'
2import { FREEZE_TURNS } from '../sim/actions'
3import type { Drop, Enemy, GameEvent, GameState, Vec } from '../sim/types'
4
5/**
6 * Short animations started by a turn: things that appear, slide, tick or go away. The sim
7 * changes the board in one go; these play the change out over a fraction of a second. Times
8 * are LittleJS `time`, in seconds.
9 */
10
11/** Popping into place (bombs, drops, spawned enemies, the decoy, a revealed exit or power-up). */
12export const APPEAR_SECONDS = 0.22
13/** A bomb's number changing (its countdown, or a Fuse charge): a quick swell. */
14export const PULSE_SECONDS = 0.2
15/** How long each kind of disappearance takes. */
16export const GONE_SECONDS: Record<Gone['kind'], number> = {
17 enemy: 0.5,
18 crate: 0.4,
19 flame: 0.5,
20 taken: 0.4,
21 burned: 0.4,
22 shield: 0.35,
23}
24/** A kicked bomb slides this long per tile; a thrown one flies for THROW_SECONDS. */
25const KICK_SECONDS_PER_TILE = 0.05
26/**
27 * An ICBM's flight: this long, plus this much per tile (user request, 2026-10-24: it was instant;
28 * then, it should speed straight rather than arc like a throw — board.ts draws it).
29 */
30const ICBM_SECONDS = 0.25
31const ICBM_SECONDS_PER_TILE = 0.05
32const THROW_SECONDS = 0.25
33/** Line's bombs pop in one after another, this far apart. */
34const LINE_STAGGER = 0.05
35/** A thing picked up waits this long, for the player to arrive, before it rises away. */
36const TAKE_DELAY = 0.12
37/** Seconds a step's slide from tile to tile takes, the player's and the enemies'. */
38export const WALK_SECONDS = 0.16
39/** How long the ice takes to change from one Freeze turn to the next (freezing over, thawing a step). */
40export const FROST_SECONDS = 0.5
41
42/**
43 * Something no longer on the board, still drawn while it goes: an enemy killed, a crate
44 * destroyed, flame going out, a drop or power-up taken (it rises) or burned (it shrinks), the
45 * decoy gone, a shield broken.
46 */
47export type Gone = (
48 /** `from`: the tile it stepped off this turn, into the flame; it walks from there first. */
49 | { kind: 'enemy'; enemy: Enemy; from?: Vec }
50 | { kind: 'crate'; pos: Vec }
51 | { kind: 'flame'; pos: Vec }
52 | { kind: 'taken'; sprite: BoardSprite; pos: Vec; size: number }
53 | { kind: 'burned'; sprite: BoardSprite; pos: Vec; size: number }
54 | { kind: 'shield'; pos: Vec }
55) & { at: number }
56
57export interface Slide {
58 from: Vec
59 at: number
60 seconds: number
61 /** Thrown: flies in an arc instead of sliding along the floor. */
62 arc: boolean
63}
64
65export interface Effects {
66 /** When each thing appeared, by `key`. */
67 appeared: Map<string, number>
68 /** When each bomb's number last changed, by `key.bomb`. */
69 pulsed: Map<string, number>
70 /** Bombs on their way to their tile, by `key.bomb` of where they end up. */
71 slides: Map<string, Slide>
72 gone: Gone[]
73 /** The ice on the enemies changing: from one level to another (0 none, 1 frozen solid), starting at `at`. */
74 frost: { from: number; to: number; at: number } | null
75 /**
76 * ICBM bombs in the air: the sim has it go off the turn it is fired, so it isn't on the board
77 * any more; it is drawn flying in a high arc, and its blast shows when it lands (`blastAt`).
78 */
79 flights: { from: Vec; to: Vec; at: number; seconds: number }[]
80 /** When this turn's blasts show (an ICBM's landing); until then, the board shows them not yet happened. */
81 blastAt: number
82}
83
84export const key = {
85 bomb: (p: Vec) => `bomb:${p.x},${p.y}`,
86 drop: (p: Vec) => `drop:${p.x},${p.y}`,
87 enemy: (id: number) => `enemy:${id}`,
88 decoy: 'decoy',
89 gem: (p: Vec) => `gem:${p.x},${p.y}`,
90 exit: 'exit',
91 shield: 'shield',
92}
93
94export function newEffects(): Effects {
95 return { appeared: new Map(), pulsed: new Map(), slides: new Map(), gone: [], frost: null, flights: [], blastAt: 0 }
96}
97
98/** A new level, or the same one again: nothing from the old board carries over. */
99export function clearEffects(fx: Effects): void {
100 fx.appeared.clear()
101 fx.pulsed.clear()
102 fx.slides.clear()
103 fx.gone = []
104 fx.frost = null
105 fx.flights = []
106 fx.blastAt = 0
107}
108
109export function dropSprite(d: Drop): BoardSprite {
110 return d.kind === 'coins' ? coinSprite(d.amount) : CHARGE_ICONS[d.kind]
111}
112
113/** Start the animations for one turn, from the state before it, the state after, and its events. */
114export function startEffects(fx: Effects, prev: GameState, next: GameState, events: GameEvent[], now: number): void {
115 if (events.some((e) => e.type === 'levelStarted' || e.type === 'levelRestarted')) return clearEffects(fx)
116 // An ICBM (fired, and gone off where it landed this same turn) flies first: everything its blast
117 // does — and the rest of the turn's blasts — shows when it lands, at `boom`.
118 const icbm = events.find(
119 (e): e is Extract<GameEvent, { type: 'bombFired' }> =>
120 e.type === 'bombFired' && events.some((x) => x.type === 'exploded' && x.pos.x === e.to.x && x.pos.y === e.to.y),
121 )
122 const flight = icbm ? ICBM_SECONDS + ICBM_SECONDS_PER_TILE * (Math.abs(icbm.to.x - icbm.from.x) + Math.abs(icbm.to.y - icbm.from.y)) : 0
123 if (icbm) fx.flights.push({ from: icbm.from, to: icbm.to, at: now, seconds: flight })
124 const boom = now + flight
125 fx.blastAt = boom
126 // Bombs placed this turn: they pop in (or fly in) rather than swell at their first tick.
127 const placed = new Set<string>()
128 for (const e of events) {
129 switch (e.type) {
130 case 'bombPlaced': {
131 const far = Math.abs(e.pos.x - prev.player.x) + Math.abs(e.pos.y - prev.player.y) > 1
132 // Thrown 2 tiles: flies from the player. Line's bombs (the first one next to the player)
133 // pop in along the row, in order.
134 if (far && placed.size === 0) {
135 fx.slides.set(key.bomb(e.pos), { from: prev.player, at: now, seconds: THROW_SECONDS, arc: true })
136 } else fx.appeared.set(key.bomb(e.pos), now + placed.size * LINE_STAGGER)
137 placed.add(key.bomb(e.pos))
138 break
139 }
140 // Fired (RPG, ICBM): it flies straight from the player, as fast as a kicked bomb slides.
141 case 'bombFired': {
142 const tiles = Math.abs(e.to.x - e.from.x) + Math.abs(e.to.y - e.from.y)
143 fx.slides.set(key.bomb(e.to), { from: e.from, at: now, seconds: tiles * KICK_SECONDS_PER_TILE + 0.08, arc: false })
144 break
145 }
146 case 'bombKicked': {
147 const tiles = Math.abs(e.to.x - e.from.x) + Math.abs(e.to.y - e.from.y)
148 fx.slides.set(key.bomb(e.to), { from: e.from, at: now, seconds: tiles * KICK_SECONDS_PER_TILE + 0.08, arc: false })
149 break
150 }
151 case 'bombTicked':
152 if (!placed.has(key.bomb(e.pos))) fx.pulsed.set(key.bomb(e.pos), now)
153 break
154 case 'blockDestroyed':
155 case 'crateCrumbled':
156 fx.gone.push({ kind: 'crate', pos: e.pos, at: boom })
157 break
158 case 'enemyDied': {
159 const enemy = prev.enemies.find((o) => o.id === e.id)
160 // Walked into the flame this turn: it finishes that step first, then dies (user report,
161 // 2026-10-23: it died on the new tile without ever leaving the old one). From its change
162 // of tile, like every move.
163 const from = enemy && (enemy.pos.x !== e.pos.x || enemy.pos.y !== e.pos.y) ? enemy.pos : undefined
164 if (enemy) fx.gone.push({ kind: 'enemy', enemy: { ...enemy, pos: e.pos }, from, at: from ? Math.max(boom, now + WALK_SECONDS) : boom })
165 break
166 }
167 case 'dropSpawned':
168 fx.appeared.set(key.drop(e.drop.pos), boom)
169 break
170 case 'pickedUp':
171 fx.gone.push({ kind: 'taken', sprite: dropSprite(e.drop), pos: e.drop.pos, size: 0.62, at: now + TAKE_DELAY })
172 break
173 case 'dropBurned':
174 fx.gone.push({ kind: 'burned', sprite: dropSprite(e.drop), pos: e.drop.pos, size: 0.62, at: boom })
175 break
176 case 'powerUpRevealed':
177 fx.appeared.set(key.gem(e.powerUp.pos), boom)
178 break
179 case 'powerUpTaken':
180 case 'gemQueued':
181 fx.gone.push({ kind: 'taken', sprite: GEM_SPRITES[e.powerUp.color], pos: e.powerUp.pos, size: 0.7, at: now + TAKE_DELAY })
182 break
183 case 'powerUpBurned':
184 fx.gone.push({ kind: 'burned', sprite: GEM_SPRITES[e.powerUp.color], pos: e.powerUp.pos, size: 0.7, at: boom })
185 break
186 case 'exitRevealed':
187 fx.appeared.set(key.exit, boom)
188 break
189 case 'exitOpened':
190 fx.pulsed.set(key.exit, boom)
191 break
192 case 'decoyPlaced':
193 fx.appeared.set(key.decoy, now)
194 break
195 case 'decoyGone':
196 fx.gone.push({ kind: 'burned', sprite: 'character_green_front', pos: e.pos, size: 0.9, at: boom })
197 break
198 case 'shieldBroke':
199 fx.gone.push({ kind: 'shield', pos: e.pos, at: boom })
200 break
201 }
202 }
203 // Freezing over, or thawing a step.
204 if (prev.frozen !== next.frozen) fx.frost = { from: frostOf(prev), to: frostOf(next), at: now }
205 // Enemies that weren't there before (spawned from a burned exit or power-up) pop in.
206 for (const enemy of next.enemies) {
207 if (!prev.enemies.some((o) => o.id === enemy.id)) fx.appeared.set(key.enemy(enemy.id), boom)
208 }
209 // Last turn's flame that has now gone out fades.
210 const burning = new Set(next.burning)
211 for (const i of prev.burning) {
212 if (!burning.has(i)) fx.gone.push({ kind: 'flame', pos: { x: i % prev.width, y: Math.floor(i / prev.width) }, at: now })
213 }
214}
215
216/** How frozen the enemies are: 1 just frozen, then a third less each turn, 0 thawed. */
217function frostOf(s: GameState): number {
218 return Math.min(1, s.frozen / (FREEZE_TURNS - 1))
219}
220
221/** The ice on the enemies now, easing between turns. */
222export function frostLevel(fx: Effects, s: GameState, now: number): number {
223 if (!fx.frost) return frostOf(s)
224 const k = progress(fx.frost.at, FROST_SECONDS, now)
225 return fx.frost.from + (fx.frost.to - fx.frost.from) * k
226}
227
228/** Ice cracking as it thaws a step: a short shake, 0 otherwise. */
229export function frostCrack(fx: Effects, now: number): number {
230 if (!fx.frost || fx.frost.to >= fx.frost.from) return 0
231 const t = now - fx.frost.at
232 return t < 0.25 ? 0.03 * Math.sin(t * 70) * (1 - t / 0.25) : 0
233}
234
235/** Forget animations that have finished. */
236export function pruneEffects(fx: Effects, now: number): void {
237 fx.gone = fx.gone.filter((g) => now - g.at < GONE_SECONDS[g.kind])
238 for (const map of [fx.appeared, fx.pulsed]) {
239 for (const [k, at] of map) if (now - at > 1) map.delete(k)
240 }
241 for (const [k, s] of fx.slides) if (now - s.at > s.seconds) fx.slides.delete(k)
242 fx.flights = fx.flights.filter((f) => now - f.at < f.seconds)
243}
244
245/**
246 * A thing's size while it appears: 0 before it starts, swelling a little past full size, then
247 * settling. 1 when it isn't appearing.
248 */
249export function appearScale(fx: Effects, k: string, now: number): number {
250 const at = fx.appeared.get(k)
251 if (at === undefined) return 1
252 const t = (now - at) / APPEAR_SECONDS
253 if (t < 0) return 0
254 if (t >= 1) return 1
255 // Ease out with a slight overshoot ("back").
256 const c = 1.70158
257 return 1 + (c + 1) * (t - 1) ** 3 + c * (t - 1) ** 2
258}
259
260/** A short swell when a bomb's number (or the exit's state) changes. 1 otherwise. */
261export function pulseScale(fx: Effects, k: string, now: number, amount = 0.15): number {
262 const at = fx.pulsed.get(k)
263 if (at === undefined) return 1
264 const t = (now - at) / PULSE_SECONDS
265 return t >= 0 && t < 1 ? 1 + amount * Math.sin(t * Math.PI) : 1
266}
267
268/** 0 at the start of something's animation, 1 at its end (clamped). */
269export function progress(at: number, seconds: number, now: number): number {
270 return Math.min(1, Math.max(0, (now - at) / seconds))
271}
272
273/**
274 * The player's movement this turn, worked out from the state itself — their tile before and
275 * after — so nothing else that happens in the same turn (a death, the level cleared, a bomb) can
276 * skip it (user request, 2026-10-23, after several moves that weren't animated: into flame, into
277 * the doorway). Drawn as a slide, or for a teleport as vanishing and reappearing.
278 */
279export interface PlayerMove {
280 from: Vec
281 at: number
282 teleport: boolean
283 /** Tiles from `from` to where the player is now, in a straight line or not. */
284 tiles?: number
285}
286
287/**
288 * What the player does on top of moving, from the time `at` (and only once the move is done):
289 * crouch to place a bomb, jump when the level is cleared, the hit pose when caught.
290 */
291export interface PlayerPose {
292 kind: 'none' | 'duck' | 'jump' | 'hit'
293 at: number
294}
295
296/** How long the player's move takes on screen: a step's slide, or a teleport's vanish and reappear. */
297export function moveSeconds(move: PlayerMove): number {
298 if (move.teleport) return 2 * TELEPORT_SECONDS
299 return isSlide(move) ? SLIDE_SECONDS_PER_TILE * move.tiles! : WALK_SECONDS
300}
301
302/**
303 * A slide: further than a run's 2 tiles, not a teleport. It glides there crouched, slowing as it
304 * stops, at a speed per tile rather than in a step's time (user report, 2026-10-24: six tiles in a
305 * step's 0.16 s looked like a few jumps).
306 */
307export function isSlide(move: PlayerMove): boolean {
308 return !move.teleport && (move.tiles ?? 1) > 2
309}
310
311/** How long a slide takes per tile. */
312export const SLIDE_SECONDS_PER_TILE = 0.07
313
314/** How far along its move the player is, 0 to 1, `t` seconds in: a slide eases out, a step is even. */
315export function moveProgress(move: PlayerMove, t: number): number {
316 const k = Math.min(1, Math.max(0, t / moveSeconds(move)))
317 return isSlide(move) ? 1 - (1 - k) * (1 - k) : k
318}
319
320/** Seconds a teleport's vanishing (and then its reappearing) takes. */
321export const TELEPORT_SECONDS = 0.15
322
323/**
324 * The player's move and pose for a turn from `prev` to `next` with its events. A new level or a
325 * retry starts still (the player is put there, not walking).
326 */
327export function playerTransition(
328 prev: GameState,
329 next: GameState,
330 events: GameEvent[],
331 teleported: boolean,
332 now: number,
333): { move: PlayerMove | null; pose: PlayerPose } {
334 if (events.some((e) => e.type === 'levelStarted' || e.type === 'levelRestarted')) return { move: null, pose: { kind: 'none', at: now } }
335 const moved = prev.player.x !== next.player.x || prev.player.y !== next.player.y
336 const tiles = Math.abs(next.player.x - prev.player.x) + Math.abs(next.player.y - prev.player.y)
337 const move = moved ? { from: { ...prev.player }, at: now, teleport: teleported, tiles } : null
338 const kind = events.some((e) => e.type === 'playerDied')
339 ? 'hit'
340 : events.some((e) => e.type === 'levelCleared')
341 ? 'jump'
342 : events.some((e) => e.type === 'bombPlaced')
343 ? 'duck'
344 : 'none'
345 return { move, pose: { kind, at: now } }
346}