fuzzyworld

explodorama / files / src / sim / enemies.ts

enemies.ts

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

c37a2b0b 2026-10-20 Pavel Safronov1import { Rng } from './rng'
1f0038ac 2026-11-01 Pavel Safronov2import { add, bombAt, cellAt, eq, idx, oilSlide } from './state'
82adee18 2026-10-20 Pavel Safronov3import { CHARGE_IDS, DIRS, DIR_LIST, type ChargeId, type Dir, type Drop, type Enemy, type EnemyKind, type GameEvent, type GameState, type Vec } from './types'
c37a2b0b 2026-10-20 Pavel Safronov4
565a6873 2026-10-25 Pavel Safronov5/** Ghosts and Wisps go through crates (soft blocks), as the NES's Kondoria and Ovapi did. */
6export const PASSES_CRATES: ReadonlySet<EnemyKind> = new Set(['ghost', 'wisp'])
7
1f0038ac 2026-11-01 Pavel Safronov8/** Ghosts and Wisps fly: they don't slip on oil. Every other enemy slides over it (user request, 2026-10-31). */
9export const FLIES: ReadonlySet<EnemyKind> = new Set(['ghost', 'wisp'])
10
565a6873 2026-10-25 Pavel Safronov11/**
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 */
15export 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 */
21export function enemyCanEnter(s: GameState, p: Vec, kind?: EnemyKind): boolean {
22 const cell = cellAt(s, p)
5f066960 2026-10-26 Pavel Safronov23 // 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 */
31export function chased(s: GameState): Vec {
32 return s.bushes.includes(idx(s, s.player)) ? s.lastSeen : s.player
c37a2b0b 2026-10-20 Pavel Safronov33}
34
35export function enemyAt(s: GameState, p: Vec): Enemy | undefined {
36 return s.enemies.find((e) => eq(e.pos, p))
37}
38
39interface Step {
40 dir: Dir
41 to: Vec
42}
43
82adee18 2026-10-20 Pavel Safronov44/** Where an enemy is, or will be, while its moves are planned ahead. */
45interface Walker {
565a6873 2026-10-25 Pavel Safronov46 kind: EnemyKind
82adee18 2026-10-20 Pavel Safronov47 pos: Vec
48 dir: Dir
52d68b59 2026-10-24 Pavel Safronov49 /** Whether another enemy, planned before this one, will be on `p` at the move being planned. */
50 crowded(p: Vec): boolean
82adee18 2026-10-20 Pavel Safronov51}
52
565a6873 2026-10-25 Pavel Safronov53/** The direction of a step in a straight line (one tile, or a Sprinter's two); null for a rest. */
54export 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
1f0038ac 2026-11-01 Pavel Safronov62 * tile for a step, every tile on the line for a Sprinter's dash or a slide over oil.
565a6873 2026-10-25 Pavel Safronov63 */
64export function moveTiles(from: Vec, to: Vec): Vec[] {
65 const steps = Math.abs(to.x - from.x) + Math.abs(to.y - from.y)
1f0038ac 2026-11-01 Pavel Safronov66 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 */
75function 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
565a6873 2026-10-25 Pavel Safronov83}
84
85/** Whether a kept planned move is still one this enemy can make on the board as it is now. */
86function 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)
1f0038ac 2026-11-01 Pavel Safronov90 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 })
82adee18 2026-10-20 Pavel Safronov97}
98
99/** How many moves ahead every enemy plans, and shows (PLAN.md §4). */
100export const PLAN_AHEAD = 3
101
565a6873 2026-10-25 Pavel Safronov102function 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))
c37a2b0b 2026-10-20 Pavel Safronov104}
105
52d68b59 2026-10-24 Pavel Safronov106/**
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 */
110function 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
c37a2b0b 2026-10-20 Pavel Safronov116/** Blob: carries on the way it was going three times in four, otherwise any open way. */
82adee18 2026-10-20 Pavel Safronov117function wander(s: GameState, w: Walker, rng: Rng): Step | null {
52d68b59 2026-10-24 Pavel Safronov118 const steps = spreadSteps(s, w)
82adee18 2026-10-20 Pavel Safronov119 const ahead = steps.find((st) => st.dir === w.dir)
c37a2b0b 2026-10-20 Pavel Safronov120 if (ahead && rng.next() < 0.75) return ahead
121 return steps.length ? steps[rng.int(steps.length)]! : null
122}
123
52d68b59 2026-10-24 Pavel Safronov124/** Drifter: straight on until blocked (or the way ahead is another enemy's), then any open way. */
82adee18 2026-10-20 Pavel Safronov125function drift(s: GameState, w: Walker, rng: Rng): Step | null {
52d68b59 2026-10-24 Pavel Safronov126 const steps = spreadSteps(s, w)
82adee18 2026-10-20 Pavel Safronov127 return steps.find((st) => st.dir === w.dir) ?? (steps.length ? steps[rng.int(steps.length)]! : null)
c37a2b0b 2026-10-20 Pavel Safronov128}
129
82adee18 2026-10-20 Pavel Safronov130/**
565a6873 2026-10-25 Pavel Safronov131 * 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.
82adee18 2026-10-20 Pavel Safronov134 */
565a6873 2026-10-25 Pavel Safronov135export function stepToward(s: GameState, w: Pick<Walker, 'pos' | 'kind'>): Step | null {
5f066960 2026-10-26 Pavel Safronov136 const goal = s.decoy?.pos ?? chased(s)
c37a2b0b 2026-10-20 Pavel Safronov137 const key = (p: Vec) => p.y * s.width + p.x
138 const first = new Map<number, Step>()
139 const queue: Vec[] = []
82adee18 2026-10-20 Pavel Safronov140 for (const st of openSteps(s, w)) {
141 if (eq(st.to, goal)) return st
c37a2b0b 2026-10-20 Pavel Safronov142 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])
565a6873 2026-10-25 Pavel Safronov149 if (first.has(key(q)) || eq(q, w.pos) || !enemyCanEnter(s, q, w.kind)) continue
c37a2b0b 2026-10-20 Pavel Safronov150 first.set(key(q), first.get(key(p))!)
82adee18 2026-10-20 Pavel Safronov151 if (eq(q, goal)) return first.get(key(q))!
c37a2b0b 2026-10-20 Pavel Safronov152 queue.push(q)
153 }
154 }
155 return null
156}
157
565a6873 2026-10-25 Pavel Safronov158/** A step along the shortest way to the player (unless another enemy takes that tile then), else a wander. */
159function 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 */
168function 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
82adee18 2026-10-20 Pavel Safronov175const PLANNERS: Record<EnemyKind, (s: GameState, w: Walker, rng: Rng) => Step | null> = {
c37a2b0b 2026-10-20 Pavel Safronov176 blob: wander,
565a6873 2026-10-25 Pavel Safronov177 // 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)),
c37a2b0b 2026-10-20 Pavel Safronov179 drifter: drift,
565a6873 2026-10-25 Pavel Safronov180 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,
c37a2b0b 2026-10-20 Pavel Safronov187}
188
82adee18 2026-10-20 Pavel Safronov189/**
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.
52d68b59 2026-10-24 Pavel Safronov194 * 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.
82adee18 2026-10-20 Pavel Safronov196 */
c37a2b0b 2026-10-20 Pavel Safronov197export function planIntents(s: GameState): void {
198 const rng = new Rng(s.aiRng)
52d68b59 2026-10-24 Pavel Safronov199 // "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}`
c37a2b0b 2026-10-20 Pavel Safronov202 for (const e of s.enemies) {
82adee18 2026-10-20 Pavel Safronov203 let from = e.pos
204 const kept: Vec[] = []
565a6873 2026-10-25 Pavel Safronov205 let dir = e.dir
206 let rested: boolean | null = null
82adee18 2026-10-20 Pavel Safronov207 for (const p of e.plan) {
565a6873 2026-10-25 Pavel Safronov208 if (!stillPossible(s, e.kind, from, p)) break
82adee18 2026-10-20 Pavel Safronov209 kept.push(p)
565a6873 2026-10-25 Pavel Safronov210 rested = eq(p, from)
211 dir = dirBetween(from, p) ?? dir
82adee18 2026-10-20 Pavel Safronov212 from = p
213 }
565a6873 2026-10-25 Pavel Safronov214 // 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)
82adee18 2026-10-20 Pavel Safronov219 e.plan = kept
220 while (e.plan.length < PLAN_AHEAD) {
565a6873 2026-10-25 Pavel Safronov221 if (rest) {
222 e.plan.push(w.pos)
223 rest = false
224 continue
225 }
1f0038ac 2026-11-01 Pavel Safronov226 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) }
82adee18 2026-10-20 Pavel Safronov230 e.plan.push(st.to)
231 w.pos = st.to
232 w.dir = st.dir
565a6873 2026-10-25 Pavel Safronov233 rest = RESTS.has(e.kind)
82adee18 2026-10-20 Pavel Safronov234 }
235 e.dir = w.dir
52d68b59 2026-10-24 Pavel Safronov236 for (let t = 1; t <= PLAN_AHEAD; t++) taken.add(at(t, e.plan[t - 1] ?? e.plan.at(-1) ?? e.pos))
c37a2b0b 2026-10-20 Pavel Safronov237 }
238 s.aiRng = rng.state
239}
240
241/**
82adee18 2026-10-20 Pavel Safronov242 * 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.
99869630 2026-10-23 Pavel Safronov244 * Frozen, they all stay put and keep their plans, for as long as the Freeze lasts.
c37a2b0b 2026-10-20 Pavel Safronov245 */
246export function moveEnemies(s: GameState, events: GameEvent[]): void {
99869630 2026-10-23 Pavel Safronov247 if (s.frozen > 0) {
248 s.frozen--
c37a2b0b 2026-10-20 Pavel Safronov249 return
250 }
251 for (const e of s.enemies) {
82adee18 2026-10-20 Pavel Safronov252 const next = e.plan[0]
253 if (!next) continue
565a6873 2026-10-25 Pavel Safronov254 const tiles = moveTiles(e.pos, next)
255 if (!tiles.every((p) => enemyCanEnter(s, p, e.kind))) {
82adee18 2026-10-20 Pavel Safronov256 e.plan = []
257 continue
258 }
259 e.plan.shift()
565a6873 2026-10-25 Pavel Safronov260 // 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
c37a2b0b 2026-10-20 Pavel Safronov267 }
268}
269
0b63d158 2026-10-25 Pavel Safronov270/**
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 */
c37a2b0b 2026-10-20 Pavel Safronov274const DROP_CHANCE: Record<EnemyKind, { coins: number; charge: number }> = {
0b63d158 2026-10-25 Pavel Safronov275 blob: { coins: 0.6, charge: 0.15 },
276 seeker: { coins: 0.8, charge: 0.25 },
277 drifter: { coins: 0.5, charge: 0.5 },
565a6873 2026-10-25 Pavel Safronov278 // 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 },
c37a2b0b 2026-10-20 Pavel Safronov284}
285
52d68b59 2026-10-24 Pavel Safronov286/**
d3e4ed26 2026-10-25 Pavel Safronov287 * 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.)
52d68b59 2026-10-24 Pavel Safronov290 */
d3e4ed26 2026-10-25 Pavel Safronov291export const LASTING_CHARGES: readonly ChargeId[] = CHARGE_IDS
52d68b59 2026-10-24 Pavel Safronov292
8da5a7c0 2026-10-24 Pavel Safronov293/**
d3e4ed26 2026-10-25 Pavel Safronov294 * 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.
8da5a7c0 2026-10-24 Pavel Safronov297 */
298function wasted(s: GameState, id: ChargeId): boolean {
d3e4ed26 2026-10-25 Pavel Safronov299 return (id === 'decoy' || id === 'freeze' || id === 'shield') && s.drops.some((d) => d.kind === id)
8da5a7c0 2026-10-24 Pavel Safronov300}
301
302/** Every charge that wouldn't be wasted is equally likely; with `last`, every lasting one. */
303function pickCharge(s: GameState, rng: Rng, last: boolean): ChargeId {
304 const ids = (last ? LASTING_CHARGES : CHARGE_IDS).filter((id) => !wasted(s, id))
52d68b59 2026-10-24 Pavel Safronov305 return ids[Math.floor(rng.next() * ids.length)]!
82adee18 2026-10-20 Pavel Safronov306}
307
52d68b59 2026-10-24 Pavel Safronov308/**
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 */
312export function rollDrops(s: GameState, e: Enemy, last = false): Drop[] {
c37a2b0b 2026-10-20 Pavel Safronov313 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) {
8da5a7c0 2026-10-24 Pavel Safronov322 drops.push({ pos: e.pos, kind: pickCharge(s, rng, last) })
c37a2b0b 2026-10-20 Pavel Safronov323 }
324 s.dropRng = rng.state
325 return drops
326}