explodorama / files / src / ui / padDemo.ts
padDemo.ts
| 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 | } |