explodorama / files / src / sim / state.ts
state.ts
c37a2b0b 2026-10-20 Pavel Safronov | 1 | import { planIntents } from './enemies' |
6e94f995 2026-10-24 Pavel Safronov | 2 | import { generateLevel } from './levelgen' |
c37a2b0b 2026-10-20 Pavel Safronov | 3 | import { hashSeed } from './rng' |
d3e4ed26 2026-10-25 Pavel Safronov | 4 | import { CHARGE_AMOUNT } from './charges' |
| 5 | import { 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 Safronov | 6 | |
| 7 | export const START_LIVES = 3 | |
| 8 | export const START_STATS: Stats = { capacity: 1, fire: 1, fuse: 3 } | |
| 9 | ||
0b63d158 2026-10-25 Pavel Safronov | 10 | /** Coins awarded for clearing any level (since 2026-10-24, for the shop). */ |
| 11 | export const CLEAR_BONUS = 2 | |
| 12 | /** Coins awarded on top for clearing a level within its par. */ | |
c37a2b0b 2026-10-20 Pavel Safronov | 13 | export const PAR_BONUS = 5 |
| 14 | ||
| 15 | /** What the player takes from one attempt at a level into the next. */ | |
| 16 | export interface Carry { | |
| 17 | lives: number | |
| 18 | stats: Stats | |
| 19 | coins: number | |
d3e4ed26 2026-10-25 Pavel Safronov | 20 | /** |
5f066960 2026-10-26 Pavel Safronov | 21 | * 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 Safronov | 23 | */ |
| 24 | charge: HeldCharge | null | |
fcf44f32 2026-10-25 Pavel Safronov | 25 | /** |
| 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 Safronov | 30 | powers: Powers |
| 31 | star: number | |
| 32 | runTurns: number | |
0580e85e 2026-10-24 Pavel Safronov | 33 | runKills: number |
27f24fed 2026-10-20 Pavel Safronov | 34 | deaths: number |
| 35 | } | |
| 36 | ||
| 37 | export function noPowers(): Powers { | |
7f242d36 2026-10-25 Pavel Safronov | 38 | return { |
fcf44f32 2026-10-25 Pavel Safronov | 39 | run: false, kick: false, throw: false, diagonal: false, detonate: false, wallPass: false, bombPass: false, |
7f242d36 2026-10-25 Pavel Safronov | 40 | rpg: false, proximity: false, slide: false, |
| 41 | } | |
c37a2b0b 2026-10-20 Pavel Safronov | 42 | } |
| 43 | ||
52d68b59 2026-10-24 Pavel Safronov | 44 | /** |
d3e4ed26 2026-10-25 Pavel Safronov | 45 | * `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 Safronov | 47 | */ |
d3e4ed26 2026-10-25 Pavel Safronov | 48 | export function newRun(seed: number, level = 1, charge: ChargeId | null = null, options: RunOptions = DEFAULT_OPTIONS): GameState { |
beb9f2b7 2026-10-21 Pavel Safronov | 49 | return createLevel(seed, level, options, { |
5f066960 2026-10-26 Pavel Safronov | 50 | lives: START_LIVES, stats: { ...START_STATS }, coins: 0, charge: charge && { kind: charge, left: CHARGE_AMOUNT[charge] }, |
d3e4ed26 2026-10-25 Pavel Safronov | 51 | powers: noPowers(), star: 0, runTurns: 0, runKills: 0, deaths: 0, |
27f24fed 2026-10-20 Pavel Safronov | 52 | }) |
c37a2b0b 2026-10-20 Pavel Safronov | 53 | } |
| 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 Safronov | 57 | * is the same board. What the player has comes with them (`carryOf` decides what a death costs). |
c37a2b0b 2026-10-20 Pavel Safronov | 58 | */ |
beb9f2b7 2026-10-21 Pavel Safronov | 59 | export function createLevel(seed: number, level: number, options: RunOptions, carry: Carry): GameState { |
5f066960 2026-10-26 Pavel Safronov | 60 | const { width, height, cells, start, exit, powerUp, enemies, par, bushes, oil, spikes } = generateLevel(seed, level) |
c37a2b0b 2026-10-20 Pavel Safronov | 61 | const s: GameState = { |
beb9f2b7 2026-10-21 Pavel Safronov | 62 | seed, options: { ...options }, level, turn: 0, width, height, cells, player: start, |
d3e4ed26 2026-10-25 Pavel Safronov | 63 | lives: carry.lives, stats: { ...carry.stats }, coins: carry.coins, charge: carry.charge && { ...carry.charge }, chargeOffer: null, |
c37a2b0b 2026-10-20 Pavel Safronov | 64 | bombs: [], status: 'playing', |
52d68b59 2026-10-24 Pavel Safronov | 65 | 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 Safronov | 66 | nextEnemyId: enemies.length, |
5f066960 2026-10-26 Pavel Safronov | 67 | drops: [], exit, par, frozen: 0, decoy: null, burning: [], |
8edb5cb9 2026-11-01 Pavel Safronov | 68 | 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 Safronov | 69 | bushes, smoldering: [], oil, spikes, lastSeen: { ...start }, cracked: [], collapsing: [], |
c37a2b0b 2026-10-20 Pavel Safronov | 70 | aiRng: hashSeed(seed, 'ai', level), dropRng: hashSeed(seed, 'drops', level), |
| 71 | } | |
52d68b59 2026-10-24 Pavel Safronov | 72 | // Developer mode: only this (user request, 2026-10-23; it used to give every ability, stats and |
6e94f995 2026-10-24 Pavel Safronov | 73 | // 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 Safronov | 78 | // 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 Safronov | 80 | planIntents(s) |
| 81 | return s | |
| 82 | } | |
| 83 | ||
27f24fed 2026-10-20 Pavel Safronov | 84 | /** |
fcf44f32 2026-10-25 Pavel Safronov | 85 | * 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 Safronov | 88 | * 2026-10-23 (user request) a death costs no power-ups; the NES rule lost the passes, Detonator |
d603166d 2026-10-25 Pavel Safronov | 89 | * 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 Safronov | 91 | */ |
52d68b59 2026-10-24 Pavel Safronov | 92 | export function carryOf(s: GameState): Carry { |
27f24fed 2026-10-20 Pavel Safronov | 93 | return { |
5f066960 2026-10-26 Pavel Safronov | 94 | lives: s.lives, stats: s.stats, coins: s.coins, powers: s.powers, star: s.star, |
fcf44f32 2026-10-25 Pavel Safronov | 95 | // 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 Safronov | 98 | runTurns: s.runTurns, runKills: s.runKills, deaths: s.deaths, |
27f24fed 2026-10-20 Pavel Safronov | 99 | } |
5551eb37 2026-10-20 Pavel Safronov | 100 | } |
| 101 | ||
fcf44f32 2026-10-25 Pavel Safronov | 102 | /** |
| 103 | * `p` if it is open floor (no wall or crate), else the nearest | |
5f066960 2026-10-26 Pavel Safronov | 104 | * open floor to it, by steps over the board (through anything but walls); a fallen charge can't |
ebcd0a8f 2026-11-02 Pavel Safronov | 105 | * lie inside a crate (with Crate Pass the player can die in one), nor an immortal player in rubble. |
fcf44f32 2026-10-25 Pavel Safronov | 106 | */ |
5f066960 2026-10-26 Pavel Safronov | 107 | export function nearestFloor(s: GameState, p: Vec): Vec { |
fcf44f32 2026-10-25 Pavel Safronov | 108 | 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 Safronov | 124 | /** |
| 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 | */ | |
| 129 | export function turnNumber(s: GameState): number { | |
| 130 | return s.status === 'playing' ? s.turn + 1 : s.turn | |
| 131 | } | |
| 132 | ||
c37a2b0b 2026-10-20 Pavel Safronov | 133 | /** The exit can be used once its block is gone and every enemy is dead. */ |
| 134 | export function exitOpen(s: GameState): boolean { | |
| 135 | return s.enemies.length === 0 && cellAt(s, s.exit) === 'floor' | |
5551eb37 2026-10-20 Pavel Safronov | 136 | } |
| 137 | ||
| 138 | export function cloneState(s: GameState): GameState { | |
| 139 | return structuredClone(s) | |
| 140 | } | |
| 141 | ||
| 142 | export function add(a: Vec, b: Vec): Vec { | |
| 143 | return { x: a.x + b.x, y: a.y + b.y } | |
| 144 | } | |
| 145 | ||
| 146 | export function eq(a: Vec, b: Vec): boolean { | |
| 147 | return a.x === b.x && a.y === b.y | |
| 148 | } | |
| 149 | ||
| 150 | export function idx(s: GameState, p: Vec): number { | |
| 151 | return p.y * s.width + p.x | |
| 152 | } | |
| 153 | ||
| 154 | export 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. */ | |
| 159 | export function cellAt(s: GameState, p: Vec): Cell { | |
| 160 | return inBounds(s, p) ? s.cells[idx(s, p)]! : 'wall' | |
| 161 | } | |
| 162 | ||
| 163 | export 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 Safronov | 167 | /** |
| 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 | */ | |
| 173 | export 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 Safronov | 185 | /** Nothing in the way: floor, and no bomb on it. */ |
| 186 | export function isFree(s: GameState, p: Vec): boolean { | |
| 187 | return cellAt(s, p) === 'floor' && !bombAt(s, p) | |
| 188 | } | |
82adee18 2026-10-20 Pavel Safronov | 189 | |
| 190 | /** Still on fire from last turn's explosion (see `GameState.burning`). */ | |
| 191 | export 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 Safronov | 195 | /** 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 Safronov | 196 | export 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 Safronov | 201 | /** |
5f066960 2026-10-26 Pavel Safronov | 202 | * 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 Safronov | 204 | */ |
99869630 2026-10-23 Pavel Safronov | 205 | export function flameSafe(s: GameState): boolean { |
5f066960 2026-10-26 Pavel Safronov | 206 | return s.charge?.kind === 'flamePass' || s.star > 0 || s.options.immortal |
99869630 2026-10-23 Pavel Safronov | 207 | } |
| 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 Safronov | 213 | export function playerCanEnter(s: GameState, p: Vec): boolean { |
99869630 2026-10-23 Pavel Safronov | 214 | return playerCanPass(s, p) && (!isBurning(s, p) || flameSafe(s)) |
82adee18 2026-10-20 Pavel Safronov | 215 | } |