explodorama / files / src / sim / picture.ts
picture.ts
| 1 | import { noPowers, START_LIVES, START_STATS } from './state' |
| 2 | import { DEFAULT_OPTIONS, type Cell, type EnemyKind, type GameState, type Stats, type Vec } from './types' |
| 3 | |
| 4 | const 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 | */ |
| 17 | export 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 |