explodorama / files / src / sim / state.ts
state.ts
| 1 | import { planIntents } from './enemies' |
| 2 | import { generateLevel } from './levelgen' |
| 3 | import { hashSeed } from './rng' |
| 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' |
| 6 | |
| 7 | export const START_LIVES = 3 |
| 8 | export const START_STATS: Stats = { capacity: 1, fire: 1, fuse: 3 } |
| 9 | |
| 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. */ |
| 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 |
| 20 | /** |
| 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. |
| 23 | */ |
| 24 | charge: HeldCharge | null |
| 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 |
| 30 | powers: Powers |
| 31 | star: number |
| 32 | runTurns: number |
| 33 | runKills: number |
| 34 | deaths: number |
| 35 | } |
| 36 | |
| 37 | export function noPowers(): Powers { |
| 38 | return { |
| 39 | run: false, kick: false, throw: false, diagonal: false, detonate: false, wallPass: false, bombPass: false, |
| 40 | rpg: false, proximity: false, slide: false, |
| 41 | } |
| 42 | } |
| 43 | |
| 44 | /** |
| 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). |
| 47 | */ |
| 48 | export function newRun(seed: number, level = 1, charge: ChargeId | null = null, options: RunOptions = DEFAULT_OPTIONS): GameState { |
| 49 | return createLevel(seed, level, options, { |
| 50 | lives: START_LIVES, stats: { ...START_STATS }, coins: 0, charge: charge && { kind: charge, left: CHARGE_AMOUNT[charge] }, |
| 51 | powers: noPowers(), star: 0, runTurns: 0, runKills: 0, deaths: 0, |
| 52 | }) |
| 53 | } |
| 54 | |
| 55 | /** |
| 56 | * A fresh attempt at a level: the layout and enemies come from the seed, so a retry after a death |
| 57 | * is the same board. What the player has comes with them (`carryOf` decides what a death costs). |
| 58 | */ |
| 59 | export function createLevel(seed: number, level: number, options: RunOptions, carry: Carry): GameState { |
| 60 | const { width, height, cells, start, exit, powerUp, enemies, par, bushes, oil, spikes } = generateLevel(seed, level) |
| 61 | const s: GameState = { |
| 62 | seed, options: { ...options }, level, turn: 0, width, height, cells, player: start, |
| 63 | lives: carry.lives, stats: { ...carry.stats }, coins: carry.coins, charge: carry.charge && { ...carry.charge }, chargeOffer: null, |
| 64 | bombs: [], status: 'playing', |
| 65 | enemies: enemies.map((e, id) => ({ id, kind: e.kind, pos: e.pos, dir: e.dir, plan: [], spawned: false, sheltered: false })), |
| 66 | nextEnemyId: enemies.length, |
| 67 | drops: [], exit, par, frozen: 0, decoy: null, burning: [], |
| 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: [], |
| 69 | bushes, smoldering: [], oil, spikes, lastSeen: { ...start }, cracked: [], collapsing: [], |
| 70 | aiRng: hashSeed(seed, 'ai', level), dropRng: hashSeed(seed, 'drops', level), |
| 71 | } |
| 72 | // Developer mode: only this (user request, 2026-10-23; it used to give every ability, stats and |
| 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 }) |
| 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 }) |
| 80 | planIntents(s) |
| 81 | return s |
| 82 | } |
| 83 | |
| 84 | /** |
| 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 |
| 88 | * 2026-10-23 (user request) a death costs no power-ups; the NES rule lost the passes, Detonator |
| 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. |
| 91 | */ |
| 92 | export function carryOf(s: GameState): Carry { |
| 93 | return { |
| 94 | lives: s.lives, stats: s.stats, coins: s.coins, powers: s.powers, star: s.star, |
| 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, |
| 98 | runTurns: s.runTurns, runKills: s.runKills, deaths: s.deaths, |
| 99 | } |
| 100 | } |
| 101 | |
| 102 | /** |
| 103 | * `p` if it is open floor (no wall or crate), else the nearest |
| 104 | * open floor to it, by steps over the board (through anything but walls); a fallen charge can't |
| 105 | * lie inside a crate (with Crate Pass the player can die in one), nor an immortal player in rubble. |
| 106 | */ |
| 107 | export function nearestFloor(s: GameState, p: Vec): Vec { |
| 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 | |
| 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 | |
| 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' |
| 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 | |
| 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 | |
| 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 | } |
| 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 | |
| 195 | /** Where the player's body can go, fire aside: floor, or a soft block with Crate Pass; a bomb only with Bomb Pass. */ |
| 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 | |
| 201 | /** |
| 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). |
| 204 | */ |
| 205 | export function flameSafe(s: GameState): boolean { |
| 206 | return s.charge?.kind === 'flamePass' || s.star > 0 || s.options.immortal |
| 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 | */ |
| 213 | export function playerCanEnter(s: GameState, p: Vec): boolean { |
| 214 | return playerCanPass(s, p) && (!isBurning(s, p) || flameSafe(s)) |
| 215 | } |