explodorama / files / src / ui / keyDemo.ts
keyDemo.ts
92cc4ee3 2026-11-02 Pavel Safronov | 1 | import { eq } from '../sim/state' |
| 2 | import { isDeadly } from '../sim/danger' | |
| 3 | import { ACTIONS, isAvailable } from '../sim/actions' | |
| 4 | import { canPick, membersOf, pickChoices, resolvePick, tapChoices } from '../sim/picks' | |
| 5 | import { CONTROL_KINDS, PAD_CONTROL_KINDS, PAD_LATER, type Gesture, type SceneKind } from '../sim/demo' | |
| 6 | import type { Action, ActionId, GameState, Vec } from '../sim/types' | |
| 7 | import { keyButton } from './keyCursor' | |
| 8 | import { cursorPath } from './padDemo' | |
| 9 | import { actionableTiles, moveCursor, tileActions, type BarButton } from './padTiles' | |
| 10 | import type { Step } from './spatial' | |
| 11 | ||
| 12 | /** | |
| 13 | * The keyboard's version of a demo turn (Q65, part B): the key presses that play a turn's action | |
| 14 | * the shortest way the game accepts (a plain arrow, an action key then an arrow, or Shift + arrows | |
| 15 | * for the cursor and an action key), so the demo can show a key overlay lighting up instead of a | |
| 16 | * finger. Pure, like padDemo.ts: it works from the turn's concrete action and the state, and | |
| 17 | * `runKeys` replays the keys through the same rules the game applies (keyCursor.ts, picks.ts, | |
| 18 | * padTiles.ts), which is how a plan is checked. | |
| 19 | */ | |
| 20 | export type KeyPress = | |
| 21 | /** A plain arrow (or W A S D): a step, or with an action key picked, that action at the nearest tile that way. */ | |
| 22 | | { kind: 'arrow'; dx: Step; dy: Step } | |
| 23 | /** Shift + arrow: the cursor hops (the first press only shows it, on the player). */ | |
| 24 | | { kind: 'hop'; dx: Step; dy: Step } | |
| 25 | /** Alt + arrow: the view pans. */ | |
| 26 | | { kind: 'pan'; dx: Step; dy: Step } | |
| 27 | /** An action key: M, B, V (the charge) or Space (Wait). */ | |
| 28 | | { kind: 'key'; button: BarButton } | |
| 29 | | { kind: 'inspect' } | |
| 30 | | { kind: 'enter' } | |
| 31 | | { kind: 'esc' } | |
| 32 | | { kind: 'home' } | |
| 33 | ||
| 34 | /** One press, and what the board shows after it: the cursor (null: not shown) and the picked action key (null: none). */ | |
| 35 | export interface KeyStep { | |
| 36 | press: KeyPress | |
| 37 | cursor: Vec | null | |
| 38 | picked: BarButton | null | |
| 39 | } | |
| 40 | ||
| 41 | /** What a run of keys did: the action played. */ | |
| 42 | export interface KeyResult { | |
| 43 | id: ActionId | |
| 44 | target?: Vec | |
| 45 | } | |
| 46 | ||
| 47 | /** | |
| 48 | * Presses the keys on `s`, as the game takes them: the cursor and the picked key after each press, | |
| 49 | * and the action the last press played (null when the keys play nothing, or play it before the | |
| 50 | * last press, or ask a question the demo cannot answer: a bomb two actions could deal with, a | |
| 51 | * step that costs a life). | |
| 52 | */ | |
| 53 | export function runKeys(s: GameState, presses: readonly KeyPress[]): { states: { cursor: Vec | null; picked: BarButton | null }[]; result: KeyResult | null } { | |
| 54 | const tiles = actionableTiles(s) | |
| 55 | let cursor: Vec | null = null | |
| 56 | let picked: BarButton | null = null | |
| 57 | const states: { cursor: Vec | null; picked: BarButton | null }[] = [] | |
| 58 | for (const [i, p] of presses.entries()) { | |
| 59 | let result: KeyResult | null | undefined | |
| 60 | if (p.kind === 'hop') { | |
| 61 | // The press that wakes the cursor only shows it, on the player (main.ts `keyHop`). | |
| 62 | cursor = cursor ? moveCursor(tiles, cursor, p.dx, p.dy) : { ...s.player } | |
| 63 | } else if (p.kind === 'arrow') { | |
| 64 | cursor = null | |
| 65 | result = directionResult(s, picked, p.dx, p.dy) | |
| 66 | } else if (p.kind === 'key') { | |
| 67 | if (cursor) { | |
| 68 | const button = keyButton(tileActions(s, cursor), p.button) | |
| 69 | const action = button ? tileActions(s, cursor).find((a) => a.button === button) : undefined | |
| 70 | // Space away from the player does not wait (main.ts `keyAction`). | |
| 71 | if (p.button === 'wait' && !button) result = null | |
| 72 | else if (action?.possible && action.id) result = action.id === 'wait' ? { id: 'wait' } : { id: action.id, target: { ...cursor } } | |
| 73 | } | |
| 74 | if (result === undefined) { | |
| 75 | // No cursor (or nothing there for the key): the key picks the action, as ever (main.ts `pick`). | |
| 76 | const now = membersOf(p.button).find((a) => isAvailable(s, a) && !ACTIONS[a].needsTarget) | |
| 77 | if (!canPick(s, p.button)) result = null | |
| 78 | else if (now) result = { id: now } | |
| 79 | else if (picked === p.button && resolvePick(s, p.button, s.player) === 'freeze') result = { id: 'freeze', target: { ...s.player } } | |
| 80 | else picked = picked === p.button ? null : p.button | |
| 81 | } | |
| 82 | } | |
| 83 | states.push({ cursor: cursor ? { ...cursor } : null, picked }) | |
| 84 | if (result !== undefined) return { states, result: result && i === presses.length - 1 ? result : null } | |
| 85 | } | |
| 86 | return { states, result: null } | |
| 87 | } | |
| 88 | ||
| 89 | /** What a plain arrow does with `picked` picked: main.ts `directionPlan`, less the questions it asks. */ | |
| 90 | function directionResult(s: GameState, picked: BarButton | null, dx: Step, dy: Step): KeyResult | null { | |
| 91 | const base: ActionId = picked && ACTIONS[picked].needsTarget ? picked : 'move' | |
| 92 | const reaches = picked ? Array.from({ length: Math.max(s.width, s.height) }, (_, i) => i + 1) : [1] | |
| 93 | for (const reach of reaches) { | |
| 94 | const target = { x: s.player.x + reach * dx, y: s.player.y + reach * dy } | |
| 95 | const id = resolvePick(s, base, target) | |
| 96 | if (!id) continue | |
| 97 | const options = picked ? pickChoices(s, base, target) : tapChoices(s, target) | |
| 98 | if (options.length > 1) return null | |
| 99 | if (!picked && options.length === 1) return { id: options[0]!, target } | |
| 100 | if (!picked && isDeadly(s, { id, target })) return null | |
| 101 | return { id, target } | |
| 102 | } | |
| 103 | return null | |
| 104 | } | |
| 105 | ||
| 106 | const ACTION_BUTTONS = ['move', 'bomb', 'charged'] as const | |
| 107 | ||
| 108 | /** The key sequences that might play `action`, in the order to prefer when two are equally short. */ | |
| 109 | function candidates(s: GameState, action: Action): KeyPress[][] { | |
| 110 | if (action.id === 'wait') return [[{ kind: 'key', button: 'wait' }]] | |
| 111 | const target = 'target' in action ? action.target : undefined | |
| 112 | if (!target) return [] | |
| 113 | const out: KeyPress[][] = [] | |
| 114 | const dx = Math.sign(target.x - s.player.x) as Step | |
| 115 | const dy = Math.sign(target.y - s.player.y) as Step | |
| 116 | if ((dx === 0) !== (dy === 0)) { | |
| 117 | out.push([{ kind: 'arrow', dx, dy }]) | |
| 118 | for (const button of ACTION_BUTTONS) out.push([{ kind: 'key', button }, { kind: 'arrow', dx, dy }]) | |
| 119 | } | |
| 120 | // Freeze on yourself: the key twice (the second confirms). | |
| 121 | if (eq(target, s.player)) for (const button of ACTION_BUTTONS) out.push([{ kind: 'key', button }, { kind: 'key', button }]) | |
| 122 | const path = cursorPath(s, target) | |
| 123 | if (path?.length) { | |
| 124 | const hops = path.map((p): KeyPress => ({ kind: 'hop', dx: p.dx, dy: p.dy })) | |
| 125 | // The first Shift + arrow only wakes the cursor on the player; the hops follow. | |
| 126 | for (const button of ACTION_BUTTONS) out.push([hops[0]!, ...hops, { kind: 'key', button }]) | |
| 127 | } | |
| 128 | return out | |
| 129 | } | |
| 130 | ||
| 131 | /** | |
| 132 | * The keys that play `action` with the fewest presses, each step with the board's cursor and picked | |
| 133 | * key after it; null when the keyboard cannot show it (a dialog's choice or pick, an action | |
| 134 | * with no tile that is not Wait, no sequence that plays it). | |
| 135 | */ | |
| 136 | export function keyPlan(s: GameState, action: Action, g: Gesture): KeyStep[] | null { | |
| 137 | if (g.kind === 'choice' || g.kind === 'pick') return null | |
| 138 | let best: { presses: KeyPress[]; states: { cursor: Vec | null; picked: BarButton | null }[] } | null = null | |
| 139 | for (const presses of candidates(s, action)) { | |
| 140 | if (best && presses.length >= best.presses.length) continue | |
| 141 | const { states, result } = runKeys(s, presses) | |
| 142 | if (!result || result.id !== action.id) continue | |
| 143 | const target = 'target' in action ? action.target : undefined | |
| 144 | if (!!target !== !!result.target || (target && result.target && !eq(target, result.target))) continue | |
| 145 | best = { presses, states } | |
| 146 | } | |
| 147 | return best && best.presses.map((press, i) => ({ press, ...best!.states[i]! })) | |
| 148 | } | |
| 149 | ||
| 150 | /** The parts of the keyboard the key overlay (keyOverlay.ts) draws. */ | |
| 151 | export type KeyPart = 'up' | 'down' | 'left' | 'right' | 'move' | 'bomb' | 'charge' | 'wait' | 'shift' | 'alt' | 'enter' | 'esc' | 'inspect' | 'home' | |
| 152 | ||
| 153 | /** Which keys of the overlay a press lights: a hop holds Shift, a pan Alt. */ | |
| 154 | export function pressParts(press: KeyPress): KeyPart[] { | |
| 155 | const dir = (p: { dx: Step; dy: Step }): KeyPart => (p.dy < 0 ? 'up' : p.dy > 0 ? 'down' : p.dx < 0 ? 'left' : 'right') | |
| 156 | switch (press.kind) { | |
| 157 | case 'arrow': return [dir(press)] | |
| 158 | case 'hop': return ['shift', dir(press)] | |
| 159 | case 'pan': return ['alt', dir(press)] | |
| 160 | case 'key': return [press.button === 'charged' ? 'charge' : press.button] | |
| 161 | default: return [press.kind] | |
| 162 | } | |
| 163 | } | |
| 164 | ||
| 165 | /** The picture (Kenney's Input Prompts, Keyboard & Mouse, Default) of each key of the overlay, as a sprite name. */ | |
| 166 | export const KEY_GLYPHS: Record<KeyPart, string> = { | |
| 167 | up: 'keyboard_arrow_up', down: 'keyboard_arrow_down', left: 'keyboard_arrow_left', right: 'keyboard_arrow_right', | |
| 168 | move: 'keyboard_m', bomb: 'keyboard_b', charge: 'keyboard_v', wait: 'keyboard_space', | |
| 169 | shift: 'keyboard_shift', alt: 'keyboard_alt', enter: 'keyboard_enter', esc: 'keyboard_escape', inspect: 'keyboard_question', home: 'keyboard_home', | |
| 170 | } | |
| 171 | ||
| 172 | /** The caption tokens: each is a key's picture (`{arrows}` the four arrows together). */ | |
| 173 | const CAPTION_GLYPHS: Record<string, string> = { | |
| 174 | arrows: 'keyboard_arrows', M: KEY_GLYPHS.move, B: KEY_GLYPHS.bomb, V: KEY_GLYPHS.charge, space: KEY_GLYPHS.wait, | |
| 175 | shift: KEY_GLYPHS.shift, alt: KEY_GLYPHS.alt, enter: KEY_GLYPHS.enter, esc: KEY_GLYPHS.esc, question: KEY_GLYPHS.inspect, home: KEY_GLYPHS.home, | |
| 176 | } | |
| 177 | ||
| 178 | /** | |
| 179 | * A keyboard demo caption with its `{token}`s turned into key pictures, as inline `<img | |
| 180 | * class="pad-glyph">` (`url` turns a sprite name into its address: `spriteUrl`). Tokens: {arrows}, | |
| 181 | * {M}, {B}, {V}, {space}, {shift}, {alt}, {enter}, {esc}, {home}, {question}. An unknown token | |
| 182 | * throws, so a typo in a caption shows up in the tests. The text itself is not escaped: captions are ours. | |
| 183 | */ | |
| 184 | export function keyCaptionHtml(text: string, url: (name: string) => string): string { | |
| 185 | return text.replace(/\{(\w+)\}/g, (_, token: string) => { | |
| 186 | const name = CAPTION_GLYPHS[token] | |
| 187 | if (!name) throw new Error(`Unknown caption token {${token}}`) | |
| 188 | return `<img class="pad-glyph" src="${url(name)}" alt="">` | |
| 189 | }) | |
| 190 | } | |
| 191 | ||
| 192 | /** The keyboard's own scenes, on a still room (the shift cursor, "What is that?", looking around, the menu). */ | |
| 193 | export type KeyStill = 'keyCursor' | 'keyInspect' | 'keyLook' | 'keyMenu' | |
| 194 | export const KEY_STILLS: readonly KeyStill[] = ['keyCursor', 'keyInspect', 'keyLook', 'keyMenu'] | |
| 195 | ||
| 196 | /** The captions of the keyboard's own scenes (tokens as in `keyCaptionHtml`). */ | |
| 197 | export const KEY_STILL_CAPTIONS: Record<KeyStill, string> = { | |
| 198 | keyCursor: '{shift} + {arrows} moves the cursor between the blue tiles, as far as you like. {M} {B} {V} {space} then act on its tile.', | |
| 199 | keyInspect: 'Not sure what something is? Press {question}, steer to it with {arrows}, then press {enter}.', | |
| 200 | keyLook: 'Hold {alt} and press {arrows} to look around the board; {home} brings the view back to you.', | |
| 201 | keyMenu: '{esc} opens the menu, and closes it again.', | |
| 202 | } | |
| 203 | ||
| 204 | /** | |
| 205 | * The keyboard's demo order (Q65) from the touch demo's: the controls first (a step, a bomb, Wait, | |
| 206 | * then the keyboard's own scenes: the cursor, "What is that?", looking around, the menu), then the touch order's scenes | |
| 207 | * but for the touch-only ones (a long press, auto-move, the top bar, "What is that?" by tap) and | |
| 208 | * those that need a dialog's answer (PAD_LATER), as the controller's demo does. | |
| 209 | */ | |
| 210 | export function keyDemoOrder<T extends string>(touchOrder: readonly T[]): (T | KeyStill | SceneKind)[] { | |
| 211 | return [ | |
| 212 | ...PAD_CONTROL_KINDS, | |
| 213 | ...KEY_STILLS, | |
| 214 | ...touchOrder.filter((k) => !CONTROL_KINDS.includes(k as SceneKind) && k !== 'topBar' && k !== 'whatIsThat' && !PAD_LATER.includes(k as SceneKind)), | |
| 215 | ] | |
| 216 | } |