explodorama / files / src / ui / padTiles.ts
padTiles.ts
| 1 | import { isAvailable } from '../sim/actions' |
| 2 | import { isDeadly } from '../sim/danger' |
| 3 | import { autoMoveAction, playerPath } from '../sim/path' |
| 4 | import { canPick, pickChoices, pickTargets, resolvePick, tapMeaning } from '../sim/picks' |
| 5 | import { eq } from '../sim/state' |
| 6 | import type { ActionId, GameState, Vec } from '../sim/types' |
| 7 | import { targetLabel } from './targetLabel' |
| 8 | import { 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. */ |
| 16 | export type BarButton = 'move' | 'bomb' | 'charged' | 'wait' |
| 17 | export 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. */ |
| 20 | export 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. */ |
| 25 | export 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. */ |
| 40 | export 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 | */ |
| 52 | export 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`. */ |
| 65 | export 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. */ |
| 70 | export 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 | */ |
| 79 | export 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 | */ |
| 92 | export 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. */ |
| 103 | export 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 | */ |
| 117 | export 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 | */ |
| 132 | export 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. */ |
| 147 | export 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 | */ |
| 157 | export 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 | */ |
| 168 | export 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. */ |
| 173 | export 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 | */ |
| 183 | export 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). */ |
| 198 | export 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.) */ |
| 203 | export 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 | */ |
| 211 | export 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 | */ |
| 217 | export 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. */ |
| 224 | export 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 | } |