fuzzyworld

explodorama / files / src / sim / types.ts

types.ts

18.1 KB · history · blame

1/**
2 * The shapes of the game's state, actions and events. Everything in `sim/` is plain data and
3 * pure functions: no DOM, no LittleJS, no `Math.random()`.
4 */
5
6/** A tile position. x grows to the right, y grows downward; (0, 0) is the top-left corner. */
7export interface Vec {
8 x: number
9 y: number
10}
11
12export type Dir = 'up' | 'down' | 'left' | 'right'
13
14export const DIRS: Record<Dir, Vec> = {
15 up: { x: 0, y: -1 },
16 down: { x: 0, y: 1 },
17 left: { x: -1, y: 0 },
18 right: { x: 1, y: 0 },
19}
20
21export const DIR_LIST: readonly Dir[] = ['up', 'down', 'left', 'right']
22
23/**
24 * `wall` is a hard wall or pillar (indestructible); `block` is a soft block (flame destroys it).
25 * Since 2026-10-25 (PLAN.md §19.3, §19.4): `metal`, a metal crate (indestructible, anywhere);
26 * `barrel`, an oil barrel (blocks like a crate; flame lights it and it goes off the next turn);
27 * `rubble`, a fallen ceiling (indestructible, for good). Bushes, oil and spikes lie on floor.
28 */
29export type Cell = 'floor' | 'wall' | 'block' | 'metal' | 'barrel' | 'rubble'
30
31/** Cells nothing goes through or into, and flame stops before: walls, metal crates, rubble. */
32export function isSolid(cell: Cell): boolean {
33 return cell === 'wall' || cell === 'metal' || cell === 'rubble'
34}
35
36/** Spikes on a floor tile: up on the turns where (turn + phase) is even, and then they hurt. */
37export interface Spike {
38 cell: number
39 phase: number
40}
41
42export interface Bomb {
43 pos: Vec
44 /** How many more player actions until it explodes (PLAN.md §1). */
45 timer: number
46 /** Blast radius in tiles, in each of the 4 directions. */
47 fire: number
48 /** The turn it was placed on. A bomb is not ticked on the turn it is placed. */
49 placedTurn: number
50 /** Pierce: its flame goes through soft blocks, burning them all. Set from the player's boosts when placed. */
51 pierce?: boolean
52 /** Power: its flame reaches to the walls. */
53 power?: boolean
54 /** Nuke: it also burns every tile within NUKE_RANGE steps, through anything. */
55 nuke?: boolean
56 /** Proximity alert: it goes off the turn an enemy is in its reach (the power-up, when placed). */
57 proximity?: boolean
58 /** A lit oil barrel, not the player's: it doesn't count to their limit, nor can they set it off. */
59 barrel?: boolean
60 /** Acid (the charge, 2026-10-25): its flame also cracks each pillar it reaches. */
61 acid?: boolean
62}
63
64export interface Stats {
65 /** Most of the player's own bombs on the board at once. */
66 capacity: number
67 fire: number
68 /** The timer a newly placed bomb starts at. */
69 fuse: number
70}
71
72/**
73 * `dead`: lost a life and waiting for `continue`. `gameOver`: no lives left.
74 * `cleared`: stepped onto the open exit; `continue` goes to the next level.
75 * `won`: cleared the last level (PLAN.md §11); the run is over.
76 */
77export type Status = 'playing' | 'dead' | 'gameOver' | 'cleared' | 'won'
78
79/**
80 * Chosen when a run starts, fixed for the run, part of its record. (A board size and Crumbling
81 * crates were options too until 2026-10-23: now there is one board, and crates always crumble.)
82 */
83export interface RunOptions {
84 /**
85 * Developer mode: an extra, rainbow gem lies right of the start, offering every power-up not
86 * held yet; the level keeps its own. Nothing else.
87 */
88 dev: boolean
89 /**
90 * Immortal: nothing can kill the player, so the run
91 * can be played to the end — but it earns no achievements (when they exist: the victory
92 * certificate).
93 */
94 immortal: boolean
95 /**
96 * Rule variants for the balance lab to compare (PLAN.md §19.1), never set in play. `squeeze`: the
97 * old Diagonal, which could step between two blocked tiles (the rule until 2026-10-25).
98 * `underfoot`: the NES rule, a bomb goes on the player's own tile, not one next to them (to
99 * measure, 2026-10-25; the player walks off it, and nothing walks onto it).
100 */
101 lab?: { squeeze?: boolean; underfoot?: boolean }
102}
103
104export const DEFAULT_OPTIONS: RunOptions = { dev: false, immortal: false }
105
106/** The power-ups (PLAN.md §5, after the NES eight). Taking a level's gem offers a choice of them. */
107export type PowerUpKind =
108 | 'bombPlus' | 'firePlus' | 'tnt' | 'fusePlus' | 'run' | 'wallPass' | 'bombPass' | 'detonator'
109 | 'star' | 'coinBag' | 'heart' | 'kick' | 'throw' | 'diagonal'
110 | 'rpg' | 'proximity' | 'slide'
111
112/**
113 * A gem's colour, from the seed. Only how it looks: every level's gem offers the same kind of
114 * choice. Rainbow is only Developer mode's extra gem (user request, 2026-10-24).
115 */
116export type GemColor = 'blue' | 'green' | 'red' | 'yellow' | 'rainbow'
117
118/**
119 * The level's gem, hidden one per level under a soft block. Taking it offers OFFER_SIZE
120 * power-ups to choose one from (user request, 2026-10-23: it used to be one fixed power-up).
121 */
122export interface PowerUp {
123 pos: Vec
124 color: GemColor
125 /**
126 * Developer mode's extra gem, beside the start; the level keeps its own (user request,
127 * 2026-10-23). It is the rainbow one and offers every power-up not held yet (2026-10-24).
128 */
129 extra?: boolean
130}
131
132/**
133 * Abilities from power-ups. Run, Detonate, Kick, Throw, Teleport and Diagonal are actions; the
134 * passes change where the player can go. All are kept through a death (since 2026-10-23).
135 */
136export interface Powers {
137 /** The Run power-up (called Skates until 2026-10-22): 2 tiles in a straight line. */
138 run: boolean
139 /** Kick: walk into a bomb and it slides away. */
140 kick: boolean
141 /** Throw: a bomb 2 tiles away in a straight line, over a crate, bomb or enemy (not a pillar or wall). */
142 throw: boolean
143 /** Diagonal: Move can also step diagonally (a charge until 2026-10-23, user request). */
144 diagonal: boolean
145 /** Detonator: the Detonate action, set off one of your bombs now. */
146 detonate: boolean
147 /** Walk through soft blocks. */
148 wallPass: boolean
149 /** Walk over bombs. */
150 bombPass: boolean
151 /** RPG: Bomb can also fire a bomb in a straight line, as far as it flies (user request, 2026-10-24). */
152 rpg: boolean
153 /** Proximity alert: your bombs go off as soon as an enemy is in their reach. */
154 proximity: boolean
155 /** Slide: Move can also slide in a straight line until a wall, crate or bomb stops you. */
156 slide: boolean
157}
158
159/** The enemy types so far (PLAN.md §10); the Reaper is for later. */
160export type EnemyKind = 'blob' | 'seeker' | 'drifter' | 'sprinter' | 'ghost' | 'wisp' | 'hunter'
161
162export interface Enemy {
163 /** Stable for the enemy's life, so the renderer can follow it between turns. */
164 id: number
165 kind: EnemyKind
166 pos: Vec
167 /** The way its last planned move goes. Blobs and Drifters keep going this way. */
168 dir: Dir
169 /**
170 * Its next moves, up to PLAN_AHEAD of them, first one first (announced in phase 5, taken one
171 * per turn in phase 2): where it will be after each. Usually the next tile; the tile it is on
172 * for a rest (Ghost, Sprinter); 2 tiles on for a Sprinter's dash. Empty: it has nowhere to go
173 * and stays put.
174 */
175 plan: Vec[]
176 /** Spawned as a punishment (the exit was flamed): drops nothing. */
177 spawned: boolean
178 /**
179 * Just let out by a flamed exit or gem: the flame that let it out (still lingering next turn)
180 * can't hurt it; any new explosion can (user's choice, 2026-10-23: they used to die in it at once).
181 */
182 sheltered: boolean
183}
184
185/**
186 * The charges dropped by enemies (PLAN.md §3.3). Since 2026-10-23 (user request) they aren't
187 * actions: each works the moment it is picked up, as a temporary change (`applyCharge`).
188 * Pierce, Power and Nuke change the next BOMB_BOOST bombs; Shield blocks the next hit; Freeze
189 * freezes every enemy; Decoy leaves a copy of the player on the pickup tile. (Line was a charge
190 * until then, and Diagonal, which became a power-up the same day; Kick, Throw and Teleport until
191 * 2026-10-22; Fuse ±1 was removed on 2026-10-22.)
192 */
193export type ChargeId = 'pierce' | 'power' | 'nuke' | 'shield' | 'freeze' | 'decoy' | 'icbm' | 'flamePass' | 'teleport' | 'acid'
194
195/** Flame Pass and Teleport were power-ups until 2026-10-25, when the balance lab found each won the game alone. */
196export const CHARGE_IDS: readonly ChargeId[] = ['pierce', 'power', 'nuke', 'shield', 'freeze', 'decoy', 'icbm', 'flamePass', 'teleport', 'acid']
197
198/**
199 * What a gem can offer: a power-up, or a charge (only Developer mode's rainbow gem, to test them
200 * at once: user request, 2026-10-24). A chosen charge works as if picked up.
201 */
202export type OfferKind = PowerUpKind | ChargeId
203
204export function isCharge(kind: OfferKind): kind is ChargeId {
205 return (CHARGE_IDS as readonly OfferKind[]).includes(kind)
206}
207
208/** What picked-up charges still have to give. Kept from level to level and through deaths. */
209/**
210 * The one charge the player carries (PLAN.md §19.7; user request, 2026-10-24) and what is left
211 * of it: bombs for Pierce, Power and Nuke, launches for ICBM, 1 for the rest. Used deliberately,
212 * with the charge button (a Shield works by itself, when the player is hit).
213 */
214export interface HeldCharge {
215 kind: ChargeId
216 left: number
217}
218
219/** A stand-in for the player that chasing enemies go after instead, for a few turns. */
220/**
221 * A Decoy put down with the charge button: chasers head for it instead of the player. It lasts
222 * until the level ends, an enemy reaches it, or flame burns it (user request, 2026-10-25; it lasted
223 * 3 turns).
224 */
225export interface Decoy {
226 pos: Vec
227}
228
229/** Something lying on a floor tile to walk over and collect: coins, or one action charge. */
230export type Drop =
231 | { pos: Vec; kind: 'coins'; amount: number }
232 /** `left`: what is left of it, for a charge dropped where the player died (full when absent). */
233 | { pos: Vec; kind: ChargeId; left?: number }
234
235export interface GameState {
236 seed: number
237 options: RunOptions
238 level: number
239 /** Turns taken on this attempt at this level, starting at 0. */
240 turn: number
241 width: number
242 height: number
243 /** Row-major: the cell at (x, y) is `cells[y * width + x]`. */
244 cells: Cell[]
245 player: Vec
246 lives: number
247 stats: Stats
248 bombs: Bomb[]
249 status: Status
250 enemies: Enemy[]
251 nextEnemyId: number
252 drops: Drop[]
253 /** Under a soft block until that block burns. Open (walkable to win) once no enemies are left. */
254 exit: Vec
255 /** Finishing the level in at most this many turns pays PAR_BONUS coins. */
256 par: number
257 coins: number
258 /** The one charge carried, if any; kept to the next level and through a death. */
259 charge: HeldCharge | null
260 /**
261 * A charge just picked up while another is carried: the player sells it, or swaps it for the one
262 * held (which is sold). Nothing else happens until they choose; it takes no turn.
263 */
264 chargeOffer: HeldCharge | null
265 /**
266 * The board's objects on floor (PLAN.md §19.3; 2026-10-25): bushes (walk through; they burn, and
267 * fire spreads to the bushes touching them a turn later), oil (flame on any of a spill burns all
268 * of it at once), spikes (up every other turn). Cell indices.
269 */
270 bushes: number[]
271 /** Bushes that catch fire next turn, from a bush that burned this turn. */
272 smoldering: number[]
273 oil: number[]
274 spikes: Spike[]
275 /** Where the enemies last saw the player: a bush hides them, and chasers head here instead. */
276 lastSeen: Vec
277 /** Acid (§19.4): pillars cracked by an acid bomb; a second blast breaks them. Cell indices. */
278 cracked: number[]
279 /** The ceiling round a broken pillar, coming down: these tiles, in this many turns. */
280 collapsing: { cells: number[]; turns: number }[]
281 /** Freeze: how many more enemy moves are skipped (FREEZE_TURNS when used; the turn it is used counts). */
282 frozen: number
283 powers: Powers
284 /** Turns played and lives lost over the whole run, for the ending. */
285 runTurns: number
286 /** Enemies killed over the whole run, for the high-score board (2026-10-24). */
287 runKills: number
288 deaths: number
289 /** Turns of Star invincibility left. */
290 star: number
291 /**
292 * This level's gems: hidden while its block stands, then lying in the open until taken; gone
293 * once taken or burned. One per level, and in Developer mode an extra one (since 2026-10-23).
294 */
295 gems: PowerUp[]
296 /**
297 * The power-ups (and, from the rainbow gem, charges) the gem just taken offers. While it is
298 * set, the only action is `choose`; a death (or game over) in the same turn drops it, and the
299 * retry has the gem back.
300 */
301 offer: OfferKind[] | null
302 /** Gems taken on the same turn as the one on offer (a slide over two): each offers in turn, after the one before is answered. */
303 waitingGems: PowerUp[]
304 /** The shop, open after a cleared level every few levels until the player goes on (sim/shop.ts). */
305 shop: Shop | null
306 /**
307 * Tile indices still on fire from last turn's explosions. Flame lingers one extra turn: it
308 * kills creatures that are in it or walk into it this turn, and the player can't step or put
309 * a bomb there. It doesn't burn crates, drops or bombs a second time.
310 */
311 burning: number[]
312 /** How the player last died on this attempt, for the banner; null while alive. */
313 death: Death | null
314 /** Crumbling crates (once the last enemy is gone): tile index and turns until it is gone. */
315 crumbling: { cell: number; turns: number }[]
316 decoy: Decoy | null
317 /** The AI and drop random streams' positions (`Rng.state`), so a turn is a pure function of the state. */
318 aiRng: number
319 dropRng: number
320}
321
322/** The actions the player can pick on the board. */
323export type ActionId =
324 | 'move' | 'bomb' | 'wait' | 'run' | 'detonate' | 'kick' | 'throw' | 'teleport' | 'diagonal' | 'rpg' | 'icbm' | 'slide'
325 /** The carried charge, used (2026-10-24): a Pierce, Power or Nuke bomb; a Freeze; a Decoy. */
326 | 'charged' | 'freeze' | 'decoy'
327
328/** `choose`: one of the gem's offer. It takes no turn. */
329export type Action =
330 | { id: ActionId; target?: Vec }
331 | { id: 'continue' }
332 | { id: 'choose'; kind: OfferKind }
333 /** A charge picked up while carrying another: sell the new one, or swap it in (the old one sold). No turn. */
334 | { id: 'sellCharge' }
335 | { id: 'swapCharge' }
336 /** The same choice, a third way: use the charge held now (`fireableHeld`), then take the new one. No turn. */
337 | { id: 'fireCharge' }
338 /** In the shop (sim/shop.ts): buy one of its items, or restock it. */
339 | { id: 'buy'; kind: OfferKind }
340 | { id: 'reroll' }
341
342/** The shop between levels (sim/shop.ts, PLAN.md §8): what it has, what is sold, how often restocked. */
343export interface Shop {
344 items: OfferKind[]
345 sold: OfferKind[]
346 rerolls: number
347}
348
349/**
350 * How the player died, for the death and game-over banners (user request, 2026-10-24): walked
351 * into an enemy, or one walked into them; caught in a blast of a bomb that was due, or only by
352 * one a chain set off; or by flame still burning from last turn.
353 */
354export interface Death {
355 /** `spread`: flame over oil or through bushes, with no blast there (PLAN.md §19.3). */
356 /** `boxedIn`: nowhere to step or put a bomb, for good (2026-10-26). */
357 cause: 'walkedIntoEnemy' | 'caughtByEnemy' | 'blast' | 'chain' | 'lingering' | 'spread' | 'spikes' | 'crushed' | 'trapped' | 'boxedIn'
358 /** The enemy, for the two enemy causes. */
359 enemy?: EnemyKind
360 /** The turn it happened on (counted from 1, as the top bar shows after it). */
361 turn: number
362}
363
364/** What `step()` reports happened, in order, for the renderer to play back. */
365export type GameEvent =
366 | { type: 'moved'; from: Vec; to: Vec }
367 /** The player slid on over oil after a move: from the tile they landed on to where they stopped. */
368 | { type: 'oilSlid'; from: Vec; to: Vec }
369 | { type: 'waited' }
370 | { type: 'bombPlaced'; pos: Vec; timer: number }
371 | { type: 'bombTicked'; pos: Vec; timer: number }
372 | { type: 'exploded'; pos: Vec; tiles: Vec[] }
373 | { type: 'blockDestroyed'; pos: Vec }
374 | { type: 'playerDied'; pos: Vec; livesLeft: number }
375 | { type: 'levelRestarted' }
376 | { type: 'enemyMoved'; id: number; from: Vec; to: Vec }
377 | { type: 'enemyDied'; id: number; pos: Vec }
378 | { type: 'enemiesSpawned'; pos: Vec; count: number }
379 | { type: 'dropSpawned'; drop: Drop }
380 | { type: 'dropBurned'; drop: Drop }
381 | { type: 'pickedUp'; drop: Drop }
382 | { type: 'exitRevealed'; pos: Vec }
383 | { type: 'exitOpened'; pos: Vec }
384 | { type: 'levelCleared'; level: number; turns: number; clearBonus: number; parBonus: number }
385 | { type: 'levelStarted'; level: number }
386 | { type: 'bombKicked'; from: Vec; to: Vec }
387 /** An RPG or ICBM bomb flew from the player to where it landed. */
388 | { type: 'bombFired'; from: Vec; to: Vec }
389 /** Proximity alert: an enemy in a bomb's reach set it off. */
390 | { type: 'proximityTriggered'; pos: Vec }
391 | { type: 'shieldBroke'; pos: Vec }
392 /** A charge picked up while another was carried: the choice to make. */
393 | { type: 'chargeOffered'; kind: ChargeId; held: ChargeId }
394 /** A charge sold for coins: the new one, or the old one swapped out. */
395 | { type: 'chargeSold'; kind: ChargeId; coins: number }
396 | { type: 'flameBlocked'; pos: Vec; hitsLeft: number }
397 /** Freeze used: every enemy stops for FREEZE_TURNS turns. */
398 | { type: 'frozen'; turns: number }
399 | { type: 'decoyPlaced'; pos: Vec }
400 /** A Decoy gone: burned by flame, or reached by an enemy. */
401 | { type: 'decoyGone'; pos: Vec; burned: boolean }
402 | { type: 'powerUpRevealed'; powerUp: PowerUp }
403 | { type: 'powerUpBurned'; powerUp: PowerUp }
404 | { type: 'powerUpTaken'; powerUp: PowerUp; offer: OfferKind[] }
405 /** A second gem taken on the turn: it leaves the board now, its offer comes later (`offerAgain`). */
406 | { type: 'gemQueued'; powerUp: PowerUp }
407 /** A gem taken earlier this turn now makes its offer (the first one is answered). */
408 | { type: 'offerAgain'; powerUp: PowerUp; offer: OfferKind[] }
409 | { type: 'powerUpChosen'; kind: OfferKind }
410 | { type: 'shopOpened' }
411 | { type: 'bought'; kind: OfferKind; price: number }
412 | { type: 'rerolled'; price: number }
413 | { type: 'detonated'; pos: Vec }
414 | { type: 'runWon'; turns: number; deaths: number }
415 | { type: 'cratesCrumbling'; count: number }
416 | { type: 'crateCrumbled'; pos: Vec }
417 /** Flame that spread without a blast: over an oil spill, or through bushes (PLAN.md §19.3). */
418 | { type: 'fireSpread'; tiles: Vec[] }
419 /** Flame reached an oil barrel: it bursts, spilling oil, and goes off next turn. */
420 | { type: 'barrelLit'; pos: Vec }
421 /** Acid (PLAN.md §19.4): a pillar cracked; a cracked one broken, and the ceiling round it to fall. */
422 | { type: 'pillarCracked'; pos: Vec }
423 | { type: 'pillarBroke'; pos: Vec; tiles: Vec[]; turns: number }
424 | { type: 'ceilingFell'; tiles: Vec[] }