explodorama / files / src / sim / enemies.ts
enemies.ts
| 1 | import { Rng } from './rng' |
| 2 | import { add, bombAt, cellAt, eq, idx, oilSlide } from './state' |
| 3 | import { CHARGE_IDS, DIRS, DIR_LIST, type ChargeId, type Dir, type Drop, type Enemy, type EnemyKind, type GameEvent, type GameState, type Vec } from './types' |
| 4 | |
| 5 | /** Ghosts and Wisps go through crates (soft blocks), as the NES's Kondoria and Ovapi did. */ |
| 6 | export const PASSES_CRATES: ReadonlySet<EnemyKind> = new Set(['ghost', 'wisp']) |
| 7 | |
| 8 | /** Ghosts and Wisps fly: they don't slip on oil. Every other enemy slides over it (user request, 2026-10-31). */ |
| 9 | export const FLIES: ReadonlySet<EnemyKind> = new Set(['ghost', 'wisp']) |
| 10 | |
| 11 | /** |
| 12 | * Ghosts and Sprinters move only every other turn, resting in between (a Sprinter dashes 2 tiles |
| 13 | * when it moves). A rest is a planned "move" to the tile it is on. |
| 14 | */ |
| 15 | export const RESTS: ReadonlySet<EnemyKind> = new Set(['ghost', 'sprinter']) |
| 16 | |
| 17 | /** |
| 18 | * Enemies walk on floor, Ghosts and Wisps through crates too, and all are stopped by bombs. They |
| 19 | * don't block each other; they can share a tile. |
| 20 | */ |
| 21 | export function enemyCanEnter(s: GameState, p: Vec, kind?: EnemyKind): boolean { |
| 22 | const cell = cellAt(s, p) |
| 23 | // Spikes: every enemy keeps off them, up or down (PLAN.md §19.3). |
| 24 | return (cell === 'floor' || (cell === 'block' && !!kind && PASSES_CRATES.has(kind))) && !bombAt(s, p) && !s.spikes.some((sp) => sp.cell === idx(s, p)) |
| 25 | } |
| 26 | |
| 27 | /** |
| 28 | * Where enemies chase: the player, or while the player hides in a bush, where they were last seen |
| 29 | * out of one (PLAN.md §19.3). |
| 30 | */ |
| 31 | export function chased(s: GameState): Vec { |
| 32 | return s.bushes.includes(idx(s, s.player)) ? s.lastSeen : s.player |
| 33 | } |
| 34 | |
| 35 | export function enemyAt(s: GameState, p: Vec): Enemy | undefined { |
| 36 | return s.enemies.find((e) => eq(e.pos, p)) |
| 37 | } |
| 38 | |
| 39 | interface Step { |
| 40 | dir: Dir |
| 41 | to: Vec |
| 42 | } |
| 43 | |
| 44 | /** Where an enemy is, or will be, while its moves are planned ahead. */ |
| 45 | interface Walker { |
| 46 | kind: EnemyKind |
| 47 | pos: Vec |
| 48 | dir: Dir |
| 49 | /** Whether another enemy, planned before this one, will be on `p` at the move being planned. */ |
| 50 | crowded(p: Vec): boolean |
| 51 | } |
| 52 | |
| 53 | /** The direction of a step in a straight line (one tile, or a Sprinter's two); null for a rest. */ |
| 54 | export function dirBetween(from: Vec, to: Vec): Dir | null { |
| 55 | const dx = Math.sign(to.x - from.x) |
| 56 | const dy = Math.sign(to.y - from.y) |
| 57 | return DIR_LIST.find((d) => DIRS[d].x === dx && DIRS[d].y === dy) ?? null |
| 58 | } |
| 59 | |
| 60 | /** |
| 61 | * The tiles a planned move from `from` to `to` passes over and ends on: none for a rest, the one |
| 62 | * tile for a step, every tile on the line for a Sprinter's dash or a slide over oil. |
| 63 | */ |
| 64 | export function moveTiles(from: Vec, to: Vec): Vec[] { |
| 65 | const steps = Math.abs(to.x - from.x) + Math.abs(to.y - from.y) |
| 66 | const dir = dirBetween(from, to) |
| 67 | if (!dir) return [] |
| 68 | return Array.from({ length: steps }, (_, i) => ({ x: from.x + DIRS[dir].x * (i + 1), y: from.y + DIRS[dir].y * (i + 1) })) |
| 69 | } |
| 70 | |
| 71 | /** |
| 72 | * Where a move of `n` tiles from `from` heading `dir` ends for `kind`: those tiles, then on over |
| 73 | * oil if it lands on some (not for a flier). Null if a tile on the way can't be entered. |
| 74 | */ |
| 75 | function moveEnd(s: GameState, kind: EnemyKind, from: Vec, dir: Dir, n: number): Vec | null { |
| 76 | let land = from |
| 77 | for (let i = 0; i < n; i++) { |
| 78 | land = add(land, DIRS[dir]) |
| 79 | if (!enemyCanEnter(s, land, kind)) return null |
| 80 | } |
| 81 | if (FLIES.has(kind)) return land |
| 82 | return oilSlide(s, land, DIRS[dir], (q) => enemyCanEnter(s, q, kind)).at(-1) ?? land |
| 83 | } |
| 84 | |
| 85 | /** Whether a kept planned move is still one this enemy can make on the board as it is now. */ |
| 86 | function stillPossible(s: GameState, kind: EnemyKind, from: Vec, to: Vec): boolean { |
| 87 | const dx = Math.abs(to.x - from.x) |
| 88 | const dy = Math.abs(to.y - from.y) |
| 89 | if (dx + dy === 0) return RESTS.has(kind) |
| 90 | if (dx && dy) return false |
| 91 | const dir = dirBetween(from, to)! |
| 92 | // A step (a Sprinter's dash too), with the slide over oil it may end in. |
| 93 | return (kind === 'sprinter' ? [1, 2] : [1]).some((n) => { |
| 94 | const end = moveEnd(s, kind, from, dir, n) |
| 95 | return !!end && eq(end, to) |
| 96 | }) |
| 97 | } |
| 98 | |
| 99 | /** How many moves ahead every enemy plans, and shows (PLAN.md §4). */ |
| 100 | export const PLAN_AHEAD = 3 |
| 101 | |
| 102 | function openSteps(s: GameState, w: Pick<Walker, 'pos' | 'kind'>): Step[] { |
| 103 | return DIR_LIST.map((dir) => ({ dir, to: add(w.pos, DIRS[dir]) })).filter((st) => enemyCanEnter(s, st.to, w.kind)) |
| 104 | } |
| 105 | |
| 106 | /** |
| 107 | * The open steps that keep clear of the other enemies (user request, 2026-10-23: enemies on one |
| 108 | * tile went everywhere together). Only when every open step is crowded is a crowded one used. |
| 109 | */ |
| 110 | function spreadSteps(s: GameState, w: Walker): Step[] { |
| 111 | const steps = openSteps(s, w) |
| 112 | const clear = steps.filter((st) => !w.crowded(st.to)) |
| 113 | return clear.length ? clear : steps |
| 114 | } |
| 115 | |
| 116 | /** Blob: carries on the way it was going three times in four, otherwise any open way. */ |
| 117 | function wander(s: GameState, w: Walker, rng: Rng): Step | null { |
| 118 | const steps = spreadSteps(s, w) |
| 119 | const ahead = steps.find((st) => st.dir === w.dir) |
| 120 | if (ahead && rng.next() < 0.75) return ahead |
| 121 | return steps.length ? steps[rng.int(steps.length)]! : null |
| 122 | } |
| 123 | |
| 124 | /** Drifter: straight on until blocked (or the way ahead is another enemy's), then any open way. */ |
| 125 | function drift(s: GameState, w: Walker, rng: Rng): Step | null { |
| 126 | const steps = spreadSteps(s, w) |
| 127 | return steps.find((st) => st.dir === w.dir) ?? (steps.length ? steps[rng.int(steps.length)]! : null) |
| 128 | } |
| 129 | |
| 130 | /** |
| 131 | * The first step of a shortest path over tiles this enemy can enter (through crates, for a |
| 132 | * Ghost) toward what they chase: the decoy while there is one, otherwise the player. Null if |
| 133 | * there is no way. |
| 134 | */ |
| 135 | export function stepToward(s: GameState, w: Pick<Walker, 'pos' | 'kind'>): Step | null { |
| 136 | const goal = s.decoy?.pos ?? chased(s) |
| 137 | const key = (p: Vec) => p.y * s.width + p.x |
| 138 | const first = new Map<number, Step>() |
| 139 | const queue: Vec[] = [] |
| 140 | for (const st of openSteps(s, w)) { |
| 141 | if (eq(st.to, goal)) return st |
| 142 | first.set(key(st.to), st) |
| 143 | queue.push(st.to) |
| 144 | } |
| 145 | for (let i = 0; i < queue.length; i++) { |
| 146 | const p = queue[i]! |
| 147 | for (const d of DIR_LIST) { |
| 148 | const q = add(p, DIRS[d]) |
| 149 | if (first.has(key(q)) || eq(q, w.pos) || !enemyCanEnter(s, q, w.kind)) continue |
| 150 | first.set(key(q), first.get(key(p))!) |
| 151 | if (eq(q, goal)) return first.get(key(q))! |
| 152 | queue.push(q) |
| 153 | } |
| 154 | } |
| 155 | return null |
| 156 | } |
| 157 | |
| 158 | /** A step along the shortest way to the player (unless another enemy takes that tile then), else a wander. */ |
| 159 | function chase(s: GameState, w: Walker, rng: Rng): Step | null { |
| 160 | const toward = stepToward(s, w) |
| 161 | return toward && !w.crowded(toward.to) ? toward : wander(s, w, rng) |
| 162 | } |
| 163 | |
| 164 | /** |
| 165 | * Sprinter: half the time a chase, otherwise a wander; then on along the same line for a second |
| 166 | * tile if it can (a dash), or just the one. |
| 167 | */ |
| 168 | function dash(s: GameState, w: Walker, rng: Rng): Step | null { |
| 169 | const first = rng.next() < 0.5 ? chase(s, w, rng) : wander(s, w, rng) |
| 170 | if (!first) return null |
| 171 | const second = add(first.to, DIRS[first.dir]) |
| 172 | return enemyCanEnter(s, second, w.kind) && !w.crowded(second) ? { dir: first.dir, to: second } : first |
| 173 | } |
| 174 | |
| 175 | const PLANNERS: Record<EnemyKind, (s: GameState, w: Walker, rng: Rng) => Step | null> = { |
| 176 | blob: wander, |
| 177 | // Seeker: half the time a chase, otherwise a Blob's wander. |
| 178 | seeker: (s, w, rng) => (rng.next() < 0.5 ? chase(s, w, rng) : wander(s, w, rng)), |
| 179 | drifter: drift, |
| 180 | sprinter: dash, |
| 181 | // Ghost: always chases, through crates, every other turn. |
| 182 | ghost: chase, |
| 183 | // Wisp: wanders like a Blob, through crates. |
| 184 | wisp: wander, |
| 185 | // Hunter: always chases. |
| 186 | hunter: chase, |
| 187 | } |
| 188 | |
| 189 | /** |
| 190 | * Phase 5: every enemy tops its plan up to PLAN_AHEAD moves, and those are the moves it will |
| 191 | * make. Planned steps the board no longer allows (a bomb now in the way) are dropped first, |
| 192 | * along with everything after them, and planned again from there. The steps are worked out on |
| 193 | * the board as it is now, so a later bomb can still block one. |
| 194 | * Enemies are planned in turn, each keeping clear of where the ones before it will be at each |
| 195 | * of the coming moves (`spreadSteps`), so they spread out instead of moving as one. |
| 196 | */ |
| 197 | export function planIntents(s: GameState): void { |
| 198 | const rng = new Rng(s.aiRng) |
| 199 | // "t:x,y": an enemy planned so far will be at x,y after t moves (it stays where its plan ends). |
| 200 | const taken = new Set<string>() |
| 201 | const at = (t: number, p: Vec) => `${t}:${p.x},${p.y}` |
| 202 | for (const e of s.enemies) { |
| 203 | let from = e.pos |
| 204 | const kept: Vec[] = [] |
| 205 | let dir = e.dir |
| 206 | let rested: boolean | null = null |
| 207 | for (const p of e.plan) { |
| 208 | if (!stillPossible(s, e.kind, from, p)) break |
| 209 | kept.push(p) |
| 210 | rested = eq(p, from) |
| 211 | dir = dirBetween(from, p) ?? dir |
| 212 | from = p |
| 213 | } |
| 214 | // Carry on from the last kept step, heading the way the last move went. |
| 215 | const w: Walker = { kind: e.kind, pos: from, dir, crowded: (p) => taken.has(at(e.plan.length + 1, p)) } |
| 216 | // One that rests moves and rests by turns: after its last kept move it rests; with nothing |
| 217 | // kept, the turn's evenness decides, so they don't all move on the same turns. |
| 218 | let rest = RESTS.has(e.kind) && (rested === null ? (s.turn + e.id) % 2 === 1 : !rested) |
| 219 | e.plan = kept |
| 220 | while (e.plan.length < PLAN_AHEAD) { |
| 221 | if (rest) { |
| 222 | e.plan.push(w.pos) |
| 223 | rest = false |
| 224 | continue |
| 225 | } |
| 226 | const planned = PLANNERS[e.kind](s, w, rng) |
| 227 | if (!planned) break |
| 228 | // Landing on oil, it slides on to the last oiled tile of the line. |
| 229 | const st = { dir: planned.dir, to: FLIES.has(e.kind) ? planned.to : (oilSlide(s, planned.to, DIRS[planned.dir], (q) => enemyCanEnter(s, q, e.kind)).at(-1) ?? planned.to) } |
| 230 | e.plan.push(st.to) |
| 231 | w.pos = st.to |
| 232 | w.dir = st.dir |
| 233 | rest = RESTS.has(e.kind) |
| 234 | } |
| 235 | e.dir = w.dir |
| 236 | for (let t = 1; t <= PLAN_AHEAD; t++) taken.add(at(t, e.plan[t - 1] ?? e.plan.at(-1) ?? e.pos)) |
| 237 | } |
| 238 | s.aiRng = rng.state |
| 239 | } |
| 240 | |
| 241 | /** |
| 242 | * Phase 2: every enemy takes the first move of its plan. One whose tile was blocked this turn |
| 243 | * (a bomb placed there) stays put and does not re-plan until phase 5, which plans it afresh. |
| 244 | * Frozen, they all stay put and keep their plans, for as long as the Freeze lasts. |
| 245 | */ |
| 246 | export function moveEnemies(s: GameState, events: GameEvent[]): void { |
| 247 | if (s.frozen > 0) { |
| 248 | s.frozen-- |
| 249 | return |
| 250 | } |
| 251 | for (const e of s.enemies) { |
| 252 | const next = e.plan[0] |
| 253 | if (!next) continue |
| 254 | const tiles = moveTiles(e.pos, next) |
| 255 | if (!tiles.every((p) => enemyCanEnter(s, p, e.kind))) { |
| 256 | e.plan = [] |
| 257 | continue |
| 258 | } |
| 259 | e.plan.shift() |
| 260 | // A rest: it stays where it is. |
| 261 | if (!tiles.length) continue |
| 262 | // A Sprinter dashing through the player's tile stops there: it caught them. |
| 263 | const to = tiles.find((p) => eq(p, s.player)) ?? next |
| 264 | if (!eq(to, next)) e.plan = [] |
| 265 | events.push({ type: 'enemyMoved', id: e.id, from: e.pos, to }) |
| 266 | e.pos = to |
| 267 | } |
| 268 | } |
| 269 | |
| 270 | /** |
| 271 | * Chance of a coin drop, and of an action-charge drop, per type (PLAN.md §10). Coin chances raised |
| 272 | * 2026-10-24 for the shop (from 0.2, 0.5 and 0.2): a kill is worth about 1 coin on average. |
| 273 | */ |
| 274 | const DROP_CHANCE: Record<EnemyKind, { coins: number; charge: number }> = { |
| 275 | blob: { coins: 0.6, charge: 0.15 }, |
| 276 | seeker: { coins: 0.8, charge: 0.25 }, |
| 277 | drifter: { coins: 0.5, charge: 0.5 }, |
| 278 | // The later ones (2026-10-24), after PLAN.md §10: a Sprinter and a Wisp mostly a charge, a |
| 279 | // Ghost coins, a Hunter both. |
| 280 | sprinter: { coins: 0.4, charge: 0.6 }, |
| 281 | ghost: { coins: 0.9, charge: 0.25 }, |
| 282 | wisp: { coins: 0.4, charge: 0.6 }, |
| 283 | hunter: { coins: 0.9, charge: 0.6 }, |
| 284 | } |
| 285 | |
| 286 | /** |
| 287 | * The charges that still help once every enemy is gone: all of them, since one charge at a time |
| 288 | * (2026-10-24) every charge is carried to the next level. (Until then Freeze and Decoy worked on |
| 289 | * pickup, so the last enemy dropped only Pierce, Power, Nuke, Shield or ICBM.) |
| 290 | */ |
| 291 | export const LASTING_CHARGES: readonly ChargeId[] = CHARGE_IDS |
| 292 | |
| 293 | /** |
| 294 | * A Decoy, Freeze or Shield already lying on the board makes another one useless (user report, |
| 295 | * 2026-10-23: a second Decoy dropped beside the first). Pierce, Power, Nuke and ICBM add up, so |
| 296 | * they are never wasted. |
| 297 | */ |
| 298 | function wasted(s: GameState, id: ChargeId): boolean { |
| 299 | return (id === 'decoy' || id === 'freeze' || id === 'shield') && s.drops.some((d) => d.kind === id) |
| 300 | } |
| 301 | |
| 302 | /** Every charge that wouldn't be wasted is equally likely; with `last`, every lasting one. */ |
| 303 | function pickCharge(s: GameState, rng: Rng, last: boolean): ChargeId { |
| 304 | const ids = (last ? LASTING_CHARGES : CHARGE_IDS).filter((id) => !wasted(s, id)) |
| 305 | return ids[Math.floor(rng.next() * ids.length)]! |
| 306 | } |
| 307 | |
| 308 | /** |
| 309 | * What a slain enemy leaves on its tile. Coins are worth 1 (60%), 2 (30%) or 5 (10%). `last`: |
| 310 | * no enemies are left, so a charge is a lasting one. |
| 311 | */ |
| 312 | export function rollDrops(s: GameState, e: Enemy, last = false): Drop[] { |
| 313 | if (e.spawned) return [] |
| 314 | const rng = new Rng(s.dropRng) |
| 315 | const drops: Drop[] = [] |
| 316 | const chance = DROP_CHANCE[e.kind] |
| 317 | if (rng.next() < chance.coins) { |
| 318 | const r = rng.next() |
| 319 | drops.push({ pos: e.pos, kind: 'coins', amount: r < 0.6 ? 1 : r < 0.9 ? 2 : 5 }) |
| 320 | } |
| 321 | if (rng.next() < chance.charge) { |
| 322 | drops.push({ pos: e.pos, kind: pickCharge(s, rng, last) }) |
| 323 | } |
| 324 | s.dropRng = rng.state |
| 325 | return drops |
| 326 | } |