fuzzyworld

explodorama / files / src / sim / picture.ts

picture.ts

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

8edb5cb9 2026-11-01 Pavel Safronov1import { noPowers, START_LIVES, START_STATS } from './state'
2import { DEFAULT_OPTIONS, type Cell, type EnemyKind, type GameState, type Stats, type Vec } from './types'
3
4const ENEMIES: Record<string, EnemyKind> = { B: 'blob', S: 'seeker', D: 'drifter', R: 'sprinter', G: 'ghost', W: 'wisp', H: 'hunter' }
5
6/**
7 * A state from a picture: `#` wall, `+` soft block, `.` floor, `P` player (on floor),
8 * `1`–`9` a bomb with that timer (on floor, placed on an earlier turn),
9 * `B` / `S` / `D` / `R` / `G` / `W` / `H` a Blob / Seeker / Drifter / Sprinter (runner) / Ghost /
10 * Wisp / Hunter facing right with an empty plan (it stays put unless the test gives it one),
11 * `E` the exit uncovered, `e` the exit under a soft block, `$` a 1-coin drop;
12 * `M` a metal crate, `O` an oil barrel, `r` rubble, `*` a bush, `~` oil, `^` spikes up this turn,
13 * `v` spikes down this turn (all on floor but the first three), `%` an uncovered gem.
14 * Shared by the tests and the developer-mode test scenes (sim/testScenes.ts).
15 * Without `E` or `e` the exit is off the board, so it never opens.
16 */
17export function board(picture: string, stats: Partial<Stats> = {}): GameState {
18 const rows = picture.trim().split('\n').map((r) => r.trim())
19 const height = rows.length
20 const width = rows[0]!.length
21 const cells: Cell[] = []
22 let player: Vec | undefined
23 const s: GameState = {
24 seed: 1, options: { ...DEFAULT_OPTIONS }, level: 1, turn: 5, width, height, cells, player: { x: 0, y: 0 },
25 lives: START_LIVES, stats: { ...START_STATS, ...stats }, bombs: [], status: 'playing',
26 enemies: [], nextEnemyId: 0, drops: [], exit: { x: -1, y: -1 }, par: 100, coins: 0,
27 charge: null, chargeOffer: null, waitingGems: [], frozen: 0, decoy: null, burning: [],
28 powers: noPowers(), star: 0, gems: [], offer: null, shop: null, death: null, runTurns: 0, runKills: 0, deaths: 0, crumbling: [], bushes: [], smoldering: [], oil: [], spikes: [], cracked: [], collapsing: [], lastSeen: { x: 0, y: 0 }, aiRng: 1, dropRng: 1,
29 }
30 rows.forEach((row, y) => {
31 if (row.length !== width) throw new Error(`row ${y} is ${row.length} wide, not ${width}`)
32 ;[...row].forEach((ch, x) => {
33 cells.push(ch === '#' ? 'wall' : ch === '+' || ch === 'e' ? 'block' : ch === 'M' ? 'metal' : ch === 'O' ? 'barrel' : ch === 'r' ? 'rubble' : 'floor')
34 const i = y * width + x
35 if (ch === '*') s.bushes.push(i)
36 if (ch === '~') s.oil.push(i)
37 // Up when (turn + phase) is even; the picture's turn is 5.
38 if (ch === '^' || ch === 'v') s.spikes.push({ cell: i, phase: ch === '^' ? 1 : 0 })
39 if (ch === 'P') player = { x, y }
40 if (/[1-9]/.test(ch)) s.bombs.push({ pos: { x, y }, timer: Number(ch), fire: s.stats.fire, placedTurn: 0 })
41 const kind = ENEMIES[ch]
42 if (kind) s.enemies.push({ id: s.nextEnemyId++, kind, pos: { x, y }, dir: 'right', plan: [], spawned: false, sheltered: false })
43 if (ch === 'E' || ch === 'e') s.exit = { x, y }
44 if (ch === '%') s.gems.push({ pos: { x, y }, color: 'red' })
45 if (ch === '$') s.drops.push({ pos: { x, y }, kind: 'coins', amount: 1 })
46 })
47 })
48 if (!player) throw new Error('no P in the picture')
49 s.player = player
50 s.lastSeen = { ...player }
51 return s
52}
53