fuzzyworld

explodorama / files / src / sim / enemies.ts

enemies.ts

12.8 KB · history · blame

1import { Rng } from './rng'
2import { add, bombAt, cellAt, eq, idx, oilSlide } from './state'
3import { 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. */
6export 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). */
9export 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 */
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)
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 */
31export function chased(s: GameState): Vec {
32 return s.bushes.includes(idx(s, s.player)) ? s.lastSeen : s.player
33}
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
44/** Where an enemy is, or will be, while its moves are planned ahead. */
45interface 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. */
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
62 * tile for a step, every tile on the line for a Sprinter's dash or a slide over oil.
63 */
64export 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 */
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
83}
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)
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). */
100export const PLAN_AHEAD = 3
101
102function 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 */
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
116/** Blob: carries on the way it was going three times in four, otherwise any open way. */
117function 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. */
125function 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 */
135export 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. */
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
175const 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 */
197export 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 */
246export 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 */
274const 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 */
291export 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 */
298function 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. */
303function 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 */
312export 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}