fuzzyworld

explodorama / files / src / ui / padTiles.ts

padTiles.ts

11.1 KB · history · blame

1import { isAvailable } from '../sim/actions'
2import { isDeadly } from '../sim/danger'
3import { autoMoveAction, playerPath } from '../sim/path'
4import { canPick, pickChoices, pickTargets, resolvePick, tapMeaning } from '../sim/picks'
5import { eq } from '../sim/state'
6import type { ActionId, GameState, Vec } from '../sim/types'
7import { targetLabel } from './targetLabel'
8import { nextFocus, type Step } from './spatial'
9
10/**
11 * The controller's "tile first" board play (decisions.md 2026-11-01, supersedes the aim, step and
12 * cycle models): a cursor sits on one actionable tile, and the four face buttons do what the
13 * action bar's buttons would do there. Pure: main.ts hands in the state.
14 */
15/** The action bar's buttons, in the order the label lists them. Cross, Square, Triangle, Circle. */
16export type BarButton = 'move' | 'bomb' | 'charged' | 'wait'
17export const BAR_BUTTONS: readonly BarButton[] = ['move', 'bomb', 'charged', 'wait']
18
19/** The pad's face-button meaning (padMap.ts `PadButton`: which button that is depends on the kind of pad) to the bar button it does. */
20export function barButtonFor(button: 'move' | 'wait' | 'bomb' | 'charge'): BarButton {
21 return button === 'charge' ? 'charged' : button
22}
23
24/** What one bar button offers on the cursor's tile. */
25export interface TileAction {
26 button: BarButton
27 /** Whether it can be done on this tile (a greyed button when not). */
28 possible: boolean
29 /** The action it would be (a kick, a run ...); null when not possible. */
30 id: ActionId | null
31 /** Doing it would cost a life this turn: marked, but still played. */
32 deadly: boolean
33 /** One line, as targetLabel says it (including "(costs a life!)"); empty when not possible. */
34 label: string
35 /** Auto-move's walk to a faded tile (`farTiles`), not an action on the tile itself: the ghost has nothing to preview there. */
36 far?: boolean
37}
38
39/** A tile only auto-move reaches (decisions.md 2026-11-02): drawn faded blue, and how many steps the walk is. */
40export interface FarTile {
41 tile: Vec
42 steps: number
43}
44
45/**
46 * Every tile tapping it would walk to with auto-move, and take a safe first move toward: the
47 * faded blue. Same rules as `tapTile` (`tapMeaning` says 'walk', `autoMoveAction` finds a move
48 * with `safe`, and `isDeadly` has no objection), minus the bright tiles (`actionableTiles`) and
49 * the player's own. With a destination given, only it (main.ts no longer passes one since Q75: all faded tiles stay reachable while a walk is set). The
50 * walk's length is `playerPath`'s, nearest tiles first.
51 */
52export function farTiles(s: GameState, safe: (p: Vec) => boolean, destination: Vec | null = null): FarTile[] {
53 const bright = brightKeys(s)
54 const far: FarTile[] = []
55 const check = (p: Vec): void => {
56 const f = farTileAt(s, safe, p, bright)
57 if (f) far.push(f)
58 }
59 if (destination) check(destination)
60 else for (let y = 0; y < s.height; y++) for (let x = 0; x < s.width; x++) check({ x, y })
61 return far.sort((a, b) => a.steps - b.steps)
62}
63
64/** The bright tiles as board indices, for `farTileAt`. */
65export function brightKeys(s: GameState): Set<number> {
66 return new Set(actionableTiles(s).map((t) => t.y * s.width + t.x))
67}
68
69/** `farTiles`' test for one tile (the mouse's hover asks it for the one tile under the pointer): the walk to it, or null if it is not a faded tile. */
70export function farTileAt(s: GameState, safe: (p: Vec) => boolean, p: Vec, bright: ReadonlySet<number> = brightKeys(s)): FarTile | null {
71 return bright.has(p.y * s.width + p.x) ? null : walkTo(s, safe, p)
72}
73
74/**
75 * The walk auto-move would take to `p` (a tap on it with nothing picked), bright tile or not:
76 * `tapMeaning` says 'walk', a safe first move exists, and it is not deadly. Q75b: the controller
77 * offers Move on such a tile even when it is also a target of a bomb or charge.
78 */
79export function walkTo(s: GameState, safe: (p: Vec) => boolean, p: Vec): FarTile | null {
80 if (tapMeaning(s, p) !== 'walk') return null
81 const move = autoMoveAction(s, p, safe)
82 const steps = playerPath(s, p)?.length
83 return move && steps && !isDeadly(s, move) ? { tile: p, steps } : null
84}
85
86/**
87 * What the four buttons do on the cursor's `tile` (Q75b, decisions.md 2026-11-02): a faded tile
88 * offers only the walk (`farActions`); any other tile offers what `tileActions` finds, and Move
89 * as a walk too when auto-move can get there and Move itself cannot target it (a tile that is
90 * also an RPG, throw or ICBM target: Bomb stays possible beside it).
91 */
92export function cursorActions(s: GameState, safe: (p: Vec) => boolean, tile: Vec, destination: Vec | null, bright: ReadonlySet<number> = brightKeys(s)): TileAction[] {
93 const going = !!destination && eq(destination, tile)
94 const walk = walkTo(s, safe, tile)
95 if (walk && !bright.has(tile.y * s.width + tile.x)) return farActions(walk, going)
96 const actions = tileActions(s, tile)
97 if (!walk) return actions
98 const far = farActions(walk, going).find((a) => a.button === 'move')!
99 return actions.map((a) => (a.button === 'move' && !a.possible ? far : a))
100}
101
102/** What the faded tile offers: Move/Go, the walk there (every other button greyed). `going`: it is auto-move's destination already, so pressing is its next step. */
103export function farActions(f: FarTile, going: boolean): TileAction[] {
104 const steps = `${f.steps} step${f.steps === 1 ? '' : 's'}`
105 return BAR_BUTTONS.map((button): TileAction =>
106 button === 'move'
107 ? { button, possible: true, id: 'move', deadly: false, label: `${going ? 'Keep walking' : 'Walk here'} · ${steps}`, far: true }
108 : { button, possible: false, id: null, deadly: false, label: '' },
109 )
110}
111
112/**
113 * Every tile a bar button could be used on now (Move, Bomb, Charge: `pickTargets` of each, so
114 * kicks, runs, throws and teleports are in), and the player's own tile (Wait). The player's tile
115 * comes first; each tile once.
116 */
117export function actionableTiles(s: GameState): Vec[] {
118 const tiles: Vec[] = [{ ...s.player }]
119 for (const button of ['move', 'bomb', 'charged'] as const) {
120 if (!canPick(s, button)) continue
121 for (const t of pickTargets(s, button)) if (!tiles.some((o) => eq(o, t))) tiles.push(t)
122 }
123 return tiles
124}
125
126/**
127 * What each of the four bar buttons does on `tile`. Wait only on the player's own tile; the
128 * others where `resolvePick` finds an action (or a choice of two, for a bomb). A button is
129 * `deadly` by the same test the board uses for its red tiles, `isDeadly` of the action there (for
130 * Wait, of waiting).
131 */
132export function tileActions(s: GameState, tile: Vec): TileAction[] {
133 return BAR_BUTTONS.map((button): TileAction => {
134 const none: TileAction = { button, possible: false, id: null, deadly: false, label: '' }
135 if (button === 'wait') {
136 if (!eq(tile, s.player) || !isAvailable(s, 'wait')) return none
137 return { button, possible: true, id: 'wait', deadly: isDeadly(s, { id: 'wait' }), label: targetLabel(s, 'wait', null) }
138 }
139 const id = resolvePick(s, button, tile)
140 if (!id) return none
141 const options = pickChoices(s, button, tile)
142 return { button, possible: true, id, deadly: isDeadly(s, { id, target: tile }), label: targetLabel(s, button, tile) || (options.length ? 'Choose' : '') }
143 })
144}
145
146/** Where the cursor rests: on the player's own tile, at the start of each turn, a level or run, and after a dialog. */
147export function restingCursor(s: GameState): Vec {
148 return { ...s.player }
149}
150
151/**
152 * Where the cursor starts each turn (decisions.md 2026-11-02): on the destination while an auto-move
153 * walk is on, so "Keep walking" is at hand; else on the player (`restingCursor`). The destination
154 * only if it is still one of the `candidates` (when given): a flame may have made it unsafe, and a cursor
155 * on a tile with no action can neither act nor move (Q80).
156 */
157export function cursorStart(s: GameState, destination: Vec | null, candidates?: readonly Vec[]): Vec {
158 if (!destination || eq(destination, s.player)) return restingCursor(s)
159 if (candidates && !candidates.some((t) => eq(t, destination))) return restingCursor(s)
160 return { ...destination }
161}
162
163/**
164 * Whether the cursor is on screen: always, while the pad is in use and the board is being
165 * played (no dialog, not the demo, not "What is that?", no gem or charge offer waiting for its dialog,
166 * not while the autopilot walks, the run not between levels or over).
167 */
168export function cursorWanted(c: { padActive: boolean; demo: boolean; dialogOpen: boolean; inspecting: boolean; offerPending: boolean; autopilot: boolean; status: GameState['status'] }): boolean {
169 return c.padActive && !c.demo && !c.dialogOpen && !c.inspecting && !c.offerPending && !c.autopilot && c.status === 'playing'
170}
171
172/** The cursor moved by the d-pad: the best actionable tile that way, or where it is if none. */
173export function moveCursor(tiles: readonly Vec[], current: Vec, dx: Step, dy: Step): Vec {
174 const at = tiles.findIndex((t) => eq(t, current))
175 if (at < 0) return current
176 return tiles[nextFocus(tiles.map((t) => ({ x: t.x, y: t.y, w: 1, h: 1 })), at, dx, dy)] ?? current
177}
178
179/**
180 * After the board changed: the cursor stays if its tile is still actionable, else goes to the
181 * nearest tile that is, else (no tiles) nowhere.
182 */
183export function keepCursor(tiles: readonly Vec[], current: Vec): Vec | null {
184 if (tiles.some((t) => eq(t, current))) return current
185 let best: Vec | null = null
186 let bestDist = Infinity
187 for (const t of tiles) {
188 const d = Math.hypot(t.x - current.x, t.y - current.y)
189 if (d < bestDist) {
190 best = t
191 bestDist = d
192 }
193 }
194 return best
195}
196
197/** The label's lines: each possible button and what it does (main.ts draws the button's picture before the text). */
198export function cursorLines(actions: readonly TileAction[]): { button: BarButton; label: string }[] {
199 return actions.filter((a) => a.possible).map((a) => ({ button: a.button, label: a.label }))
200}
201
202/** Whether the cursor's tile is drawn red: something is possible there and every possible action would cost a life. (If only some would, the tile stays blue and the red-marked buttons say which.) */
203export function cursorDeadly(actions: readonly TileAction[]): boolean {
204 const possible = actions.filter((a) => a.possible)
205 return possible.length > 0 && possible.every((a) => a.deadly)
206}
207
208/**
209 * What L1's hint says when there is nothing to undo, so the legend does not jump around.
210 */
211export const IDLE_L1_TEXT = 'Move cursor back to you'
212
213/**
214 * The board hint for L1 (it returns the cursor to the player, and puts away what is in the way):
215 * what it says now, and whether it is dimmed because there is nothing to undo.
216 */
217export function l1Hint(c: { inspecting: boolean; picked: boolean; onPlayer: boolean }): { text: string; dim: boolean } {
218 if (c.inspecting) return { text: IDLE_L1_TEXT, dim: false }
219 if (c.picked) return { text: 'Cancel what you picked', dim: false }
220 return { text: IDLE_L1_TEXT, dim: c.onPlayer }
221}
222
223/** The first action of Move, Bomb, Charge that is possible on a tile that is not the player's own: what the ghost previews. */
224export function ghostAction(actions: readonly TileAction[], tile: Vec, player: Vec): { id: ActionId; target: Vec } | null {
225 if (eq(tile, player)) return null
226 const first = actions.find((a) => a.button !== 'wait' && a.possible && a.id && !a.far)
227 return first ? { id: first.id!, target: tile } : null
228}