fuzzyworld

explodorama / files / src / sim / picks.ts

picks.ts

7.1 KB · history · blame

1import { ACTIONS, isAvailable, isValidTarget } from './actions'
2import { isDeadly } from './danger'
3import { enemyAt } from './enemies'
4import { playerPath } from './path'
5import { add, bombAt, cellAt, eq, flameSafe, isBurning } from './state'
6import { 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 */
21const 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. */
28export 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. */
33export 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. */
38export 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. */
48export 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`). */
53export 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. */
56const 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 */
73export 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. */
79function 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. */
99function 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). */
107export 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 */
116export 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 */
126export 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}