explodorama / files / src / render / effects.ts
effects.ts
52d68b59 2026-10-24 Pavel Safronov | 1 | import { CHARGE_ICONS, coinSprite, GEM_SPRITES, type BoardSprite } from './sprites' |
99869630 2026-10-23 Pavel Safronov | 2 | import { FREEZE_TURNS } from '../sim/actions' |
ea380ee8 2026-10-22 Pavel Safronov | 3 | import 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). */ | |
| 12 | export const APPEAR_SECONDS = 0.22 | |
| 13 | /** A bomb's number changing (its countdown, or a Fuse charge): a quick swell. */ | |
| 14 | export const PULSE_SECONDS = 0.2 | |
| 15 | /** How long each kind of disappearance takes. */ | |
| 16 | export 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. */ | |
| 25 | const KICK_SECONDS_PER_TILE = 0.05 | |
e78b7781 2026-10-25 Pavel Safronov | 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 | */ | |
| 30 | const ICBM_SECONDS = 0.25 | |
| 31 | const ICBM_SECONDS_PER_TILE = 0.05 | |
ea380ee8 2026-10-22 Pavel Safronov | 32 | const THROW_SECONDS = 0.25 |
| 33 | /** Line's bombs pop in one after another, this far apart. */ | |
| 34 | const LINE_STAGGER = 0.05 | |
| 35 | /** A thing picked up waits this long, for the player to arrive, before it rises away. */ | |
| 36 | const TAKE_DELAY = 0.12 | |
fa3e81a7 2026-10-24 Pavel Safronov | 37 | /** Seconds a step's slide from tile to tile takes, the player's and the enemies'. */ |
| 38 | export const WALK_SECONDS = 0.16 | |
99869630 2026-10-23 Pavel Safronov | 39 | /** How long the ice takes to change from one Freeze turn to the next (freezing over, thawing a step). */ |
| 40 | export const FROST_SECONDS = 0.5 | |
ea380ee8 2026-10-22 Pavel Safronov | 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 | */ | |
| 47 | export type Gone = ( | |
fa3e81a7 2026-10-24 Pavel Safronov | 48 | /** `from`: the tile it stepped off this turn, into the flame; it walks from there first. */ |
| 49 | | { kind: 'enemy'; enemy: Enemy; from?: Vec } | |
ea380ee8 2026-10-22 Pavel Safronov | 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 | ||
| 57 | export 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 | ||
| 65 | export 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[] | |
99869630 2026-10-23 Pavel Safronov | 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 | |
e78b7781 2026-10-25 Pavel Safronov | 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 | |
ea380ee8 2026-10-22 Pavel Safronov | 82 | } |
| 83 | ||
| 84 | export 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', | |
39c26813 2026-10-24 Pavel Safronov | 89 | gem: (p: Vec) => `gem:${p.x},${p.y}`, |
ea380ee8 2026-10-22 Pavel Safronov | 90 | exit: 'exit', |
| 91 | shield: 'shield', | |
| 92 | } | |
| 93 | ||
| 94 | export function newEffects(): Effects { | |
e78b7781 2026-10-25 Pavel Safronov | 95 | return { appeared: new Map(), pulsed: new Map(), slides: new Map(), gone: [], frost: null, flights: [], blastAt: 0 } |
ea380ee8 2026-10-22 Pavel Safronov | 96 | } |
| 97 | ||
| 98 | /** A new level, or the same one again: nothing from the old board carries over. */ | |
| 99 | export function clearEffects(fx: Effects): void { | |
| 100 | fx.appeared.clear() | |
| 101 | fx.pulsed.clear() | |
| 102 | fx.slides.clear() | |
| 103 | fx.gone = [] | |
99869630 2026-10-23 Pavel Safronov | 104 | fx.frost = null |
e78b7781 2026-10-25 Pavel Safronov | 105 | fx.flights = [] |
| 106 | fx.blastAt = 0 | |
ea380ee8 2026-10-22 Pavel Safronov | 107 | } |
| 108 | ||
| 109 | export 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. */ | |
| 114 | export 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) | |
e78b7781 2026-10-25 Pavel Safronov | 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 | |
ea380ee8 2026-10-22 Pavel Safronov | 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 | } | |
7f242d36 2026-10-25 Pavel Safronov | 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 | } | |
ea380ee8 2026-10-22 Pavel Safronov | 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': | |
e78b7781 2026-10-25 Pavel Safronov | 156 | fx.gone.push({ kind: 'crate', pos: e.pos, at: boom }) |
ea380ee8 2026-10-22 Pavel Safronov | 157 | break |
| 158 | case 'enemyDied': { | |
| 159 | const enemy = prev.enemies.find((o) => o.id === e.id) | |
fa3e81a7 2026-10-24 Pavel Safronov | 160 | // Walked into the flame this turn: it finishes that step first, then dies (user report, |
00ad7d0a 2026-10-24 Pavel Safronov | 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 | |
e78b7781 2026-10-25 Pavel Safronov | 164 | if (enemy) fx.gone.push({ kind: 'enemy', enemy: { ...enemy, pos: e.pos }, from, at: from ? Math.max(boom, now + WALK_SECONDS) : boom }) |
ea380ee8 2026-10-22 Pavel Safronov | 165 | break |
| 166 | } | |
| 167 | case 'dropSpawned': | |
e78b7781 2026-10-25 Pavel Safronov | 168 | fx.appeared.set(key.drop(e.drop.pos), boom) |
ea380ee8 2026-10-22 Pavel Safronov | 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': | |
e78b7781 2026-10-25 Pavel Safronov | 174 | fx.gone.push({ kind: 'burned', sprite: dropSprite(e.drop), pos: e.drop.pos, size: 0.62, at: boom }) |
ea380ee8 2026-10-22 Pavel Safronov | 175 | break |
| 176 | case 'powerUpRevealed': | |
e78b7781 2026-10-25 Pavel Safronov | 177 | fx.appeared.set(key.gem(e.powerUp.pos), boom) |
ea380ee8 2026-10-22 Pavel Safronov | 178 | break |
| 179 | case 'powerUpTaken': | |
8edb5cb9 2026-11-01 Pavel Safronov | 180 | case 'gemQueued': |
52d68b59 2026-10-24 Pavel Safronov | 181 | fx.gone.push({ kind: 'taken', sprite: GEM_SPRITES[e.powerUp.color], pos: e.powerUp.pos, size: 0.7, at: now + TAKE_DELAY }) |
ea380ee8 2026-10-22 Pavel Safronov | 182 | break |
| 183 | case 'powerUpBurned': | |
e78b7781 2026-10-25 Pavel Safronov | 184 | fx.gone.push({ kind: 'burned', sprite: GEM_SPRITES[e.powerUp.color], pos: e.powerUp.pos, size: 0.7, at: boom }) |
ea380ee8 2026-10-22 Pavel Safronov | 185 | break |
| 186 | case 'exitRevealed': | |
e78b7781 2026-10-25 Pavel Safronov | 187 | fx.appeared.set(key.exit, boom) |
ea380ee8 2026-10-22 Pavel Safronov | 188 | break |
| 189 | case 'exitOpened': | |
e78b7781 2026-10-25 Pavel Safronov | 190 | fx.pulsed.set(key.exit, boom) |
ea380ee8 2026-10-22 Pavel Safronov | 191 | break |
| 192 | case 'decoyPlaced': | |
| 193 | fx.appeared.set(key.decoy, now) | |
| 194 | break | |
| 195 | case 'decoyGone': | |
e78b7781 2026-10-25 Pavel Safronov | 196 | fx.gone.push({ kind: 'burned', sprite: 'character_green_front', pos: e.pos, size: 0.9, at: boom }) |
ea380ee8 2026-10-22 Pavel Safronov | 197 | break |
| 198 | case 'shieldBroke': | |
e78b7781 2026-10-25 Pavel Safronov | 199 | fx.gone.push({ kind: 'shield', pos: e.pos, at: boom }) |
ea380ee8 2026-10-22 Pavel Safronov | 200 | break |
| 201 | } | |
| 202 | } | |
99869630 2026-10-23 Pavel Safronov | 203 | // Freezing over, or thawing a step. |
| 204 | if (prev.frozen !== next.frozen) fx.frost = { from: frostOf(prev), to: frostOf(next), at: now } | |
ea380ee8 2026-10-22 Pavel Safronov | 205 | // Enemies that weren't there before (spawned from a burned exit or power-up) pop in. |
| 206 | for (const enemy of next.enemies) { | |
e78b7781 2026-10-25 Pavel Safronov | 207 | if (!prev.enemies.some((o) => o.id === enemy.id)) fx.appeared.set(key.enemy(enemy.id), boom) |
ea380ee8 2026-10-22 Pavel Safronov | 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 | ||
99869630 2026-10-23 Pavel Safronov | 216 | /** How frozen the enemies are: 1 just frozen, then a third less each turn, 0 thawed. */ |
| 217 | function 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. */ | |
| 222 | export 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. */ | |
| 229 | export 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 | ||
ea380ee8 2026-10-22 Pavel Safronov | 235 | /** Forget animations that have finished. */ |
| 236 | export 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) | |
e78b7781 2026-10-25 Pavel Safronov | 242 | fx.flights = fx.flights.filter((f) => now - f.at < f.seconds) |
ea380ee8 2026-10-22 Pavel Safronov | 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 | */ | |
| 249 | export 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. */ | |
| 261 | export 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). */ | |
| 269 | export function progress(at: number, seconds: number, now: number): number { | |
| 270 | return Math.min(1, Math.max(0, (now - at) / seconds)) | |
| 271 | } | |
00ad7d0a 2026-10-24 Pavel Safronov | 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 | */ | |
| 279 | export interface PlayerMove { | |
| 280 | from: Vec | |
| 281 | at: number | |
| 282 | teleport: boolean | |
7f242d36 2026-10-25 Pavel Safronov | 283 | /** Tiles from `from` to where the player is now, in a straight line or not. */ |
| 284 | tiles?: number | |
00ad7d0a 2026-10-24 Pavel Safronov | 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 | */ | |
| 291 | export 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. */ | |
| 297 | export function moveSeconds(move: PlayerMove): number { | |
7f242d36 2026-10-25 Pavel Safronov | 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 | */ | |
| 307 | export function isSlide(move: PlayerMove): boolean { | |
| 308 | return !move.teleport && (move.tiles ?? 1) > 2 | |
| 309 | } | |
| 310 | ||
| 311 | /** How long a slide takes per tile. */ | |
| 312 | export 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. */ | |
| 315 | export 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 | |
00ad7d0a 2026-10-24 Pavel Safronov | 318 | } |
| 319 | ||
| 320 | /** Seconds a teleport's vanishing (and then its reappearing) takes. */ | |
| 321 | export 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 | */ | |
| 327 | export 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 | |
7f242d36 2026-10-25 Pavel Safronov | 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 | |
00ad7d0a 2026-10-24 Pavel Safronov | 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 | } |