explodorama / files / src / sim / path.ts
path.ts
| 1 | import { diagonalAllowed, isAvailable, isValidTarget } from './actions' |
| 2 | import type { Danger } from './danger' |
| 3 | import { enemyAt, moveTiles } from './enemies' |
| 4 | import { add, eq, exitOpen, flameSafe, idx, oilSlide, playerCanPass } from './state' |
| 5 | import { DIRS, DIR_LIST, type Action, type GameState, type Vec } from './types' |
| 6 | |
| 7 | /** The four diagonal steps, for a walk with Diagonal. */ |
| 8 | const DIAGONALS: readonly Vec[] = [{ x: -1, y: -1 }, { x: 1, y: -1 }, { x: -1, y: 1 }, { x: 1, y: 1 }] |
| 9 | |
| 10 | /** |
| 11 | * Whether a step from `from` to its neighbour `q` (or a run landing on `q`) would start an oil |
| 12 | * slide: the player would not stay on `q`. Auto-move keeps off such steps (reviewer, 2026-10-31): |
| 13 | * it plans on tiles it lands on, and a slide can carry the player past the destination, back and |
| 14 | * forth for ever, or onto the open exit. A tap on a single tile away still moves, and slides. |
| 15 | */ |
| 16 | export function slips(s: GameState, from: Vec, q: Vec): boolean { |
| 17 | const d = { x: Math.sign(q.x - from.x), y: Math.sign(q.y - from.y) } |
| 18 | return !(d.x && d.y) && oilSlide(s, q, d, (p) => playerCanPass(s, p)).length > 0 |
| 19 | } |
| 20 | |
| 21 | /** |
| 22 | * The shortest walk for the player to `to`, for auto-move, over tiles the player can pass (free |
| 23 | * floor, plus crates or bombs with Wall or Bomb Pass). The result leaves out the player's own |
| 24 | * tile and ends at `to`; null if `to` can't be reached. |
| 25 | * Enemies don't block the path, because they will have moved by the time the player gets there; |
| 26 | * the UI refuses a step onto an enemy instead. |
| 27 | * An open exit is only walked onto as the destination: passing over it would end the level on |
| 28 | * the way somewhere else (user report, 2026-10-23). |
| 29 | * With Diagonal, a diagonal step counts as one too, where the action allows it (no squeezing; user |
| 30 | * report, 2026-10-25: auto-move went round two sides of a corner it could cut). |
| 31 | */ |
| 32 | export function playerPath(s: GameState, to: Vec): Vec[] | null { |
| 33 | if (eq(s.player, to) || !playerCanPass(s, to)) return null |
| 34 | const key = (p: Vec) => p.y * s.width + p.x |
| 35 | const prev = new Map<number, Vec>([[key(s.player), s.player]]) |
| 36 | const avoidExit = exitOpen(s) && !eq(s.exit, to) |
| 37 | const queue = [s.player] |
| 38 | const steps = s.powers.diagonal ? [...DIR_LIST.map((d) => DIRS[d]), ...DIAGONALS] : DIR_LIST.map((d) => DIRS[d]) |
| 39 | for (let i = 0; i < queue.length; i++) { |
| 40 | const p = queue[i]! |
| 41 | for (const d of steps) { |
| 42 | if (d.x && d.y && !diagonalAllowed(s, p, d)) continue |
| 43 | const q = add(p, d) |
| 44 | if (prev.has(key(q)) || !playerCanPass(s, q) || (avoidExit && eq(q, s.exit)) || slips(s, p, q)) continue |
| 45 | prev.set(key(q), p) |
| 46 | if (eq(q, to)) { |
| 47 | const path = [q] |
| 48 | for (let r = p; !eq(r, s.player); r = prev.get(key(r))!) path.unshift(r) |
| 49 | return path |
| 50 | } |
| 51 | queue.push(q) |
| 52 | } |
| 53 | } |
| 54 | return null |
| 55 | } |
| 56 | |
| 57 | /** |
| 58 | * Auto-move's next action toward `to`, also the first one when a far tile is tapped (user |
| 59 | * request, 2026-10-23): a tile next to the player is a step; otherwise whichever of a step along |
| 60 | * the way or a Run (2 straight along it) leaves the player closest to `to`, counted as the walk |
| 61 | * still left; on a tie, a Run. (Teleport took part until it became a charge, 2026-10-25.) Then it |
| 62 | * is worked out again next turn. Only tiles `safe` allows are landed on, and never the open exit |
| 63 | * unless it is `to`. Null if nothing gets closer. |
| 64 | */ |
| 65 | export function autoMoveAction(s: GameState, to: Vec, safe: (p: Vec) => boolean): Action | null { |
| 66 | const path = playerPath(s, to) |
| 67 | // The first step along it: a plain step, or a diagonal one (with Diagonal). |
| 68 | const first = path?.[0] |
| 69 | const stepId = first && Math.abs(first.x - s.player.x) + Math.abs(first.y - s.player.y) === 2 ? 'diagonal' : 'move' |
| 70 | const step: Action | null = first && safe(first) && isValidTarget(s, stepId, first) ? { id: stepId, target: first } : null |
| 71 | if (path?.length === 1) return step |
| 72 | // (Teleport was an option too until 2026-10-25; as a charge it isn't auto-move's to spend.) |
| 73 | const options: { action: Action; left: number }[] = [] |
| 74 | const [next, after] = [path?.[0], path?.[1]] |
| 75 | const straight = next && after && after.x - next.x === next.x - s.player.x && after.y - next.y === next.y - s.player.y |
| 76 | if (straight && isAvailable(s, 'run') && isValidTarget(s, 'run', after) && safe(next) && safe(after) && !slips(s, next, after)) { |
| 77 | options.push({ action: { id: 'run', target: after }, left: path!.length - 2 }) |
| 78 | } |
| 79 | if (step) options.push({ action: step, left: path!.length - 1 }) |
| 80 | // The closest; the first listed wins a tie. |
| 81 | let best: { action: Action; left: number } | null = null |
| 82 | for (const o of options) if (!best || o.left < best.left) best = o |
| 83 | const now = path?.length ?? Infinity |
| 84 | return best && best.left < now ? best.action : null |
| 85 | } |
| 86 | |
| 87 | /** |
| 88 | * Whether auto-move may take this step: not onto an enemy, not onto a tile an enemy moves to or |
| 89 | * dashes through next, and not into flame this turn. Those would be deaths the player didn't choose. |
| 90 | */ |
| 91 | export function safeStep(s: GameState, danger: Danger, p: Vec): boolean { |
| 92 | // Frozen enemies won't move this turn; flame doesn't matter while it can't hurt. |
| 93 | const intended = s.frozen === 0 && s.enemies.some((e) => e.plan[0] && moveTiles(e.pos, e.plan[0]).some((t) => eq(t, p))) |
| 94 | return !enemyAt(s, p) && !intended && (flameSafe(s) || !danger.now.has(idx(s, p))) |
| 95 | } |
| 96 | |
| 97 | /** |
| 98 | * Where auto-move will land, move by move, from here to `to` (user report, 2026-10-25: with Run, |
| 99 | * every tile of the way lit up though only every other one is landed on): `autoMoveAction` played |
| 100 | * out on the player's place alone, the first move only where `safe` allows (later turns' dangers |
| 101 | * aren't known yet). A Run or Teleport skips tiles; a step lands on each. Empty if the first move |
| 102 | * can't be made now. |
| 103 | */ |
| 104 | export function autoRoute(s: GameState, to: Vec, safe: (p: Vec) => boolean): Vec[] { |
| 105 | const route: Vec[] = [] |
| 106 | let at = s |
| 107 | for (let i = 0; i < s.width * s.height && !eq(at.player, to); i++) { |
| 108 | const action = autoMoveAction(at, to, i === 0 ? safe : () => true) |
| 109 | if (!action || !('target' in action) || !action.target) break |
| 110 | route.push(action.target) |
| 111 | at = { ...at, player: action.target } |
| 112 | } |
| 113 | return route |
| 114 | } |