explodorama / files / src / sim / picks.ts
picks.ts
| 1 | import { ACTIONS, isAvailable, isValidTarget } from './actions' |
| 2 | import { isDeadly } from './danger' |
| 3 | import { enemyAt } from './enemies' |
| 4 | import { playerPath } from './path' |
| 5 | import { add, bombAt, cellAt, eq, flameSafe, isBurning } from './state' |
| 6 | import { DIRS, DIR_LIST, type ActionId, type GameState, type Vec } from './types' |
| 7 | |
| 8 | /** |
| 9 | * The action buttons stand for groups of actions (user request, 2026-10-22): Move also kicks, |
| 10 | * runs, teleports and steps diagonally; Bomb also throws, and with the Detonator a tap on one of |
| 11 | * your bombs sets it off. Which one a tap on a tile means |
| 12 | * depends on the tile. The record still holds the actual action (Run, Throw...). |
| 13 | * |
| 14 | * Members are in order of preference: a tile two of them can reach goes to the earlier one, so |
| 15 | * a kick beats walking onto the bomb with Bomb Pass (walking into a bomb kicks it, if it can |
| 16 | * slide), a plain step beats a run, and a diagonal step beats a teleport to the same tile (a |
| 17 | * tile next to you is a step; user report, 2026-10-23). A teleport beats a slide to the same |
| 18 | * tile (it can't crash or burn). The charge button (2026-10-24, one charge at a time) stands for |
| 19 | * whichever use the charge held has: a charged bomb, an ICBM launch, a Freeze or a Decoy. |
| 20 | */ |
| 21 | const GROUPS: Partial<Record<ActionId, readonly ActionId[]>> = { |
| 22 | move: ['kick', 'move', 'run', 'diagonal', 'slide'], |
| 23 | bomb: ['bomb', 'throw', 'rpg', 'detonate'], |
| 24 | charged: ['charged', 'icbm', 'freeze', 'decoy', 'teleport'], |
| 25 | } |
| 26 | |
| 27 | /** The actions a picked one stands for: its group, or just itself. */ |
| 28 | export function membersOf(picked: ActionId): readonly ActionId[] { |
| 29 | return GROUPS[picked] ?? [picked] |
| 30 | } |
| 31 | |
| 32 | /** The action a tap on `target` means with `picked` picked, or null if it means nothing. */ |
| 33 | export function resolvePick(s: GameState, picked: ActionId, target: Vec): ActionId | null { |
| 34 | return membersOf(picked).find((id) => isAvailable(s, id) && isValidTarget(s, id, target)) ?? null |
| 35 | } |
| 36 | |
| 37 | /** Every tile a tap can use with `picked` picked, each once. */ |
| 38 | export function pickTargets(s: GameState, picked: ActionId): Vec[] { |
| 39 | const tiles: Vec[] = [] |
| 40 | for (const id of membersOf(picked)) { |
| 41 | if (!isAvailable(s, id)) continue |
| 42 | for (const p of ACTIONS[id].validTargets(s)) if (!tiles.some((t) => eq(t, p))) tiles.push(p) |
| 43 | } |
| 44 | return tiles |
| 45 | } |
| 46 | |
| 47 | /** Whether `picked` can be picked right now: some action it stands for is available. */ |
| 48 | export function canPick(s: GameState, picked: ActionId): boolean { |
| 49 | return membersOf(picked).some((id) => isAvailable(s, id)) |
| 50 | } |
| 51 | |
| 52 | /** What a tap on a tile means with no button picked (`tapMeaning`). */ |
| 53 | export type TapMeaning = 'deadly' | 'detonate' | 'kick' | 'blocked' | 'move' | 'diagonal' | 'slide' | 'walk' | 'teleport' | 'approach' | 'choose' |
| 54 | |
| 55 | /** The meanings that are an action on the tapped tile, played at once. */ |
| 56 | const TAP_ACTIONS: ReadonlySet<TapMeaning> = new Set(['detonate', 'kick', 'move', 'diagonal', 'slide', 'teleport']) |
| 57 | |
| 58 | /** |
| 59 | * What a tap on `p` means with nothing picked (main.ts `tapTile`; also the demo's taps), first |
| 60 | * match wins: the player's own tile is 'blocked' (it was Wait until 2026-10-26, user request: |
| 61 | * a tap on yourself waited, which could kill); one of your bombs, with the Detonator, goes off; a |
| 62 | * bomb next to you, with Kick, is kicked; an enemy, any other bomb, or flame that can hurt you is |
| 63 | * 'blocked' (never a move into it); then a step, a diagonal step, the tile where a slide stops |
| 64 | * (with Slide: user report, 2026-10-24, it walked there instead), a far tile you can walk to |
| 65 | * ('walk': it becomes auto-move's destination), or one you can only teleport to. |
| 66 | * A bomb two of your actions could deal with (detonate it, kick it, walk onto it with Bomb Pass) |
| 67 | * is 'choose': the UI asks which (`tapChoices`; user request, 2026-10-31). A crate away from the |
| 68 | * player is 'approach': walk to the tile next to it (`approachTile`; same request). Null: it means |
| 69 | * nothing. An action that would cost a life this turn is 'deadly' instead (`isDeadly`, user |
| 70 | * request, 2026-10-26): a tap never takes it; its button still can. |
| 71 | * Auto-move's own destination is the UI's to check: a tap on it is its next step. |
| 72 | */ |
| 73 | export function tapMeaning(s: GameState, p: Vec): TapMeaning | null { |
| 74 | const means = plainMeaning(s, p) |
| 75 | return means && TAP_ACTIONS.has(means) && isDeadly(s, { id: means as ActionId, target: p }) ? 'deadly' : means |
| 76 | } |
| 77 | |
| 78 | /** `tapMeaning`, before asking whether it is deadly. */ |
| 79 | function plainMeaning(s: GameState, p: Vec): TapMeaning | null { |
| 80 | if (eq(p, s.player)) return 'blocked' |
| 81 | if (bombAt(s, p)) { |
| 82 | if (!bombActions(s, p).length) return 'blocked' |
| 83 | const safe = tapChoices(s, p) |
| 84 | if (!safe.length) return 'deadly' |
| 85 | return safe.length > 1 ? 'choose' : (safe[0] as TapMeaning) |
| 86 | } |
| 87 | if (enemyAt(s, p) || (isBurning(s, p) && !flameSafe(s))) return 'blocked' |
| 88 | if (isValidTarget(s, 'move', p)) return 'move' |
| 89 | if (isAvailable(s, 'diagonal') && isValidTarget(s, 'diagonal', p)) return 'diagonal' |
| 90 | if (isAvailable(s, 'slide') && isValidTarget(s, 'slide', p)) return 'slide' |
| 91 | if (cellAt(s, p) === 'floor' && playerPath(s, p)) return 'walk' |
| 92 | if (approachTile(s, p)) return 'approach' |
| 93 | // (A tile only Teleport could reach was a teleport there until 2026-10-25; Teleport is a charge |
| 94 | // now, used only from the charge button, so a tap never spends it.) |
| 95 | return null |
| 96 | } |
| 97 | |
| 98 | /** What a tap on the bomb at `p` could do: set it off, kick it, or (with Bomb Pass, not onto a barrel) walk onto it. */ |
| 99 | function bombActions(s: GameState, p: Vec): ActionId[] { |
| 100 | const barrel = bombAt(s, p)?.barrel |
| 101 | return (['detonate', 'kick', 'move'] as const).filter( |
| 102 | (id) => !(id === 'move' && barrel) && isAvailable(s, id) && isValidTarget(s, id, p), |
| 103 | ) |
| 104 | } |
| 105 | |
| 106 | /** `bombActions` that don't cost a life: the choice a tap on a bomb offers (one is taken at once). */ |
| 107 | export function tapChoices(s: GameState, p: Vec): ActionId[] { |
| 108 | return bombActions(s, p).filter((id) => !isDeadly(s, { id, target: p })) |
| 109 | } |
| 110 | |
| 111 | /** |
| 112 | * What the picked button could do on the bomb at `p`, when that is more than one thing (Move with |
| 113 | * Kick and Bomb Pass: kick it or walk onto it): the UI asks which. Not filtered for danger, as a |
| 114 | * picked button's tile never is (user report, 2026-10-31: Move then the bomb just kicked it). |
| 115 | */ |
| 116 | export function pickChoices(s: GameState, picked: ActionId, p: Vec): ActionId[] { |
| 117 | const ids = bombAt(s, p) ? bombActions(s, p).filter((id) => membersOf(picked).includes(id)) : [] |
| 118 | return ids.length > 1 ? ids : [] |
| 119 | } |
| 120 | |
| 121 | /** |
| 122 | * Where a tap on a crate (soft or metal) walks to: the tile next to it with the shortest way |
| 123 | * there, so the player ends up beside it. Null if the player is already beside it, or there is no |
| 124 | * way (user request, 2026-10-31: with Run, a tap on a crate two tiles away did nothing). |
| 125 | */ |
| 126 | export function approachTile(s: GameState, p: Vec): Vec | null { |
| 127 | const cell = cellAt(s, p) |
| 128 | if (cell !== 'block' && cell !== 'metal') return null |
| 129 | let best: { tile: Vec; steps: number } | null = null |
| 130 | for (const d of DIR_LIST) { |
| 131 | const q = add(p, DIRS[d]) |
| 132 | if (eq(q, s.player)) return null |
| 133 | if (cellAt(s, q) !== 'floor' || enemyAt(s, q) || (isBurning(s, q) && !flameSafe(s))) continue |
| 134 | const steps = playerPath(s, q)?.length |
| 135 | if (steps !== undefined && (!best || steps < best.steps)) best = { tile: q, steps } |
| 136 | } |
| 137 | return best?.tile ?? null |
| 138 | } |