fuzzyworld

explodorama / files / src / ui / padDemo.ts

padDemo.ts

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

92cc4ee3 2026-11-02 Pavel Safronov1import { eq } from '../sim/state'
2import { CONTROL_KINDS, PAD_CONTROL_KINDS, PAD_LATER, type Gesture, type SceneKind } from '../sim/demo'
3import type { Action, GameState, Vec } from '../sim/types'
4import { actionableTiles, BAR_BUTTONS, moveCursor, tileActions, type BarButton } from './padTiles'
5import type { Step } from './spatial'
6import type { PadPart } from './padOverlay'
7import 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 */
16export 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). */
24export interface PadStep {
25 press: PadPress
26 cursor: Vec | null
27}
28
29const 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 */
37export 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 */
60export 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. */
76export 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`). */
87export 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. */
97export 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 */
119export 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}