fuzzyworld

explodorama / files / src / ui / keyDemo.ts

keyDemo.ts

11.2 KB · history · blame

1import { eq } from '../sim/state'
2import { isDeadly } from '../sim/danger'
3import { ACTIONS, isAvailable } from '../sim/actions'
4import { canPick, membersOf, pickChoices, resolvePick, tapChoices } from '../sim/picks'
5import { CONTROL_KINDS, PAD_CONTROL_KINDS, PAD_LATER, type Gesture, type SceneKind } from '../sim/demo'
6import type { Action, ActionId, GameState, Vec } from '../sim/types'
7import { keyButton } from './keyCursor'
8import { cursorPath } from './padDemo'
9import { actionableTiles, moveCursor, tileActions, type BarButton } from './padTiles'
10import 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 */
20export 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). */
35export 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. */
42export 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 */
53export 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. */
90function 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
106const 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. */
109function 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 */
136export 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. */
151export 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. */
154export 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. */
166export 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). */
173const 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 */
184export 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). */
193export type KeyStill = 'keyCursor' | 'keyInspect' | 'keyLook' | 'keyMenu'
194export const KEY_STILLS: readonly KeyStill[] = ['keyCursor', 'keyInspect', 'keyLook', 'keyMenu']
195
196/** The captions of the keyboard's own scenes (tokens as in `keyCaptionHtml`). */
197export 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 */
210export 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}