fuzzyworld

explodorama / files / src / sim / state.ts

state.ts

Blame: the commit that last changed each line · file · history

c37a2b0b 2026-10-20 Pavel Safronov1import { planIntents } from './enemies'
6e94f995 2026-10-24 Pavel Safronov2import { generateLevel } from './levelgen'
c37a2b0b 2026-10-20 Pavel Safronov3import { hashSeed } from './rng'
d3e4ed26 2026-10-25 Pavel Safronov4import { CHARGE_AMOUNT } from './charges'
5import { DEFAULT_OPTIONS, type Bomb, type Cell, type ChargeId, type GameState, type HeldCharge, type Powers, type RunOptions, type Stats, type Vec } from './types'
5551eb37 2026-10-20 Pavel Safronov6
7export const START_LIVES = 3
8export const START_STATS: Stats = { capacity: 1, fire: 1, fuse: 3 }
9
0b63d158 2026-10-25 Pavel Safronov10/** Coins awarded for clearing any level (since 2026-10-24, for the shop). */
11export const CLEAR_BONUS = 2
12/** Coins awarded on top for clearing a level within its par. */
c37a2b0b 2026-10-20 Pavel Safronov13export const PAR_BONUS = 5
14
15/** What the player takes from one attempt at a level into the next. */
16export interface Carry {
17 lives: number
18 stats: Stats
19 coins: number
d3e4ed26 2026-10-25 Pavel Safronov20 /**
5f066960 2026-10-26 Pavel Safronov21 * The charge carried goes on to the next level. A Shield or Flame Pass carried works by itself
22 * (since 2026-10-25), so it goes on with whatever uses it has left.
d3e4ed26 2026-10-25 Pavel Safronov23 */
24 charge: HeldCharge | null
fcf44f32 2026-10-25 Pavel Safronov25 /**
26 * A death drops the charge carried where the player fell (user's choice, 2026-10-25): the retry
27 * has it lying there, to pick up again.
28 */
29 fallen?: { pos: Vec; charge: HeldCharge } | null
27f24fed 2026-10-20 Pavel Safronov30 powers: Powers
31 star: number
32 runTurns: number
0580e85e 2026-10-24 Pavel Safronov33 runKills: number
27f24fed 2026-10-20 Pavel Safronov34 deaths: number
35}
36
37export function noPowers(): Powers {
7f242d36 2026-10-25 Pavel Safronov38 return {
fcf44f32 2026-10-25 Pavel Safronov39 run: false, kick: false, throw: false, diagonal: false, detonate: false, wallPass: false, bombPass: false,
7f242d36 2026-10-25 Pavel Safronov40 rpg: false, proximity: false, slide: false,
41 }
c37a2b0b 2026-10-20 Pavel Safronov42}
43
52d68b59 2026-10-24 Pavel Safronov44/**
d3e4ed26 2026-10-25 Pavel Safronov45 * `charge`: start carrying that charge, as if just picked up (a testing aid, `?charge=` in the
46 * URL; it was `?charges=n`, n of each, until one charge at a time, 2026-10-24).
52d68b59 2026-10-24 Pavel Safronov47 */
d3e4ed26 2026-10-25 Pavel Safronov48export function newRun(seed: number, level = 1, charge: ChargeId | null = null, options: RunOptions = DEFAULT_OPTIONS): GameState {
beb9f2b7 2026-10-21 Pavel Safronov49 return createLevel(seed, level, options, {
5f066960 2026-10-26 Pavel Safronov50 lives: START_LIVES, stats: { ...START_STATS }, coins: 0, charge: charge && { kind: charge, left: CHARGE_AMOUNT[charge] },
d3e4ed26 2026-10-25 Pavel Safronov51 powers: noPowers(), star: 0, runTurns: 0, runKills: 0, deaths: 0,
27f24fed 2026-10-20 Pavel Safronov52 })
c37a2b0b 2026-10-20 Pavel Safronov53}
54
55/**
56 * A fresh attempt at a level: the layout and enemies come from the seed, so a retry after a death
27f24fed 2026-10-20 Pavel Safronov57 * is the same board. What the player has comes with them (`carryOf` decides what a death costs).
c37a2b0b 2026-10-20 Pavel Safronov58 */
beb9f2b7 2026-10-21 Pavel Safronov59export function createLevel(seed: number, level: number, options: RunOptions, carry: Carry): GameState {
5f066960 2026-10-26 Pavel Safronov60 const { width, height, cells, start, exit, powerUp, enemies, par, bushes, oil, spikes } = generateLevel(seed, level)
c37a2b0b 2026-10-20 Pavel Safronov61 const s: GameState = {
beb9f2b7 2026-10-21 Pavel Safronov62 seed, options: { ...options }, level, turn: 0, width, height, cells, player: start,
d3e4ed26 2026-10-25 Pavel Safronov63 lives: carry.lives, stats: { ...carry.stats }, coins: carry.coins, charge: carry.charge && { ...carry.charge }, chargeOffer: null,
c37a2b0b 2026-10-20 Pavel Safronov64 bombs: [], status: 'playing',
52d68b59 2026-10-24 Pavel Safronov65 enemies: enemies.map((e, id) => ({ id, kind: e.kind, pos: e.pos, dir: e.dir, plan: [], spawned: false, sheltered: false })),
c37a2b0b 2026-10-20 Pavel Safronov66 nextEnemyId: enemies.length,
5f066960 2026-10-26 Pavel Safronov67 drops: [], exit, par, frozen: 0, decoy: null, burning: [],
8edb5cb9 2026-11-01 Pavel Safronov68 powers: { ...carry.powers }, star: carry.star, gems: [powerUp], offer: null, waitingGems: [], shop: null, death: null, runTurns: carry.runTurns, runKills: carry.runKills, deaths: carry.deaths, crumbling: [],
5f066960 2026-10-26 Pavel Safronov69 bushes, smoldering: [], oil, spikes, lastSeen: { ...start }, cracked: [], collapsing: [],
c37a2b0b 2026-10-20 Pavel Safronov70 aiRng: hashSeed(seed, 'ai', level), dropRng: hashSeed(seed, 'drops', level),
71 }
52d68b59 2026-10-24 Pavel Safronov72 // Developer mode: only this (user request, 2026-10-23; it used to give every ability, stats and
6e94f995 2026-10-24 Pavel Safronov73 // unlimited charges). An extra gem lies uncovered right of the start (always open floor), every
74 // level and after every death, to try the choice at once; the level keeps its own gem, hidden
75 // as usual (user request: it used to be that gem, moved). It is a rainbow gem, so it can't be
76 // taken for the level's own (user request, 2026-10-24).
77 if (options.dev) s.gems.push({ pos: { x: start.x + 1, y: start.y }, color: 'rainbow', extra: true })
fcf44f32 2026-10-25 Pavel Safronov78 // The charge dropped by a death, where the player fell (or the nearest open floor to it).
79 if (carry.fallen) s.drops.push({ pos: nearestFloor(s, carry.fallen.pos), kind: carry.fallen.charge.kind, left: carry.fallen.charge.left })
c37a2b0b 2026-10-20 Pavel Safronov80 planIntents(s)
81 return s
82}
83
27f24fed 2026-10-20 Pavel Safronov84/**
fcf44f32 2026-10-25 Pavel Safronov85 * What the player takes on, to the next level or the retry after a death: everything — except,
86 * since 2026-10-25 (user's choice), a death drops the charge carried where the player fell, for the
87 * retry to pick up again. Since
52d68b59 2026-10-24 Pavel Safronov88 * 2026-10-23 (user request) a death costs no power-ups; the NES rule lost the passes, Detonator
d603166d 2026-10-25 Pavel Safronov89 * and Star. (A Star can't be running at a death: nothing hurts the player while it lasts.) There
90 * is no Keep / Fresh choice (dropped 2026-10-24, PLAN.md §9): a retry is the same try again.
27f24fed 2026-10-20 Pavel Safronov91 */
52d68b59 2026-10-24 Pavel Safronov92export function carryOf(s: GameState): Carry {
27f24fed 2026-10-20 Pavel Safronov93 return {
5f066960 2026-10-26 Pavel Safronov94 lives: s.lives, stats: s.stats, coins: s.coins, powers: s.powers, star: s.star,
fcf44f32 2026-10-25 Pavel Safronov95 // A death leaves the charge where the player fell; otherwise it goes on with them.
96 charge: s.status === 'dead' ? null : s.charge,
97 fallen: s.status === 'dead' && s.charge ? { pos: { ...s.player }, charge: { ...s.charge } } : null,
0580e85e 2026-10-24 Pavel Safronov98 runTurns: s.runTurns, runKills: s.runKills, deaths: s.deaths,
27f24fed 2026-10-20 Pavel Safronov99 }
5551eb37 2026-10-20 Pavel Safronov100}
101
fcf44f32 2026-10-25 Pavel Safronov102/**
103 * `p` if it is open floor (no wall or crate), else the nearest
5f066960 2026-10-26 Pavel Safronov104 * open floor to it, by steps over the board (through anything but walls); a fallen charge can't
ebcd0a8f 2026-11-02 Pavel Safronov105 * lie inside a crate (with Crate Pass the player can die in one), nor an immortal player in rubble.
fcf44f32 2026-10-25 Pavel Safronov106 */
5f066960 2026-10-26 Pavel Safronov107export function nearestFloor(s: GameState, p: Vec): Vec {
fcf44f32 2026-10-25 Pavel Safronov108 const seen = new Set([idx(s, p)])
109 const queue = [p]
110 for (let i = 0; i < queue.length; i++) {
111 const q = queue[i]!
112 if (cellAt(s, q) === 'floor') return q
113 for (const d of [{ x: 1, y: 0 }, { x: -1, y: 0 }, { x: 0, y: 1 }, { x: 0, y: -1 }]) {
114 const n = add(q, d)
115 if (inBounds(s, n) && !seen.has(idx(s, n)) && cellAt(s, n) !== 'wall') {
116 seen.add(idx(s, n))
117 queue.push(n)
118 }
119 }
120 }
121 return s.player
122}
123
720cc9d3 2026-11-03 Pavel Safronov124/**
125 * The turn the player is on, counting from 1 (user request, 2026-11-02: 0-based feels odd outside
126 * programming). `s.turn` is the number of turns TAKEN, so while playing the one being chosen is the next;
127 * once the level is over the last one played is the count itself. Display only: the sim, par and scores keep the count.
128 */
129export function turnNumber(s: GameState): number {
130 return s.status === 'playing' ? s.turn + 1 : s.turn
131}
132
c37a2b0b 2026-10-20 Pavel Safronov133/** The exit can be used once its block is gone and every enemy is dead. */
134export function exitOpen(s: GameState): boolean {
135 return s.enemies.length === 0 && cellAt(s, s.exit) === 'floor'
5551eb37 2026-10-20 Pavel Safronov136}
137
138export function cloneState(s: GameState): GameState {
139 return structuredClone(s)
140}
141
142export function add(a: Vec, b: Vec): Vec {
143 return { x: a.x + b.x, y: a.y + b.y }
144}
145
146export function eq(a: Vec, b: Vec): boolean {
147 return a.x === b.x && a.y === b.y
148}
149
150export function idx(s: GameState, p: Vec): number {
151 return p.y * s.width + p.x
152}
153
154export function inBounds(s: GameState, p: Vec): boolean {
155 return p.x >= 0 && p.y >= 0 && p.x < s.width && p.y < s.height
156}
157
158/** Outside the grid counts as wall. */
159export function cellAt(s: GameState, p: Vec): Cell {
160 return inBounds(s, p) ? s.cells[idx(s, p)]! : 'wall'
161}
162
163export function bombAt(s: GameState, p: Vec): Bomb | undefined {
164 return s.bombs.find((b) => eq(b.pos, p))
165}
166
1f0038ac 2026-11-01 Pavel Safronov167/**
168 * Oil is slippery (user request, 2026-10-31). Whoever lands on an oil tile heading `d` keeps
169 * going over oil, and stops on the last oiled tile of the line. Returns the tiles it slides
170 * over after `from`, the last being where it stops; none if `from` isn't oil, or the next tile
171 * isn't oil or `canEnter` refuses it (a bomb, an enemy...).
172 */
173export function oilSlide(s: GameState, from: Vec, d: Vec, canEnter: (p: Vec) => boolean): Vec[] {
174 const tiles: Vec[] = []
175 let p = from
176 while (s.oil.includes(idx(s, p))) {
177 const q = add(p, d)
178 if (!inBounds(s, q) || !s.oil.includes(idx(s, q)) || !canEnter(q)) break
179 tiles.push(q)
180 p = q
181 }
182 return tiles
183}
184
5551eb37 2026-10-20 Pavel Safronov185/** Nothing in the way: floor, and no bomb on it. */
186export function isFree(s: GameState, p: Vec): boolean {
187 return cellAt(s, p) === 'floor' && !bombAt(s, p)
188}
82adee18 2026-10-20 Pavel Safronov189
190/** Still on fire from last turn's explosion (see `GameState.burning`). */
191export function isBurning(s: GameState, p: Vec): boolean {
192 return inBounds(s, p) && s.burning.includes(idx(s, p))
193}
194
ebcd0a8f 2026-11-02 Pavel Safronov195/** Where the player's body can go, fire aside: floor, or a soft block with Crate Pass; a bomb only with Bomb Pass. */
27f24fed 2026-10-20 Pavel Safronov196export function playerCanPass(s: GameState, p: Vec): boolean {
197 const cell = cellAt(s, p)
198 return (cell === 'floor' || (cell === 'block' && s.powers.wallPass)) && (!bombAt(s, p) || s.powers.bombPass)
199}
200
fcf44f32 2026-10-25 Pavel Safronov201/**
5f066960 2026-10-26 Pavel Safronov202 * The player may go into flame this turn: a Star or Immortal (nothing hurts), or a Flame Pass
203 * carried (each turn in flame then uses one of its hits).
fcf44f32 2026-10-25 Pavel Safronov204 */
99869630 2026-10-23 Pavel Safronov205export function flameSafe(s: GameState): boolean {
5f066960 2026-10-26 Pavel Safronov206 return s.charge?.kind === 'flamePass' || s.star > 0 || s.options.immortal
99869630 2026-10-23 Pavel Safronov207}
208
209/**
210 * Where the player can step or land now: passable, and not on fire unless fire is safe for them
211 * (user request, 2026-10-22: with a Star it used to be off limits too).
212 */
82adee18 2026-10-20 Pavel Safronov213export function playerCanEnter(s: GameState, p: Vec): boolean {
99869630 2026-10-23 Pavel Safronov214 return playerCanPass(s, p) && (!isBurning(s, p) || flameSafe(s))
82adee18 2026-10-20 Pavel Safronov215}