fuzzyworld

explodorama / files / src / sim / picks.ts

picks.ts

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

99869630 2026-10-23 Pavel Safronov1import { ACTIONS, isAvailable, isValidTarget } from './actions'
e3d8dc49 2026-10-27 Pavel Safronov2import { isDeadly } from './danger'
36c5d6a2 2026-10-24 Pavel Safronov3import { enemyAt } from './enemies'
4import { playerPath } from './path'
bb3823d2 2026-10-31 Pavel Safronov5import { add, bombAt, cellAt, eq, flameSafe, isBurning } from './state'
6import { DIRS, DIR_LIST, type ActionId, type GameState, type Vec } from './types'
99869630 2026-10-23 Pavel Safronov7
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
28670c3e 2026-10-24 Pavel Safronov16 * slide), a plain step beats a run, and a diagonal step beats a teleport to the same tile (a
7f242d36 2026-10-25 Pavel Safronov17 * tile next to you is a step; user report, 2026-10-23). A teleport beats a slide to the same
d3e4ed26 2026-10-25 Pavel Safronov18 * 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.
99869630 2026-10-23 Pavel Safronov20 */
21const GROUPS: Partial<Record<ActionId, readonly ActionId[]>> = {
fcf44f32 2026-10-25 Pavel Safronov22 move: ['kick', 'move', 'run', 'diagonal', 'slide'],
d3e4ed26 2026-10-25 Pavel Safronov23 bomb: ['bomb', 'throw', 'rpg', 'detonate'],
5f066960 2026-10-26 Pavel Safronov24 charged: ['charged', 'icbm', 'freeze', 'decoy', 'teleport'],
99869630 2026-10-23 Pavel Safronov25}
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}
36c5d6a2 2026-10-24 Pavel Safronov51
52/** What a tap on a tile means with no button picked (`tapMeaning`). */
bb3823d2 2026-10-31 Pavel Safronov53export type TapMeaning = 'deadly' | 'detonate' | 'kick' | 'blocked' | 'move' | 'diagonal' | 'slide' | 'walk' | 'teleport' | 'approach' | 'choose'
e3d8dc49 2026-10-27 Pavel Safronov54
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'])
36c5d6a2 2026-10-24 Pavel Safronov57
58/**
59 * What a tap on `p` means with nothing picked (main.ts `tapTile`; also the demo's taps), first
e3d8dc49 2026-10-27 Pavel Safronov60 * 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
bb3823d2 2026-10-31 Pavel Safronov65 * ('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
e3d8dc49 2026-10-27 Pavel Safronov69 * 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.
36c5d6a2 2026-10-24 Pavel Safronov71 * 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 {
e3d8dc49 2026-10-27 Pavel Safronov74 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'
bb3823d2 2026-10-31 Pavel Safronov81 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'
36c5d6a2 2026-10-24 Pavel Safronov88 if (isValidTarget(s, 'move', p)) return 'move'
89 if (isAvailable(s, 'diagonal') && isValidTarget(s, 'diagonal', p)) return 'diagonal'
7f242d36 2026-10-25 Pavel Safronov90 if (isAvailable(s, 'slide') && isValidTarget(s, 'slide', p)) return 'slide'
36c5d6a2 2026-10-24 Pavel Safronov91 if (cellAt(s, p) === 'floor' && playerPath(s, p)) return 'walk'
bb3823d2 2026-10-31 Pavel Safronov92 if (approachTile(s, p)) return 'approach'
fcf44f32 2026-10-25 Pavel Safronov93 // (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.)
36c5d6a2 2026-10-24 Pavel Safronov95 return null
96}
bb3823d2 2026-10-31 Pavel Safronov97
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}