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