explodorama / files / src / ui / padDemo.ts
padDemo.ts
92cc4ee3 2026-11-02 Pavel Safronov | 1 | import { eq } from '../sim/state' |
| 2 | import { CONTROL_KINDS, PAD_CONTROL_KINDS, PAD_LATER, type Gesture, type SceneKind } from '../sim/demo' | |
| 3 | import type { Action, GameState, Vec } from '../sim/types' | |
| 4 | import { actionableTiles, BAR_BUTTONS, moveCursor, tileActions, type BarButton } from './padTiles' | |
| 5 | import type { Step } from './spatial' | |
| 6 | import type { PadPart } from './padOverlay' | |
| 7 | import type { StickDir } from './padGlyph' | |
| 8 | ||
| 9 | /** | |
| 10 | * The controller's version of a demo turn (Q58): the button presses that play a turn's action | |
| 11 | * with the pad's "tile first" board play (padTiles.ts), so the demo can show a virtual controller | |
| 12 | * lighting up instead of a finger. Pure: it works from the turn's concrete action and the state, | |
| 13 | * not from the touch gesture, so every scene whose turns are plain moves, bombs, charges and | |
| 14 | * waits has a pad version for free. | |
| 15 | */ | |
| 16 | export type PadPress = | |
| 17 | | { kind: 'dir'; dx: Step; dy: Step } | |
| 18 | | { kind: 'face'; button: BarButton } | |
| 19 | | { kind: 'shoulder'; side: 'l' | 'r' } | |
| 20 | | { kind: 'stick'; dir: 'left' | 'right' | 'up' | 'down' | 'press' } | |
| 21 | | { kind: 'start' } | |
| 22 | ||
| 23 | /** One press, and where the cursor is after it (null: not shown). */ | |
| 24 | export interface PadStep { | |
| 25 | press: PadPress | |
| 26 | cursor: Vec | null | |
| 27 | } | |
| 28 | ||
| 29 | const DIRS4: readonly [Step, Step][] = [[0, -1], [0, 1], [-1, 0], [1, 0]] | |
| 30 | ||
| 31 | /** | |
| 32 | * The d-pad presses that take the cursor from the player's tile to `to`, over the actionable | |
| 33 | * tiles (the only places it can rest): the shortest way, found by trying the four directions from | |
| 34 | * each tile with `moveCursor`. Empty if `to` is the player's tile; null if the d-pad cannot get | |
| 35 | * there (not an actionable tile, or not reachable by the pad's jumps). | |
| 36 | */ | |
| 37 | export function cursorPath(s: GameState, to: Vec): { dx: Step; dy: Step; cursor: Vec }[] | null { | |
| 38 | const tiles = actionableTiles(s) | |
| 39 | if (!tiles.some((t) => eq(t, to))) return null | |
| 40 | const start = { ...s.player } | |
| 41 | const seen: { at: Vec; path: { dx: Step; dy: Step; cursor: Vec }[] }[] = [{ at: start, path: [] }] | |
| 42 | for (let i = 0; i < seen.length; i++) { | |
| 43 | const { at, path } = seen[i]! | |
| 44 | if (eq(at, to)) return path | |
| 45 | for (const [dx, dy] of DIRS4) { | |
| 46 | const next = moveCursor(tiles, at, dx, dy) | |
| 47 | if (seen.some((o) => eq(o.at, next))) continue | |
| 48 | seen.push({ at: next, path: [...path, { dx, dy, cursor: next }] }) | |
| 49 | } | |
| 50 | } | |
| 51 | return null | |
| 52 | } | |
| 53 | ||
| 54 | /** | |
| 55 | * The presses that play `action` on the pad, or null when the pad cannot show it yet: a choice or | |
| 56 | * a pick (dialogs), an action with no tile that is not Wait, a tile the d-pad cannot reach, or a | |
| 57 | * tile where no face button does this action (a bomb tile with two options, say). Wait is one | |
| 58 | * press on the player's own tile, where the cursor starts. | |
| 59 | */ | |
| 60 | export function padPlan(s: GameState, action: Action, g: Gesture): PadStep[] | null { | |
| 61 | if (g.kind === 'choice' || g.kind === 'pick') return null | |
| 62 | if (action.id === 'wait') return [{ press: { kind: 'face', button: 'wait' }, cursor: { ...s.player } }] | |
| 63 | const target = 'target' in action ? action.target : undefined | |
| 64 | if (!target) return null | |
| 65 | const path = cursorPath(s, target) | |
| 66 | if (!path) return null | |
| 67 | const button = BAR_BUTTONS.find((b) => tileActions(s, target).some((a) => a.button === b && a.possible && a.id === action.id)) | |
| 68 | if (!button) return null | |
| 69 | return [ | |
| 70 | ...path.map((p): PadStep => ({ press: { kind: 'dir', dx: p.dx, dy: p.dy }, cursor: p.cursor })), | |
| 71 | { press: { kind: 'face', button }, cursor: { ...target } }, | |
| 72 | ] | |
| 73 | } | |
| 74 | ||
| 75 | /** Which part of the virtual controller (padOverlay.ts) a press lights, and for the stick which way it is pushed. */ | |
| 76 | export function pressPart(press: PadPress): { part: PadPart; stickDir?: StickDir } { | |
| 77 | switch (press.kind) { | |
| 78 | case 'dir': return { part: press.dy < 0 ? 'up' : press.dy > 0 ? 'down' : press.dx < 0 ? 'left' : 'right' } | |
| 79 | case 'face': return { part: press.button === 'charged' ? 'charge' : press.button } | |
| 80 | case 'shoulder': return { part: press.side === 'l' ? 'l1' : 'r1' } | |
| 81 | case 'stick': return { part: 'stick', stickDir: press.dir } | |
| 82 | case 'start': return { part: 'start' } | |
| 83 | } | |
| 84 | } | |
| 85 | ||
| 86 | /** The captions of the pad's own scenes, on a still room (`{dpad}`, `{l1}` ... as in padGlyph.ts `padCaptionHtml`). */ | |
| 87 | export const PAD_STILL_CAPTIONS = { | |
| 88 | padCursor: '{dpad} moves the cursor between the blue tiles. The face buttons show what they do there.', | |
| 89 | padL1: '{l1} puts the cursor back on you.', | |
| 90 | padR1: 'Not sure what something is? Press {r1}, move to it, press {move}.', | |
| 91 | padStick: '{stick} looks around the board; press it to come back.', | |
| 92 | padLook: 'Something far away? Push {stick} toward it to look there.', | |
| 93 | padStart: '{start} opens the menu.', | |
| 94 | } as const | |
| 95 | ||
| 96 | /** The pad's own scenes: the cursor's hops (up to `count`), each to a tile it has not been on, from the player's. */ | |
| 97 | export function padHops(s: GameState, count: number): { press: PadPress; to: Vec }[] { | |
| 98 | const tiles = actionableTiles(s) | |
| 99 | const seen: Vec[] = [{ ...s.player }] | |
| 100 | const hops: { press: PadPress; to: Vec }[] = [] | |
| 101 | for (let i = 0; i < count; i++) { | |
| 102 | const from = seen[seen.length - 1]! | |
| 103 | const next = ([[1, 0], [0, 1], [-1, 0], [0, -1]] as const) | |
| 104 | .map(([dx, dy]) => ({ dx, dy, to: moveCursor(tiles, from, dx, dy) })) | |
| 105 | .find((c) => !seen.some((o) => eq(o, c.to))) | |
| 106 | if (!next) break | |
| 107 | seen.push(next.to) | |
| 108 | hops.push({ press: { kind: 'dir', dx: next.dx, dy: next.dy }, to: next.to }) | |
| 109 | } | |
| 110 | return hops | |
| 111 | } | |
| 112 | ||
| 113 | /** | |
| 114 | * The gamepad's demo order (Q58) from the touch demo's: the controller's own scenes first (the | |
| 115 | * cursor, the control scenes, L1, R1, the stick, looking at something far, Start), then the touch order's scenes, but for the | |
| 116 | * touch-only ones (a long press, auto-move, the top bar, "What is that?" by tap) and those that | |
| 117 | * need a dialog's answer the pad demo cannot show yet (PAD_LATER). | |
| 118 | */ | |
| 119 | export function padDemoOrder<T extends string>(touchOrder: readonly T[]): (T | 'padCursor' | 'padL1' | 'padR1' | 'padStick' | 'padLook' | 'padStart' | SceneKind)[] { | |
| 120 | return [ | |
| 121 | 'padCursor', | |
| 122 | ...PAD_CONTROL_KINDS, | |
| 123 | 'padL1', | |
| 124 | 'padR1', | |
| 125 | 'padStick', | |
| 126 | 'padLook', | |
| 127 | 'padStart', | |
| 128 | ...touchOrder.filter((k) => !CONTROL_KINDS.includes(k as SceneKind) && k !== 'topBar' && k !== 'whatIsThat' && !PAD_LATER.includes(k as SceneKind)), | |
| 129 | ] | |
| 130 | } |