fuzzyworld

explodorama / files / src / main.ts

main.ts

148.3 KB · history · blame

1import {
2 cameraScale,
3 engineInit,
4 mainCanvas,
5 screenToWorld,
6 setCanvasClearColor,
7 setCanvasPixelRatio,
8 setDebugWatermark,
9 setGamepadsEnable,
10 setGLEnable,
11 glSetAntialias,
12 setShowSplashScreen,
13 timeDelta,
14 setTilesPixelated,
15 PostProcessPlugin,
16 postProcess,
17 postProcessChromatic,
18 postProcessEffects,
19 postProcessNoise,
20 time,
21 vec2,
22 worldToScreen,
23} from 'littlejsengine'
24import { BACKGROUND, drawBoard, drawIndicators, playerPos, toTile, toWorld } from './render/board'
25import { animateTurn as animateBoard, clearAnimation, newAnimation, pruneAnimation } from './render/animate'
26import { endPan, keepView, panView, resetPan, revealTile, updateCamera, viewRect } from './render/camera'
27import { CHARGE_ICONS, MONO_ICONS, POWER_UP_ICONS, TEXTURES } from './render/sprites'
28import { isAvailable, isValidTarget, ACTIONS } from './sim/actions'
29import { approachTile, canPick, membersOf, pickChoices, pickTargets, resolvePick, tapChoices, tapMeaning } from './sim/picks'
30import { enemyAt } from './sim/enemies'
31import { autoMoveAction, autoRoute, playerPath, safeStep as isSafeStep } from './sim/path'
32import { autopilotPlan } from './sim/autopilot'
33import { isDeadly, predictDanger, type Danger } from './sim/danger'
34import { newRecord, replay, SIM_VERSION, type RunRecord } from './sim/record'
35import { cellAt, eq, inBounds, isBurning, newRun, PAR_BONUS, turnNumber } from './sim/state'
36import { LAST_LEVEL } from './sim/levelgen'
37import { step } from './sim/turn'
38import { CHARGE_IDS, DEFAULT_OPTIONS, DIRS, type Action, type ActionId, type ChargeId, type Dir, type GameEvent, type GameState, type RunOptions, type Vec } from './sim/types'
39import { openAlmanac } from './ui/almanac'
40import { chargeToast, showToast } from './ui/toast'
41import { CHARGE_AMOUNT } from './sim/charges'
42import { VERSION } from './changelog'
43import { channelBox, channelLine, loadNextInfo, newSinceLastVisit, openChanges } from './ui/whatsnew'
44import { CHANNEL, storageKey, TITLE } from './channel'
45import { createControls } from './ui/controls'
46import { nextFocus, type Step } from './ui/spatial'
47import { createPadInput } from './ui/gamepad'
48import { dpadGlyph, glyphFor, padCaptionHtml, shoulderGlyph } from './ui/padGlyph'
49import { cancelFace, confirmFace, type FaceIndex, type PadFamily } from './ui/padMap'
50import { PAD_STILL_CAPTIONS, padDemoOrder, padHops, padPlan, pressPart, type PadPress } from './ui/padDemo'
51import { createPadOverlay, PAD_AFTER_SECONDS, PAD_LIT_SECONDS } from './ui/padOverlay'
52import { KEY_STILL_CAPTIONS, KEY_STILLS, keyCaptionHtml, keyDemoOrder, keyPlan, pressParts, type KeyPress, type KeyStill } from './ui/keyDemo'
53import { createKeyOverlay } from './ui/keyOverlay'
54import { createHoldRing } from './ui/holdRing'
55import { padDialog } from './ui/padDialog'
56import { aimStart, explainableTiles, jumpTarget } from './ui/padTarget'
57import { routePreview, type RouteView } from './ui/routePreview'
58import { actionableTiles, cursorDeadly, cursorLines, barButtonFor, cursorWanted, cursorActions, farTileAt, farTiles, ghostAction, cursorStart, keepCursor, restingCursor, IDLE_L1_TEXT, l1Hint, moveCursor, tileActions, type BarButton, type FarTile, type TileAction } from './ui/padTiles'
59import { defaultButton, escStep, KEY_CAPS, keyButton, keyDirection, ignoredForMode, keyRole, modeAfterKey, panStep, targetTakesKey, type InputMode } from './ui/keyCursor'
60import { closeDialog, dialogOpen, MENU_TITLE, openDialog, setDialogInDemo, type DialogButton } from './ui/dialog'
61import { actionInfo, CHARGE_TEXT, chargeInfo, hudInfo, tileInfo, type HudItem, type Info } from './ui/info'
62import { openChargeOffer } from './ui/chargeOffer'
63import { openTapChoice } from './ui/tapChoice'
64import { bindTimeline } from './ui/timeline'
65import { openOffer } from './ui/offer'
66import { showShop, updateShop } from './ui/shop'
67import { shopAfter } from './sim/shop'
68import { startClip, drawClips, setClipPad } from './ui/clip'
69import { makeScene } from './ui/scenes'
70import { openScores, recordScore } from './ui/scores'
71import { countsForScores, scoreEntry } from './sim/scores'
72import { device, recentErrors, reportError } from './devlog'
73import { hashSeed } from './sim/rng'
74import { buildReplay, type ReplayScript } from './sim/replayCompare'
75import { compareRules, type RuleComparison } from './sim/ruleCompare'
76import { TEST_KINDS, TEST_SCENES, type TestKind } from './sim/testScenes'
77import { CONTROL_KINDS, DYING_KINDS, LIVING_KINDS, SCENES, type Gesture, type Scene, type SceneKind } from './sim/demo'
78import { spriteUrl } from './render/sprites'
79import { demoHintText, demoInputFamily, demoInputLabel, effectiveDemoInput, isKeysInput, isPadInput, nextDemoInput, type DemoInput } from './ui/demoInput'
80import { countFrame, timed } from './ui/fps'
81import { canVibrate, feedbackSettings, initFeedback, setFeedbackSettings, turnFeedback, vibrate } from './ui/feedback'
82import playIcon from '../assets/kenney-ui/Extra/icon_play_light.png'
83import newRunIcon from '../assets/kenney-ui/Extra/icon_repeat_light.png'
84import handIcon from '../assets/kenney-ui-rpg/cursorHand_beige.svg'
85import mouseIcon from '../assets/kenney-cursor/cursor_none.png'
86import questionIcon from '../assets/kenney-icons/question.png'
87import returnIcon from '../assets/kenney-icons/return.png'
88import zoomIcon from '../assets/kenney-icons/zoom.png'
89
90// `?seed=123&level=4` gives a reproducible board (screenshots, bug reports). Otherwise a random
91// seed — chosen here, never inside the sim.
92const params = new URLSearchParams(location.search)
93const startLevel = Number(params.get('level')) || 1
94// The replay page (Q103) takes no input: a replay and a simulation play alone. Everything the user does
95// (pointer, keys, touch, wheel) is stopped before it reaches the game; the pad is not polled.
96const replayOn = import.meta.env.DEV && params.has('replay')
97if (replayOn) {
98 for (const type of ['pointerdown', 'pointermove', 'pointerup', 'pointerleave', 'mousedown', 'mouseup', 'click', 'dblclick', 'touchstart', 'touchmove', 'touchend', 'wheel', 'contextmenu', 'keydown', 'keyup']) {
99 window.addEventListener(type, (e) => e.stopImmediatePropagation(), { capture: true, passive: false })
100 }
101}
102// `?charge=nuke` starts carrying that charge (state.ts `newRun`), to try it.
103const startCharge = (CHARGE_IDS as readonly string[]).includes(params.get('charge') ?? '') ? (params.get('charge') as ChargeId) : null
104
105let record: RunRecord
106let state: GameState
107let danger: Danger
108let selected: ActionId | null = null
109/** "What is that?" is on: the next tap on the board explains the tile instead of acting. */
110let inspecting = false
111let hover: Vec | null = null
112/** The tile under a mouse pointer with no button down (never a finger): a far walkable one previews auto-move's way. */
113let mouseHover: Vec | null = null
114/**
115 * The controller's "What is that?" aim: the tiles that have something to explain, the current one,
116 * and what pressing again says. Gone when "What is that?" ends, or a mouse, touch or key acts.
117 */
118interface Aim {
119 button: 'inspect'
120 tiles: Vec[]
121 target: Vec | null
122 label: string
123 /** On a top-bar item instead of a tile (Up from the top row; Q82): what pressing again explains. */
124 hud?: HudItem
125}
126let aim: Aim | null = null
127/** What animates the board between turns (render/animate.ts). */
128const anim = newAnimation()
129let snapCamera = true
130/** Auto-move: a far tile the player tapped. Each press of Move takes one step along `path` toward it. */
131let destination: Vec | null = null
132let path: Vec[] | null = null
133/**
134 * What would cost a life this turn (sim/danger.ts `isDeadly`; user request, 2026-10-26): Wait,
135 * the picked button's tiles, and auto-move's next move. Worked out once a turn, not every frame.
136 */
137let deadlyWait = false
138let deadlyTargets: Vec[] = []
139let deadlyAutoMove = false
140/** A tap refused because it would cost a life: the tile flashes red for a moment. */
141let refused: { tile: Vec; at: number } | null = null
142/** Long press: the adjacent tile a held finger has armed for a bomb, placed on release. */
143let armed: Vec | null = null
144let pressTimer: number | undefined
145let pressTile: Vec | null = null
146const LONG_PRESS_MS = 450
147/**
148 * With every enemy gone, auto-move walks by itself: one step this long after the last action.
149 * Half a second, twice as fast as at first (user request, 2026-10-23: "at least 50% faster").
150 */
151const AUTO_STEP_SECONDS = 0.5
152/** The autopilot (Q73) moves two and a half times a second (Q89: it was four, a bug report found it too fast), and waits this long after the last enemy before its first move (the note shows meanwhile). */
153const AUTOPILOT_STEP_SECONDS = 0.4
154const AUTOPILOT_START_SECONDS = 1.0
155/** Waits in a row (not for crates, nor with a bomb or flame on the board) after which the autopilot gives the turn back. */
156const AUTOPILOT_STALL_WAITS = 6
157let lastActionAt = -Infinity
158/** Armed while enemies stand; the level's last kill fires it once (`autopilotTick`). */
159let autopilotArmed = true
160/** Under way: it plays the turns of the level's cleaning up. The player's Esc, L1 or Stop ends it for the level; the board takes no input meanwhile (`boardLocked`). */
161let autopilot = false
162/** When its first move may come (the start beat), and how many waits in a row it has made. */
163let autopilotStartAt = 0
164let autopilotWaits = 0
165/** Kinds of action charge picked up this run: only the first of each kind opens a dialog. */
166const chargesSeen = new Set<ChargeId>()
167
168/** Remembers a charge picked up; returns its kind the first time this run, otherwise null. */
169function firstCharge(e: GameEvent): ChargeId | null {
170 if (e.type !== 'pickedUp' || e.drop.kind === 'coins' || chargesSeen.has(e.drop.kind)) return null
171 chargesSeen.add(e.drop.kind)
172 return e.drop.kind
173}
174
175/** The demo's zoom on the room (render/camera.ts `updateCamera`): above 1 only in the looking-around scenes (the pad's stick, the keyboard's Alt + arrows), so the view can pan. */
176let demoZoom = 1
177
178/** A fresh view of the board: no animations under way, nothing picked, the camera snapped. */
179function resetView(): void {
180 stopAutopilot()
181 demoZoom = 1
182 clearAnimation(anim)
183 selected = null
184 inspecting = false
185 destination = null
186 aim = null
187 cursor = null
188 snapCamera = true
189 resetPan()
190}
191
192function startRun(seed: number, options: RunOptions): void {
193 chargesSeen.clear()
194 lastTurns = []
195 resetView()
196 record = newRecord(seed, startLevel, startCharge, options)
197 state = newRun(seed, startLevel, startCharge, options)
198 // `?shop` (with a `?level` after which the shop opens, as 3): straight into that shop with 9
199 // coins, a testing aid (tools/screenshots.mjs) — a test URL, so the run isn't saved.
200 if (params.has('shop') && shopAfter(state.level)) {
201 state = step({ ...state, status: 'cleared', coins: 9 }, { id: 'continue' }).state
202 afterChange()
203 return openShopDialog()
204 }
205 save()
206 afterChange()
207}
208
209/** The modes picked last time, the default for the next new run. */
210const OPTIONS_KEY = storageKey('newRunOptions')
211
212function preferredOptions(): RunOptions {
213 try {
214 // Only the options that still exist.
215 const saved = JSON.parse(localStorage.getItem(OPTIONS_KEY) ?? '{}') as Partial<RunOptions>
216 return { dev: !!saved.dev, immortal: !!saved.immortal }
217 } catch {
218 return { ...DEFAULT_OPTIONS }
219 }
220}
221
222function rememberOptions(options: RunOptions): void {
223 try {
224 localStorage.setItem(OPTIONS_KEY, JSON.stringify(options))
225 } catch {
226 // No storage: the choice just isn't remembered.
227 }
228}
229
230/**
231 * Mid-run save (PLAN.md §11): the run record goes to localStorage after every action, and is
232 * replayed on the next visit. A URL that sets up a test board (`?seed`, `?level`, `?charge`)
233 * neither loads nor saves, so trying something never replaces the real run. Storage can be
234 * missing or full; then the game simply doesn't save.
235 */
236const SAVE_KEY = storageKey('run')
237
238/** A test URL's options: `?dev`, `?immortal` (for screenshots and bug reports). */
239function urlOptions(): RunOptions {
240 return { dev: params.has('dev'), immortal: params.has('immortal') }
241}
242const testing = ['seed', 'level', 'charge', 'dev', 'immortal', 'shop', 'replay'].some((p) => params.has(p))
243
244function save(): void {
245 if (testing) return
246 try {
247 localStorage.setItem(SAVE_KEY, JSON.stringify(record))
248 } catch {
249 // No storage: play on without saving.
250 }
251}
252
253/** The saved run, if there is one from this version of the rules that is still going. */
254function resumeSaved(): boolean {
255 if (testing) return false
256 try {
257 const saved = JSON.parse(localStorage.getItem(SAVE_KEY) ?? 'null') as RunRecord | null
258 if (!saved || saved.simVersion !== SIM_VERSION) return false
259 chargesSeen.clear()
260 const s = replay(saved, firstCharge)
261 if (s.status === 'gameOver' || s.status === 'won') return false
262 record = saved
263 state = s
264 afterChange()
265 // Closed with a gem's choice (or a charge's) still open, or in the shop: it opens again.
266 showOffer()
267 showChargeOffer()
268 if (state.shop) openShopDialog()
269 return true
270 } catch {
271 return false
272 }
273}
274
275function randomSeed(): number {
276 return Math.floor(Math.random() * 2 ** 31)
277}
278
279/** The actions that move the player. One that doesn't land on the auto-move path means "changed my mind". */
280const MOVES: ReadonlySet<Action['id']> = new Set(['move', 'run', 'diagonal', 'teleport'])
281
282/** `auto`: auto-move's own move, which may leave the walking way (a Teleport) without ending it. */
283function act(action: Action, auto = false): void {
284 // A gem's offer is answered before anything else (its dialog opens a moment after the pickup).
285 if (state.offer && action.id !== 'choose') return
286 if (state.chargeOffer && action.id !== 'sellCharge' && action.id !== 'swapCharge' && action.id !== 'fireCharge') return
287 // The autopilot plays alone: only its own turns and the answers to its dialogs get through (the inputs are locked already; this is the backstop).
288 if (autopilot && !auto && action.id !== 'choose' && action.id !== 'sellCharge' && action.id !== 'swapCharge' && action.id !== 'fireCharge' && action.id !== 'continue') return
289 lastActionAt = time
290 if (!auto && MOVES.has(action.id) && 'target' in action && !path?.some((t) => eq(t, action.target!))) destination = null
291 // step() throws on an action the rules don't allow; then nothing happens, and it is reported.
292 let result: ReturnType<typeof step>
293 try {
294 result = step(state, action)
295 } catch (error) {
296 reportError(`turn ${state.turn}, ${JSON.stringify(action)}`, error)
297 return
298 }
299 const wasGoing = state.status !== 'gameOver' && state.status !== 'won'
300 const before = state
301 animateTurn(result, action)
302 turnFeedback(result.events, action, anim)
303 record.actions.push(action)
304 rememberTurn(before, action, result.events)
305 // A run that has just ended goes on the high-score board (not from a test URL); its banner
306 // says if it made it.
307 if (wasGoing && !testing && countsForScores(state)) {
308 const rank = recordScore({ ...scoreEntry(state, record, new Date()), channel: CHANNEL === 'local' ? 'local' : CHANNEL, commit: import.meta.env.VITE_COMMIT || undefined })
309 controls.setBannerNote(rank ? `New high score: #${rank}!` : '')
310 }
311 for (const e of result.events) {
312 // A gem taken: its choice opens once the gem has risen away (render/effects.ts).
313 if (e.type === 'powerUpTaken') window.setTimeout(showOffer, PICKUP_DIALOG_MS)
314 // The next of several gems taken on one turn.
315 if (e.type === 'offerAgain') window.setTimeout(showOffer, 150)
316 // A charge picked up while carrying another: sell it, or swap.
317 if (e.type === 'chargeOffered') window.setTimeout(showChargeOffer, PICKUP_DIALOG_MS)
318 // The first charge of each kind this run says what it does, so a new button is never a
319 // mystery; unless a gem was taken on the same turn (a run over both), whose choice comes first.
320 const charge = firstCharge(e)
321 // Any later pickup of a charge just goes into the bag, so a toast says what it was (a swap asks instead).
322 if (!charge && e.type === 'pickedUp' && e.drop.kind !== 'coins' && !result.events.some((x) => x.type === 'chargeOffered')) {
323 const kind = e.drop.kind
324 const n = e.drop.left ?? CHARGE_AMOUNT[kind]
325 const total = result.state.charge?.kind === kind ? result.state.charge.left : n
326 const { title, text } = chargeToast(CHARGE_TEXT[kind].name, n, total)
327 // Not over a choice that is open (a gem's, a charge's): its dialog says enough.
328 window.setTimeout(() => state.offer || state.chargeOffer || showToast(spriteUrl(CHARGE_ICONS[kind]), title, text), PICKUP_DIALOG_MS)
329 }
330 if (charge && !result.events.some((x) => x.type === 'chargeOffered')) window.setTimeout(() => state.offer || state.chargeOffer || showInfo(chargeInfo(charge)), PICKUP_DIALOG_MS)
331 }
332 selected = null
333 inspecting = false
334 save()
335 afterChange()
336 // The shop, after every few levels: opened by going on from the level, then kept up to date.
337 // A gem's choice made while a charge's was waiting too: that one next.
338 if (action.id === 'choose') window.setTimeout(showChargeOffer, PICKUP_DIALOG_MS)
339 if (action.id === 'continue' && state.shop) openShopDialog()
340 else if (action.id === 'buy' || action.id === 'reroll') updateShop(state)
341 // A charge bought while carrying another asked first; the shop comes back once it is answered.
342 else if ((action.id === 'sellCharge' || action.id === 'swapCharge' || action.id === 'fireCharge') && state.shop) openShopDialog()
343}
344
345/** The shop's dialog (ui/shop.ts): buying and restocking are turns of the run record, taking no time. */
346function openShopDialog(): void {
347 showShop(state, {
348 buy: (kind) => act({ id: 'buy', kind }),
349 reroll: () => act({ id: 'reroll' }),
350 leave: () => act({ id: 'continue' }),
351 })
352}
353
354/**
355 * The last few turns of this attempt at the level, to replay on the "Ouch!" banner when the player
356 * dies (PLAN.md §13c; user's choice, 2026-10-24: their own last turns, so they see what went wrong).
357 */
358const REPLAY_TURNS = 4
359let lastTurns: { prev: GameState; action: Action }[] = []
360const bannerClip = document.getElementById('banner-clip') as HTMLCanvasElement
361
362function rememberTurn(prev: GameState, action: Action, events: GameEvent[]): void {
363 if (events.some((e) => e.type === 'levelStarted' || e.type === 'levelRestarted')) lastTurns = []
364 else lastTurns = [...lastTurns, { prev, action }].slice(-REPLAY_TURNS)
365 const died = state.status === 'dead' || (state.status === 'gameOver' && events.some((e) => e.type === 'playerDied'))
366 bannerClip.hidden = !died || !lastTurns.length
367 if (bannerClip.hidden) return
368 const actions = lastTurns.map((t) => t.action)
369 // Only the part of the level round where it happened.
370 const at = state.player
371 startClip(bannerClip, { kind: 'dieEnemy', start: lastTurns[0]!.prev, actions, gestures: actions.map(() => ({ kind: 'go' as const })) }, at)
372}
373
374/** A turn played: the new state, and everything that animates it (also for the demo). */
375function animateTurn(result: ReturnType<typeof step>, action: Action): void {
376 const newLevel = animateBoard(anim, state, result, action, time)
377 const from = state.player
378 state = result.state
379 // A view the player dragged somewhere stays there (Q40); a new level starts following again.
380 if (newLevel) resetPan()
381 else if (!demo) keepView(from, state.player)
382 // A turn played ends the aim and the cursor (the tiles and the label were for the board as it was).
383 aim = null
384 cursor = null
385 if (newLevel) snapCamera = true
386 if (newLevel || result.events.some((e) => e.type === 'levelCleared')) destination = null
387}
388
389/** How long after taking a gem (or a new charge) its dialog opens: the walk there and the rise away first. */
390const PICKUP_DIALOG_MS = 450
391
392/** The gem's choice, if one is waiting. Choosing takes no turn. */
393function showOffer(): void {
394 if (state.offer) openOffer(state, state.offer, (kind) => act({ id: 'choose', kind }))
395}
396
397/** The choice a charge picked up while carrying another asks for (one charge at a time). */
398function showChargeOffer(): void {
399 if (state.chargeOffer && !state.offer) openChargeOffer(state, (choice) => act({ id: choice === 'fire' ? 'fireCharge' : choice === 'swap' ? 'swapCharge' : 'sellCharge' }))
400}
401
402/** Since when a gem's or charge's choice has been waiting with no dialog showing (0: not waiting); see `reopenPendingChoice`. */
403let choiceMissingSince = 0
404
405/**
406 * A choice the rules wait for (a gem's, a charge's) must always have its dialog: with none, every
407 * action is refused and the player can't move (Q90, bug report: a slide took a charge and a gem
408 * at once, then nothing worked). Called every frame; after a short grace (the pickup's own
409 * timers open the dialog) it opens whichever is missing.
410 */
411function reopenPendingChoice(): void {
412 if (demo || replayOn || !(state.offer || state.chargeOffer) || dialogOpen()) {
413 choiceMissingSince = 0
414 return
415 }
416 choiceMissingSince ||= time
417 if (time - choiceMissingSince < 1.5) return
418 choiceMissingSince = 0
419 showOffer()
420 showChargeOffer()
421}
422
423function afterChange(): void {
424 if (aim && (state.status !== 'playing' || !inspecting)) aim = null
425 // "What is that?" was what woke the keyboard's aim (not the cursor): when it ends, the board is the plain keys' again.
426 if (keysFromInspect && !inspecting) handBack(null)
427 danger = predictDanger(state)
428 // The walk is worked out again every turn, since bombs and crates change; reaching the
429 // destination, or it becoming unreachable, ends auto-move.
430 if (destination && (eq(state.player, destination) || state.status !== 'playing')) destination = null
431 path = destination ? playerPath(state, destination) : null
432 if (!path) destination = null
433 // After the danger and the destination: the cursor's faded tiles are safe ones (`farTiles`).
434 refreshCursor()
435 deadlyWait = isAvailable(state, 'wait') && isDeadly(state, { id: 'wait' })
436 const picked = selected
437 deadlyTargets = picked
438 ? pickTargets(state, picked).filter((p) => {
439 const id = resolvePick(state, picked, p)
440 return !!id && isDeadly(state, { id, target: p })
441 })
442 : []
443 const next = destination && autoMoveAction(state, destination, safeStep)
444 deadlyAutoMove = !!next && isDeadly(state, next)
445 controls.update(state, selected, destination !== null, inspecting, deadlyWait)
446}
447
448/**
449 * Two taps: pick an action, then a highlighted tile. With "What is that?" on: explain the tile.
450 * With nothing picked (never a move that would cost a life: `tapMeaning` calls it 'deadly', and
451 * the tile flashes red; the button and then the tile still take it):
452 * - the player: nothing (it was Wait until 2026-10-26, user request: it could kill);
453 * - the auto-move flag: one step toward it (like Go), with auto-move's safety check;
454 * - a tile next to the player: move there; with Diagonal, a diagonal neighbour too (user report,
455 * 2026-10-23: it was walked to round the corner, as auto-move);
456 * - any other tile you can walk to: set it as the auto-move destination, and take the first move
457 * (a step, a Run or a Teleport, whichever gets closest: `autoMoveAction`);
458 * - with Teleport, a tile you can't walk to but can teleport to: teleport there;
459 * - a wall or crate, or any floor you can't walk to: clears the flag if there is one;
460 * - one of the player's bombs, with the Detonator: set it off (user request, 2026-10-22);
461 * - else a bomb next to the player, with Kick: kick it (walking into it); with Bomb Pass too, or the
462 * Detonator, a dialog asks which (2026-10-31);
463 * - a crate: walk to the tile beside it (2026-10-31);
464 * - an enemy, any other bomb, or flame still burning: nothing, never a move into it — unless
465 * flame can't hurt the player (Flame Pass, a Star), then it is walked into like floor.
466 */
467function tapTile(p: Vec): void {
468 aim = null
469 if (state.status !== 'playing' || boardLocked()) return
470 if (inspecting) {
471 inspecting = false
472 afterChange()
473 showTileInfo(p)
474 } else if (selected) {
475 // The picked button stands for a group (Move also runs, teleports...): the tile decides which.
476 const id = resolvePick(state, selected, p)
477 const options = pickChoices(state, selected, p)
478 if (options.length) openTapChoice(options, (choice) => act({ id: choice, target: p }), (c) => isDeadly(state, { id: c, target: p }))
479 else if (id) act({ id, target: p })
480 else {
481 selected = null
482 afterChange()
483 }
484 } else {
485 // What a tap means is worked out in the sim (picks.ts `tapMeaning`), shared with the demo.
486 const means = tapMeaning(state, p)
487 if (means === 'deadly') refuse(p)
488 else if (means === 'detonate' || means === 'kick') act({ id: means, target: p })
489 else if (means === 'blocked') return
490 else if (means === 'choose') openTapChoice(tapChoices(state, p), (id) => act({ id, target: p }))
491 else if (means === 'approach') {
492 const to = approachTile(state, p)!
493 if (destination && eq(destination, to)) stepTowardDestination()
494 else setDestination(to)
495 } else if (destination && eq(destination, p)) stepTowardDestination()
496 else if (means === 'walk') setDestination(p)
497 else if (means) act({ id: means, target: p })
498 else if (destination) {
499 destination = null
500 afterChange()
501 }
502 }
503}
504
505/** A tap or key that would cost a life, not taken: the tile flashes red, and a short buzz. */
506function refuse(p: Vec): void {
507 refused = { tile: p, at: time }
508 vibrate([30, 40, 30])
509}
510
511/** The "What is that?" button: on, or off again (dropping any action picked). */
512function inspect(): void {
513 if (state.status !== 'playing' || boardLocked()) return
514 aim = null
515 inspecting = !inspecting
516 selected = null
517 afterChange()
518}
519
520/**
521 * Auto-move to `p`, taking the first step at once, as a tap next to the player would (user
522 * request, 2026-10-23: setting the way used to wait for Go). An unsafe first step isn't taken.
523 */
524function setDestination(p: Vec): void {
525 // "Walk there" from a tile's info dialog, opened while the autopilot waited: the player takes over.
526 stopAutopilot()
527 destination = p
528 afterChange()
529 stepTowardDestination()
530}
531
532/** The info dialog for a tile; with "Walk there" when it is something to walk to. */
533function showTileInfo(p: Vec): void {
534 const info = tileInfo(state, p)
535 if (!info) return
536 const reachable = !eq(p, state.player) && cellAt(state, p) === 'floor' && !isBurning(state, p) && !enemyAt(state, p) && playerPath(state, p)
537 showInfo(info, reachable ? [{ label: 'Walk there', kind: 'primary', onClick: () => {
538 // Walking ends the asking (the mode stays on after an explanation, Q87).
539 inspecting = false
540 aim = null
541 setDestination(p)
542 } }] : [])
543}
544
545/** An info dialog; with a clip, that kind of scene looping above the words (ui/clip.ts). */
546function showInfo(info: Info, extra: DialogButton[] = []): void {
547 const clip = info.clip
548 openDialog({
549 icon: spriteUrl(info.icon),
550 title: info.title,
551 body: (clip ? '<canvas class="clip" aria-hidden="true"></canvas>' : '') + info.body.map((t) => `<p>${t}</p>`).join(''),
552 buttons: [...extra, { label: 'Got it', kind: 'plain' }],
553 })
554 if (clip) startClip(document.querySelector<HTMLCanvasElement>('#dialog-body canvas.clip')!, clip)
555}
556
557const REPO_URL = 'https://code.fuzzyworld.net/explodorama'
558/**
559 * Where bug reports go (user, 2026-10-26): a forward-only address on fuzzyworld.net, so players
560 * need no account. `+explodorama` tags the game. Public by design: it is in the game's email link
561 * either way. The random part (`k7q4`) lets the address be rotated if it draws spam.
562 */
563const BUG_EMAIL = 'bugs-k7q4+explodorama@fuzzyworld.net'
564/** Longer email links than this may be cut off by some mail apps: the run record then stays out. */
565const MAILTO_MAX = 1900
566
567/** What a bug report says about the game and this run, to reproduce it (the run record replays it). */
568function bugDetails(withRecord: boolean): string {
569 const commit = import.meta.env.VITE_COMMIT ? `, commit ${import.meta.env.VITE_COMMIT}${import.meta.env.VITE_COMMIT_DIRTY ? ' plus local changes' : ''}` : ''
570 const modes = [state.options.dev && 'Developer', state.options.immortal && 'Immortal'].filter(Boolean).join(', ') || 'none'
571 const errors = recentErrors()
572 return [
573 `Version ${VERSION} (rules ${SIM_VERSION})${commit}`,
574 `Device: ${device()}; ${navigator.userAgent}`,
575 `Seed ${state.seed}, level ${state.level}, ${state.turn} turns taken, ${state.lives} lives, ${state.status}; modes: ${modes}`,
576 `Errors caught: ${errors.length ? '\n' + errors.map((e) => `- ${e}`).join('\n') : 'none'}`,
577 ...(withRecord ? [`Run record: ${JSON.stringify(record)}`] : []),
578 ].join('\n')
579}
580
581/**
582 * Report a bug (user request, 2026-10-24): an email to BUG_EMAIL, filled in with the
583 * details and the run record. A long run's record makes the link too long for some mail apps:
584 * then it stays out, the full details go on the clipboard, and the email asks to paste them.
585 */
586function reportBug(): void {
587 const full = bugDetails(true)
588 const mailto = (details: string) =>
589 `mailto:${BUG_EMAIL}?subject=${encodeURIComponent(`Bug report: version ${VERSION} (rules ${SIM_VERSION}), level ${state.level}`)}` +
590 `&body=${encodeURIComponent(`What happened:\n\n\n\n---\n${details}`)}`
591 const withRecord = mailto(full)
592 if (withRecord.length <= MAILTO_MAX) {
593 location.href = withRecord
594 return
595 }
596 void navigator.clipboard?.writeText(full).catch(() => {})
597 location.href = mailto(bugDetails(false) + '\nRun record: too long for an email link. The full details are on your clipboard: please paste them here.')
598}
599
600/** ", commit 0580e85" linked to its page on code.fuzzyworld.net, with a note if built with uncommitted changes. */
601function commitLine(): string {
602 const commit = import.meta.env.VITE_COMMIT
603 if (!commit) return ''
604 const link = `<a href="${REPO_URL}/commit/${commit}.html" target="_blank" rel="noopener">${commit}</a>`
605 return `, commit ${link}${import.meta.env.VITE_COMMIT_DIRTY ? ' plus local changes' : ''}`
606}
607
608/**
609 * The menu: resume, the almanac, high scores, the demo again (user request, 2026-10-24: "just
610 * seems fun"), a bug report, or abandon this run for a new one (confirmed first, since it can't be undone). The
611 * run's modes are only named here, to keep it short (user request, same day); their tags in the
612 * top bar say what they do. Laid out compact (user request, 2026-10-25: "still very packed"):
613 * Resume and New side by side, then one row of single words — Almanac, Scores, Demo, Bug.
614 */
615function showMenu(): void {
616 const where = state.status === 'playing' ? `Level ${state.level}, turn ${turnNumber(state)}. ` : ''
617 const modes = [state.options.dev && 'Developer', state.options.immortal && 'Immortal'].filter(Boolean)
618 openDialog({
619 title: MENU_TITLE,
620 body:
621 `<p>${where}Seed ${state.seed}. The game saves after every move, so you can close it and come back.</p>` +
622 (modes.length ? `<p class="mode">${modes.length > 1 ? 'Modes' : 'Mode'}: ${modes.join(', ')}.</p>` : '') +
623 channelBox() +
624 feedbackChoices() +
625 // The version is the release (semver, tagged by hand); the rules version, in brackets, changes
626 // exactly when old saves stop resuming. The version opens the list of changes.
627 `<p class="credit">Version <a href="#" id="changes-link">${VERSION}</a> (rules ${SIM_VERSION})${commitLine()}. ` +
628 `<a href="${REPO_URL}/" target="_blank" rel="noopener">Source code</a></p>` +
629 (channelLine() ? `<p class="credit">${channelLine()}</p>` : ''),
630 buttons: [
631 { label: 'Resume', kind: 'primary', icon: playIcon },
632 { label: 'New', kind: 'danger', icon: newRunIcon, onClick: newRunDialog },
633 { label: 'Almanac', kind: 'plain', onClick: () => openAlmanac(undefined, state.options.dev) },
634 { label: 'Scores', kind: 'plain', onClick: openScores },
635 { label: 'Changes', kind: 'plain', onClick: () => openChanges() },
636 { label: 'Demo', kind: 'plain', onClick: startDemo },
637 { label: 'Bug', kind: 'plain', onClick: reportBug },
638 ],
639 className: 'menu',
640 })
641 document.querySelector<HTMLElement>('#dialog-body .channel-switch')?.addEventListener('click', (e) => {
642 location.href = (e.currentTarget as HTMLElement).dataset.go!
643 })
644 document.getElementById('changes-link')!.addEventListener('click', (e) => {
645 e.preventDefault()
646 closeDialog(() => openChanges())
647 })
648 // The switches work at once, without a button (user request, 2026-10-24: sounds and vibration).
649 for (const input of document.querySelectorAll<HTMLInputElement>('#dialog-body .feedback input')) {
650 input.addEventListener('change', () => {
651 if (input.name === 'autopilot') {
652 setAutopilot(input.checked)
653 // Turned off mid-walk: it stops at once.
654 if (!input.checked) stopAutopilot()
655 return
656 }
657 if (input.name === 'fullscreenNow') return toggleFullscreen()
658 setFeedbackSettings({ ...feedbackSettings(), [input.name]: input.checked })
659 // Turned on: a short buzz, so the player knows at once whether it works on their phone.
660 if (input.name === 'vibration' && input.checked) vibrate(200)
661 })
662 }
663}
664
665/** The menu's Sound and Vibration switches; Vibration only where the browser can vibrate. */
666function feedbackChoices(): string {
667 const f = feedbackSettings()
668 const checked = (on: boolean) => (on ? ' checked' : '')
669 return (
670 `<fieldset class="choices feedback"><legend>Settings</legend>` +
671 `<label><input type="checkbox" name="sound"${checked(f.sound)}><span><b>Sound</b></span></label>` +
672 (document.fullscreenEnabled
673 ? `<label><input type="checkbox" name="fullscreenNow"${checked(!!document.fullscreenElement)}><span><b>Fullscreen</b> Now. A controller press can’t switch it in a browser: press F11, or use the button at the top.</span></label>`
674 : '') +
675 `<label><input type="checkbox" name="autopilot"${checked(autopilotOn())}><span><b>Autopilot</b> Once the last enemy is gone, walk round picking up the coins, gems and charges left, then to the exit. Gems and charges still ask you.</span></label>` +
676 (canVibrate() ? `<label><input type="checkbox" name="vibration"${checked(f.vibration)}><span><b>Vibration</b></span></label>` : '') +
677 `</fieldset>`
678 )
679}
680
681/**
682 * The new-run dialog: the two mode checkboxes (remembered for next time). From the menu, while a
683 * run is still going, it also warns that the run will be lost. (A board size and Crumbling crates
684 * were choices too until 2026-10-23; user request.)
685 */
686function newRunDialog(): void {
687 const o = preferredOptions()
688 const ongoing = state.status !== 'gameOver' && state.status !== 'won'
689 const checked = (on: boolean) => (on ? ' checked' : '')
690 const warning = ongoing
691 ? `<p class="warning">This run — level ${state.level}, ${state.coins} coins, ${state.lives} ${state.lives === 1 ? 'life' : 'lives'} — will be lost. This can’t be undone.</p>`
692 : ''
693 openDialog({
694 title: 'New run',
695 body:
696 warning +
697 `<fieldset class="choices"><legend>Modes</legend>` +
698 `<label><input type="checkbox" name="dev"${checked(o.dev)}><span><b>Developer</b> A special gem lies next to you on every level.</span></label>` +
699 `<label><input type="checkbox" name="immortal"${checked(o.immortal)}><span><b>Immortal</b> Nothing can kill you. You can play through to the end, but an Immortal run earns no achievements.</span></label>` +
700 `</fieldset><fieldset class="choices"><legend>Seed</legend>` +
701 `<label class="seed"><input type="text" name="seed" inputmode="numeric" autocomplete="off" maxlength="40" placeholder="random"><span>Leave it empty for a new dungeon, or type the seed of one to play it (shown in the menu and at the end of a run).</span></label>` +
702 `</fieldset>`,
703 buttons: [
704 { label: 'Start', kind: ongoing ? 'danger' : 'primary', icon: newRunIcon, onClick: startChosenRun },
705 { label: ongoing ? 'Keep playing' : 'Cancel', kind: 'plain' },
706 ],
707 })
708}
709
710function startChosenRun(): void {
711 const form = document.getElementById('dialog-body')!
712 const on = (name: string) => form.querySelector<HTMLInputElement>(`input[name=${name}]`)!.checked
713 const options: RunOptions = { dev: on('dev'), immortal: on('immortal') }
714 rememberOptions(options)
715 startRun(seedFrom(form.querySelector<HTMLInputElement>('input[name=seed]')!.value), options)
716}
717
718/**
719 * The seed typed in the New run dialog (user request, 2026-10-24): empty for a random one; a
720 * whole number as it is; any other text (a word to share) made into a number by hashing it.
721 */
722function seedFrom(text: string): number {
723 const t = text.trim()
724 if (!t) return randomSeed()
725 if (/^\d+$/.test(t) && Number(t) < 2 ** 31) return Number(t)
726 return hashSeed('seed', t) % 2 ** 31
727}
728
729/** Whether auto-move may take this step (path.ts `safeStep`). */
730function safeStep(p: Vec): boolean {
731 return isSafeStep(state, danger, p)
732}
733
734/**
735 * One move of auto-move: a step, a Run or a Teleport, whichever gets closest (`autoMoveAction`),
736 * only onto safe tiles, and never one that would cost a life (`isDeadly`). True if it moved.
737 */
738function stepTowardDestination(): boolean {
739 if (!destination) return false
740 const action = autoMoveAction(state, destination, safeStep)
741 if (!action || isDeadly(state, action)) return false
742 act(action, true)
743 return true
744}
745
746/**
747 * The Autopilot setting (on unless turned off): see `autopilotTick`. It was "Finish the level for
748 * me" until Q73, and keeps that stored key so a player's choice survives the rename.
749 */
750const AUTOPILOT_KEY = storageKey('autoFinish')
751function autopilotOn(): boolean {
752 try {
753 return localStorage.getItem(AUTOPILOT_KEY) !== '0'
754 } catch {
755 return true
756 }
757}
758function setAutopilot(on: boolean): void {
759 try {
760 localStorage.setItem(AUTOPILOT_KEY, on ? '1' : '0')
761 } catch {
762 // Storage is blocked: the setting lasts until the page closes (it reads back as on).
763 }
764}
765
766/** The note for a controller (or anything else) that cannot switch fullscreen: a pad press is no browser gesture. */
767function fullscreenHint(): void {
768 showToast(handIcon, 'Fullscreen', 'Press F11, or use the fullscreen button')
769}
770
771/** The fullscreen button's, and the menu switch's, way to switch: in, or out. A refusal (a pad press, no gesture) says what to do instead. */
772function toggleFullscreen(): void {
773 if (!document.fullscreenEnabled) return
774 const done = document.fullscreenElement ? document.exitFullscreen() : document.documentElement.requestFullscreen()
775 done.catch(() => {
776 syncFullscreenUi()
777 fullscreenHint()
778 })
779}
780
781/** The top bar's fullscreen button (user request, 2026-11-02): only where the browser has fullscreen; shows the state, and the menu's switch follows. */
782const fullscreenButton = document.getElementById('fullscreen-button')!
783function syncFullscreenUi(): void {
784 const on = !!document.fullscreenElement
785 fullscreenButton.setAttribute('aria-pressed', String(on))
786 fullscreenButton.setAttribute('aria-label', on ? 'Leave fullscreen' : 'Fullscreen')
787 fullscreenButton.title = on ? 'Leave fullscreen' : 'Fullscreen'
788 const now = document.querySelector<HTMLInputElement>('#dialog-body input[name=fullscreenNow]')
789 if (now) now.checked = on
790}
791fullscreenButton.hidden = !document.fullscreenEnabled
792fullscreenButton.addEventListener('click', toggleFullscreen)
793document.addEventListener('fullscreenchange', syncFullscreenUi)
794
795/** The autopilot's note (style.css `#autopilot`): its words, how to stop it (Esc, or L1 for a pad), and a Stop button. Not a popover, so a dialog covers it. */
796const autopilotNote = document.createElement('div')
797autopilotNote.id = 'autopilot'
798autopilotNote.hidden = true
799const autopilotText = document.createElement('span')
800autopilotText.setAttribute('role', 'status')
801const autopilotHint = document.createElement('span')
802autopilotHint.className = 'autopilot-hint'
803const autopilotStop = document.createElement('button')
804autopilotStop.type = 'button'
805autopilotStop.textContent = 'Stop'
806autopilotStop.addEventListener('click', () => stopAutopilot())
807autopilotNote.append(autopilotText, autopilotHint, autopilotStop)
808document.body.append(autopilotNote)
809/** What the note shows now, so it is rebuilt only when it changes. */
810let autopilotShown = ''
811const AUTOPILOT_TEXT = { gather: 'Autopilot: cleaning up', crates: 'Autopilot: waiting for the crates', bombs: 'Autopilot: waiting for the bombs', exit: 'Autopilot: off to the exit' } as const
812
813/** Whether the autopilot has the board: the board's inputs are ignored, a dialog (which it waits for) still works. */
814function boardLocked(): boolean {
815 return autopilot && state.status === 'playing' && !dialogOpen()
816}
817
818/** Puts the note up (or its words and its stop hint right), once a frame; it hides with the autopilot. */
819function syncAutopilotNote(phase: keyof typeof AUTOPILOT_TEXT): void {
820 const hint = padActive ? `pad${padKind}` : 'key'
821 const key = `${phase}|${hint}`
822 if (key !== autopilotShown) {
823 autopilotShown = key
824 autopilotText.textContent = AUTOPILOT_TEXT[phase]
825 // L1's picture for a pad, the Esc cap for the keyboard (hidden on touch by the style sheet).
826 autopilotHint.replaceChildren(padActive ? buttonPicture(spriteUrl(shoulderGlyph(padKind, 'l'))) : keyCap('Esc'))
827 }
828 autopilotNote.style.top = `${controls.insets().top + 8}px`
829 autopilotNote.hidden = false
830}
831
832/**
833 * Autopilot (Q73, user requests 2026-11-01 and 2026-11-02): once the last enemy is gone, the
834 * character walks round at four moves a second, one real turn each, to every coin, charge and gem
835 * left on the floor (the order that takes the fewest turns, sim/autopilot.ts), then onto the exit. A
836 * gem's or charge's dialog opens on its turn and the walk waits for the answer. Esc, L1 or the
837 * note's Stop button ends it for the level. Gathering costs turns, so a par bonus can be lost.
838 */
839function startAutopilot(): void {
840 autopilot = true
841 autopilotWaits = 0
842 autopilotStartAt = time + AUTOPILOT_START_SECONDS
843 selected = null
844 inspecting = false
845 destination = null
846 aim = null
847 cursor = null
848 resetPan()
849 document.body.classList.add('autopilot')
850 // The note shows at once, even if a dialog from the kill turn is open (the walk starts after the answer).
851 syncAutopilotNote(autopilotPlan(state, danger).phase)
852 afterChange()
853}
854
855/** Ends the autopilot for the level (it is armed again by the next level's enemies); `toast`: tells the player the turn is theirs. */
856function stopAutopilot(toast = false): void {
857 if (!autopilot) return
858 autopilot = false
859 autopilotShown = ''
860 autopilotNote.hidden = true
861 document.body.classList.remove('autopilot')
862 destination = null
863 afterChange()
864 if (toast) showToast(handIcon, 'Autopilot stopped', 'Your turn')
865}
866
867/** One frame of the autopilot: starts it on the level's last kill, then takes a turn every step, unless a dialog is open or a choice waits. */
868function autopilotTick(): void {
869 // The replay page plays alone (Q103).
870 if (replayOn) return
871 if (demo || state.status !== 'playing') return stopAutopilot()
872 if (state.enemies.length > 0) {
873 autopilotArmed = true
874 return stopAutopilot()
875 }
876 if (autopilotArmed) {
877 autopilotArmed = false
878 if (autopilotOn()) startAutopilot()
879 }
880 if (!autopilot) return
881 if (state.offer || state.chargeOffer || dialogOpen()) return
882 if (time < autopilotStartAt || time - lastActionAt < AUTOPILOT_STEP_SECONDS) return
883 const plan = autopilotPlan(state, danger)
884 syncAutopilotNote(plan.phase)
885 // Boxed in with nothing crumbling (the game's own rule, a life lost, not this one's), or waits that lead nowhere.
886 // Waiting for crates, or for bombs and flame to clear (a fuse can be longer than the limit), is not a stall.
887 autopilotWaits = plan.action?.id === 'wait' && plan.phase !== 'crates' && plan.phase !== 'bombs' && state.bombs.length === 0 && state.burning.length === 0 ? autopilotWaits + 1 : 0
888 if (!plan.action || autopilotWaits > AUTOPILOT_STALL_WAITS) return stopAutopilot(true)
889 destination = plan.goal
890 act(plan.action, true)
891}
892
893/** Once every enemy is gone, the flag needs no more taps: auto-move steps on its own. */
894function autoStep(): void {
895 if (demo || autopilot || state.status !== 'playing' || !destination || state.enemies.length > 0) return
896 if (selected || inspecting || dialogOpen() || time - lastActionAt < AUTO_STEP_SECONDS) return
897 stepTowardDestination()
898}
899
900/**
901 * An action button or key. `pressed`: it, or another action of its button, is already picked,
902 * so this cancels it. With "What is that?" on, it explains the button instead.
903 */
904function pick(id: ActionId, pressed = selected === id): void {
905 if (boardLocked()) return
906 aim = null
907 if (inspecting) {
908 inspecting = false
909 afterChange()
910 return showInfo(actionInfo(state, id))
911 }
912 if (!canPick(state, id)) return
913 if (id === 'move' && destination && !selected && stepTowardDestination()) return
914 // Wait happens at once. Everything else is picked first, then aimed at a tile.
915 const now = membersOf(id).find((a) => isAvailable(state, a) && !ACTIONS[a].needsTarget)
916 if (now) return act({ id: now })
917 // A Freeze picked, whose only tile is the player's own: pressing the button again confirms it (as
918 // tapping yourself does), rather than putting it away.
919 const self = pressed && selected ? resolvePick(state, selected, state.player) : null
920 if (self === 'freeze') return act({ id: self, target: state.player })
921 selected = pressed ? null : id
922 afterChange()
923}
924
925/**
926 * A direction key: uses the picked action that way (a bomb or kick next to the player, a throw
927 * or run 2 tiles away), or moves if nothing is picked.
928 */
929function direction(d: Dir): void {
930 if (state.status !== 'playing') return
931 aim = null
932 runStep(directionPlan(d))
933}
934
935/** What a direction key would do: the action and tile, and whether it asks which or is refused. */
936interface StepPlan {
937 id: ActionId
938 p: Vec
939 /** A bomb two actions could deal with: the options to ask about. */
940 ask?: ActionId[]
941 /** A shortcut that would cost a life: refused. */
942 refuse?: boolean
943}
944
945function directionPlan(d: Dir): StepPlan | null {
946 // Nothing picked: a step, or a kick into a bomb. A picked button reaches further too (a run,
947 // a teleport, a throw; an RPG, an ICBM, a slide as far as they go): the nearest tile that way
948 // it can use.
949 const picked: ActionId = selected && ACTIONS[selected].needsTarget ? selected : 'move'
950 const reaches = selected ? Array.from({ length: Math.max(state.width, state.height) }, (_, i) => i + 1) : [1]
951 for (const reach of reaches) {
952 const p = { x: state.player.x + reach * DIRS[d].x, y: state.player.y + reach * DIRS[d].y }
953 const id = resolvePick(state, picked, p)
954 if (!id) continue
955 // A bomb two actions could deal with (kick or walk onto it) asks which, picked or not.
956 const options = selected ? pickChoices(state, picked, p) : tapChoices(state, p)
957 if (options.length > 1) return { id, p, ask: options }
958 // Nothing picked and one safe option on a bomb: that one, as a tap takes it (even if a deadlier one comes first).
959 if (!selected && options.length === 1) return { id: options[0]!, p }
960 // Nothing picked, a key is a shortcut, as a tap is: never one that costs a life.
961 if (!selected && isDeadly(state, { id, target: p })) return { id, p, refuse: true }
962 return { id, p }
963 }
964 return null
965}
966
967function runStep(plan: StepPlan | null): void {
968 if (!plan) return
969 const { p } = plan
970 if (plan.ask) return openTapChoice(plan.ask, (choice) => act({ id: choice, target: p }), (c) => isDeadly(state, { id: c, target: p }))
971 if (plan.refuse) return refuse(p)
972 act({ id: plan.id, target: p })
973}
974
975const controls = createControls({
976 pick,
977 banner(second) {
978 if (state.status === 'dead' || state.status === 'cleared') act({ id: 'continue' })
979 // Game over: the same dungeon again (same seed and modes, from the start), or a new one.
980 else if (state.status === 'gameOver') startRun(second ? randomSeed() : state.seed, state.options)
981 else if (state.status === 'won') newRunDialog()
982 },
983 info: (item) => showInfo(hudInfo(item, state)),
984 inspect,
985 cancel,
986 menu: showMenu,
987})
988
989const KEY_DIRS: Record<string, Dir> = {
990 KeyW: 'up', ArrowUp: 'up',
991 KeyS: 'down', ArrowDown: 'down',
992 KeyA: 'left', ArrowLeft: 'left',
993 KeyD: 'right', ArrowRight: 'right',
994}
995
996const ACTION_KEYS: Record<string, ActionId> = {
997 // M picks Move, as B picks Bomb (user request, 2026-10-23; the button said "WASD").
998 KeyM: 'move',
999 KeyB: 'bomb', KeyR: 'run', KeyX: 'detonate', KeyK: 'kick', KeyT: 'throw', KeyG: 'diagonal', KeyP: 'teleport',
1000 // Added 2026-10-24: F fires an RPG bomb, I an ICBM, L slides.
1001 KeyF: 'rpg', KeyI: 'icbm', KeyL: 'slide',
1002 // V uses the charge held (2026-10-24, one charge at a time): next to B for Bomb.
1003 KeyV: 'charged',
1004}
1005
1006/** Q / E / Z / C: a diagonal step up-left, up-right, down-left, down-right (with Diagonal). */
1007const DIAGONAL_KEYS: Record<string, Vec> = { KeyQ: { x: -1, y: -1 }, KeyE: { x: 1, y: -1 }, KeyZ: { x: -1, y: 1 }, KeyC: { x: 1, y: 1 } }
1008
1009/** A diagonal step (Q / E / Z / C), as a key takes it: never one that costs a life. */
1010function stepDiagonal(diag: Vec): void {
1011 runStep(diagonalPlan(diag))
1012}
1013
1014function diagonalPlan(diag: Vec): StepPlan | null {
1015 const p = { x: state.player.x + diag.x, y: state.player.y + diag.y }
1016 if (!isValidTarget(state, 'diagonal', p)) return null
1017 return { id: 'diagonal', p, refuse: !selected && isDeadly(state, { id: 'diagonal', target: p }) }
1018}
1019
1020/**
1021 * Esc: puts away one thing, nearest first: "What is that?", then the picked
1022 * action or the flag. False when there was nothing
1023 * to put away.
1024 */
1025function cancel(): boolean {
1026 if (inspecting) inspecting = false
1027 else if (selected || destination) {
1028 selected = null
1029 destination = null
1030 } else return false
1031 aim = null
1032 afterChange()
1033 return true
1034}
1035
1036window.addEventListener('keydown', (e) => {
1037 // The dialog has the keyboard while it is open (Esc closes it, Tab moves between its buttons).
1038 const role = keyRole(e)
1039 // Alt + Left / Right is the browser's Back / Forward on Windows and Linux: never let it through, dialog or not (a text field keeps its own).
1040 const target = e.target as HTMLElement | null
1041 if (role === 'pan' && !(target?.isContentEditable || ['INPUT', 'TEXTAREA'].includes(target?.tagName ?? ''))) e.preventDefault()
1042 // The view: Alt + arrows pan it (and repeat while held), Home follows the player again.
1043 if (!dialogOpen() && (role === 'pan' || role === 'recenter')) {
1044 e.preventDefault()
1045 const step = panStep(e.code)
1046 if (step) panView(step.x, step.y)
1047 else resetPan()
1048 return
1049 }
1050 if (e.repeat || dialogOpen()) return
1051 // The autopilot has the board: Esc stops it, every other key waits (looking round, above, and the menu's dialog still work).
1052 if (boardLocked()) {
1053 if (e.code === 'Escape') stopAutopilot()
1054 return
1055 }
1056 // Enter and Space mean something to a button or link that has the focus (it presses it): the game stays out.
1057 if ((e.code === 'Enter' || e.code === 'NumpadEnter' || e.code === 'Space') && targetTakesKey(e.target as Element | null)) return
1058 const d = KEY_DIRS[e.code]
1059 const keys = inputMode === 'keys'
1060 const dir = keyDirection(e.code)
1061 // With Diagonal, Q / E / Z / C step diagonally as W A S D step straight: at once, or with Move
1062 // (or Diagonal) picked.
1063 const diagonalKeys = isAvailable(state, 'diagonal') && (!selected || selected === 'move' || selected === 'diagonal')
1064 const diag = diagonalKeys ? DIAGONAL_KEYS[e.code] : undefined
1065 if (role === 'hop' && dir) keyHop(dir.dx, dir.dy)
1066 else if (inspecting && dir && role === 'direction') aimStep(dir.dx, dir.dy)
1067 else if (inspecting && role === 'diagonal') return
1068 else if (diag) stepDiagonal(diag)
1069 else if (d) direction(d)
1070 else if (e.code === 'Space') keyAction('wait')
1071 else if (role === 'inspect') keyInspect()
1072 else if (ACTION_KEYS[e.code]) keyAction(ACTION_KEYS[e.code]!)
1073 else if (e.code === 'Escape') {
1074 // With the keyboard's cursor: cancels what is open, else brings the cursor home, else puts it away.
1075 // Else cancels "What is that?", what is picked or the flag; with nothing to cancel, opens the menu.
1076 if (keys) return keyEsc()
1077 if (!cancel()) showMenu()
1078 } else if (e.code === 'Enter' || e.code === 'NumpadEnter') {
1079 // The cursor's tile: its first action, or the picked one; else presses the banner's button.
1080 if (keys && state.status === 'playing') keyEnter()
1081 else controls.pressBanner()
1082 }
1083})
1084window.addEventListener('keyup', (e) => {
1085 if (keyDirection(e.code) || e.code.startsWith('Alt')) endPan()
1086})
1087// A key released while the window was elsewhere is never seen: the pan would run on.
1088addEventListener('blur', () => endPan())
1089
1090/** Shift + arrow: the keyboard's cursor hops that way. The press that wakes it only shows it, on the player. */
1091function keyHop(dx: Step, dy: Step): void {
1092 if (demo || state.status !== 'playing' || boardLocked()) return
1093 if (inspecting) return aimStep(dx, dy)
1094 if (keyWoke) cursor = null
1095 ensureCursor()
1096 if (!keyWoke) cursorMove(dx, dy)
1097}
1098
1099/** An action key: acts on the cursor's tile if the cursor shows (as the pad's face button does), else picks it as ever. */
1100function keyAction(id: ActionId): void {
1101 if (boardLocked()) return
1102 if (inputMode === 'keys' && cursor && !inspecting) {
1103 const button = keyButton(cursor.actions, id)
1104 if (button) return padDo(button)
1105 // Space is Wait, which only the player's own tile can do: away from it the key does not wait for you.
1106 if (id === 'wait') return controls.flashPad('wait')
1107 }
1108 pick(id)
1109}
1110
1111/** `?`: turns "What is that?" on, aimed at the nearest tile; pressed again on an aim, explains that tile. */
1112function keyInspect(): void {
1113 if (inspecting && aimExplain()) return
1114 inspect()
1115 if (inspecting) aimInspect()
1116}
1117
1118/** Enter with the keyboard's cursor: explains the aimed tile; confirms the picked action on the cursor's tile; else does the tile's first action. */
1119function keyEnter(): void {
1120 if (boardLocked()) return
1121 if (inspecting) {
1122 if (!aimExplain()) aimInspect()
1123 return
1124 }
1125 if (!cursor) return
1126 if (selected) {
1127 const tile = cursor.tile
1128 cursor = null
1129 return tapTile(tile)
1130 }
1131 const button = defaultButton(cursor.actions, cursor.tile, state.player)
1132 if (button) padDo(button)
1133 else controls.flashPad('move')
1134}
1135
1136/** Esc with the keyboard's cursor: cancels what is open or brings the cursor home, else puts it away (the next Esc, with the cursor gone, opens the menu). */
1137function keyEsc(): void {
1138 const step = escStep({ inspecting, picked: !!(selected || destination), onPlayer: !cursor || eq(cursor.tile, state.player) })
1139 if (step === 'cancel' || step === 'home') padBack()
1140 else handBack(null)
1141}
1142
1143/** Start timing a long press on `p`, or with null, cancel any press in progress. */
1144/** A press on the board that may become a drag: where it is now, and whether it moves the view. */
1145let drag: { x: number; y: number; panning: boolean } | null = null
1146/** How far (CSS pixels) a press may wander and still be a tap; further, it is a drag. */
1147const DRAG_PIXELS = 12
1148
1149function startPress(p: Vec | null): void {
1150 clearTimeout(pressTimer)
1151 armed = null
1152 pressTile = p
1153 if (!p || boardLocked() || selected || inspecting || state.status !== 'playing' || !isValidTarget(state, 'bomb', p)) return
1154 // A shortcut, as a tap is: never a bomb that costs a life (with a fuse of 1).
1155 if (isDeadly(state, { id: 'bomb', target: p })) return
1156 pressTimer = window.setTimeout(() => {
1157 armed = p
1158 navigator.vibrate?.(15)
1159 }, LONG_PRESS_MS)
1160}
1161
1162function tileUnder(e: PointerEvent): Vec | null {
1163 const r = mainCanvas.getBoundingClientRect()
1164 const w = screenToWorld(vec2(e.clientX - r.left, e.clientY - r.top))
1165 // Only what is in view: a tile under the bars round it can't be tapped.
1166 const v = viewRect()
1167 if (!demo && (w.x < v.left || w.x > v.right || w.y < v.bottom || w.y > v.top)) return null
1168 const p = toTile(state, w)
1169 return inBounds(state, p) ? p : null
1170}
1171
1172/** How fast the right stick moves the view at full push, in tiles a second (the controller, ui/gamepad.ts). */
1173const LOOK_TILES_PER_SECOND = 9
1174
1175/** The controller is what the player is using now (body.pad); a mouse or key takes it back. */
1176let padActive = false
1177/**
1178 * Who has the board (ui/keyCursor.ts): the controller ('pad', `padActive`), the keyboard's cursor
1179 * and aim ('keys': Shift + arrows, decisions.md 2026-11-02), or the mouse, touch and plain keys (null).
1180 * The cursor and the aim are on screen for the first two.
1181 */
1182let inputMode: InputMode = null
1183/** This key press woke the keyboard's cursor (the mode was not 'keys' before it): it only shows the cursor. */
1184let keyWoke = false
1185/** The keyboard's mode came from "What is that?" (`?` or an arrow while it was on), not from the cursor: it ends with it. */
1186let keysFromInspect = false
1187function setInputMode(mode: InputMode): void {
1188 inputMode = mode
1189 padActive = mode === 'pad'
1190}
1191/** This pad input is the one that woke the pad (a key or the mouse had had the board): a face button then only shows the cursor. */
1192let padWoke = false
1193/** The demo changes scene at most this often from the pad: a held stick repeats faster than a scene can be made. */
1194const PAD_SCENE_MS = 400
1195let lastPadScene = -Infinity
1196function padDemoControl(act: () => void): void {
1197 if (!demo || performance.now() - lastPadScene < PAD_SCENE_MS) return
1198 lastPadScene = performance.now()
1199 act()
1200}
1201/**
1202 * Up and down (shuffle, back to the first scene) act on the press only, not on the d-pad's repeats (a held direction calls again after
1203 * 250 ms, then every 75 ms): a call soon after the last one is a repeat.
1204 */
1205const PAD_ONCE_GAP_MS = 300
1206let lastPadOnce = -Infinity
1207function padDemoOnce(act: () => void): void {
1208 const now = performance.now()
1209 const repeat = now - lastPadOnce < PAD_ONCE_GAP_MS
1210 lastPadOnce = now
1211 if (!repeat) padDemoControl(act)
1212}
1213function padScene(delta: number): void {
1214 padDemoControl(() => skipTo(adjacentScene(demo!.index, delta)))
1215}
1216
1217/** "What is that?": the aim on a tile to explain (`aim`), with what pressing again does. */
1218function aimAt(tiles: Vec[], target: Vec | null): void {
1219 aim = { button: 'inspect', tiles, target, label: 'Find out what this is' }
1220 if (target) revealTile(target.x, target.y, state.width, state.height)
1221}
1222
1223/**
1224 * The controller's cursor (decisions.md 2026-11-01, "tile first"): one actionable tile, shown with
1225 * the dashed square while every actionable tile is blue, and what each face button would do there.
1226 */
1227let cursor: CursorState | null = null
1228/** The cursor: its tile, the bright blue ones, auto-move's faded ones (`far`, pad and keyboard only), and what the buttons do on its tile. */
1229interface CursorState {
1230 tile: Vec
1231 tiles: Vec[]
1232 far?: FarTile[]
1233 actions: TileAction[]
1234}
1235/** The kind of pad in use, for the names in the cursor's label. */
1236let padKind: PadFamily = 'generic'
1237
1238/** The faded tiles for the state as it is (`farTiles` walks the whole floor, so once per turn, not per hop). */
1239let farCache: { state: GameState; danger: Danger; destination: Vec | null; far: FarTile[] } | null = null
1240function currentFar(): FarTile[] {
1241 if (!farCache || farCache.state !== state || farCache.danger !== danger || farCache.destination !== destination) {
1242 farCache = { state, danger, destination, far: farTiles(state, safeStep) }
1243 }
1244 return farCache.far
1245}
1246
1247/**
1248 * Auto-move's way for a far tile only looked at, nothing set (decisions.md 2026-11-02): where the
1249 * pad's or keyboard's cursor rests on a faded tile, or the mouse hovers over one. Null while an action
1250 * is picked, a way is set, a dialog or the demo has the screen, or "What is that?" is on.
1251 * Worked out again only when the tile, the board or the danger changes.
1252 */
1253let previewCache: { state: GameState; danger: Danger; tile: Vec | null; view: RouteView | null } | null = null
1254function previewNow(): RouteView | null {
1255 if (selected || destination || inspecting || demo || dialogOpen() || autopilot || state.status !== 'playing') return null
1256 const tile = aimShown() ? (cursor?.actions.some((a) => a.far) ? cursor.tile : null) : inputMode === null ? mouseHover : null
1257 if (!tile) return null
1258 if (!previewCache || previewCache.state !== state || previewCache.danger !== danger || !previewCache.tile || !eq(previewCache.tile, tile)) {
1259 const far = aimShown() || farTileAt(state, safeStep, tile)
1260 previewCache = { state, danger, tile, view: far ? routePreview(state, tile, safeStep) : null }
1261 }
1262 return previewCache.view
1263}
1264
1265/** Every tile the cursor can rest on: the bright ones and the faded ones. */
1266function cursorCandidates(c: { tiles: readonly Vec[]; far?: readonly FarTile[] }): Vec[] {
1267 return [...c.tiles, ...(c.far ?? []).map((f) => f.tile)]
1268}
1269
1270function setCursor(tile: Vec): void {
1271 const far = currentFar()
1272 const tiles = actionableTiles(state)
1273 cursor = { tile, tiles, far, actions: cursorActions(state, safeStep, tile, destination, new Set(tiles.map((t) => t.y * state.width + t.x))) }
1274 revealTile(tile.x, tile.y, state.width, state.height)
1275}
1276
1277/** The board changed under the cursor: it stays on a tile that is still actionable, else moves to the nearest that is, else goes. */
1278function refreshCursor(): void {
1279 if (!cursor) return
1280 const tiles = cursorCandidates({ tiles: actionableTiles(state), far: currentFar() })
1281 const tile = state.status === 'playing' ? keepCursor(tiles, cursor.tile) : null
1282 if (tile) setCursor(tile)
1283 else cursor = null
1284}
1285
1286/**
1287 * The cursor is always there while a pad plays the board, starting on the player (each turn, level,
1288 * run, and after a dialog), or on the destination while auto-move walks (`cursorStart`), and gone otherwise. Called every frame, and by the pad's handlers.
1289 */
1290function ensureCursor(): void {
1291 const wanted = cursorWanted({ padActive: inputMode !== null, demo: !!demo, dialogOpen: dialogOpen(), inspecting, offerPending: !!(state.offer || state.chargeOffer), autopilot, status: state.status })
1292 if (!wanted) cursor = null
1293 else if (!cursor) {
1294 // Waited with the cursor on the player (Q79): it stays there, whatever walk is still set.
1295 const stay = cursorPinned
1296 cursorPinned = false
1297 setCursor(stay ? restingCursor(state) : cursorStart(state, destination, cursorCandidates({ tiles: actionableTiles(state), far: currentFar() })))
1298 }
1299}
1300
1301/** Wait was pressed with the cursor on the player's own tile: the cursor starts there again, not on a walk's destination. */
1302let cursorPinned = false
1303
1304/** A d-pad press: the cursor moves to the next actionable tile that way (from the player's tile, if it was resting). */
1305function cursorMove(dx: Step, dy: Step): void {
1306 ensureCursor()
1307 if (!cursor) return
1308 const tiles = cursorCandidates(cursor)
1309 // On a tile that has no action any more (Q80): the press takes it to the nearest one that has, so it is never stuck.
1310 if (!tiles.some((t) => eq(t, cursor!.tile))) return setCursor(keepCursor(tiles, cursor.tile) ?? restingCursor(state))
1311 setCursor(moveCursor(tiles, cursor.tile, dx, dy))
1312}
1313
1314/**
1315 * A face button with the cursor showing: that action, at once, on the cursor's tile (the action
1316 * bar's button picked and the tile tapped, so a choice dialog for a bomb asks, and Freeze works
1317 * as it does there). One that is not possible there flashes its button. One that would cost a
1318 * life is played (the button was marked): only a tap with nothing picked refuses.
1319 */
1320function padDo(button: BarButton): void {
1321 const c = cursor
1322 const action = c?.actions.find((a) => a.button === button)
1323 if (!c || !action?.possible) return controls.flashPad(button)
1324 cursor = null
1325 if (button === 'wait') {
1326 cursorPinned = eq(c.tile, state.player)
1327 return pick('wait', false)
1328 }
1329 // A faded tile: nothing is picked, the tile is tapped, which is auto-move's walk there (or its next step).
1330 if (action.far) {
1331 selected = null
1332 return tapTile(c.tile)
1333 }
1334 selected = button
1335 afterChange()
1336 tapTile(c.tile)
1337}
1338
1339/**
1340 * L1: in "What is that?" puts the aim on you (Q85, Q86); else what is left picked (after a cancelled choice
1341 * dialog) and the flag, as Esc does. The cursor then goes back to the player's own tile.
1342 */
1343function padBack(): void {
1344 cursorPinned = false
1345 // In "What is that?" it only puts the aim on you: leaving is Circle (Q85, Q86).
1346 if (inspecting) return aimAt(explainableTiles(state, true), state.player)
1347 if (selected || destination) {
1348 selected = null
1349 destination = null
1350 }
1351 cursor = null
1352 aim = null
1353 afterChange()
1354}
1355
1356/**
1357 * The dialog open now is an explanation the pad's "What is that?" asked for, or one opened from it (Q88): its buttons also take a single press.
1358 * It lasts while dialogs follow one another (a button that opens the next one: the `close` event comes after that dialog has already opened), and ends when none is left.
1359 */
1360let inspectDialog = false
1361document.getElementById('dialog')!.addEventListener('close', () => {
1362 if (!dialogOpen()) inspectDialog = false
1363})
1364
1365/** Whether "What is that?" asks for a held press like the rest of the pad (to be an option, Q71/Q88); off: one press does it. */
1366const INSPECT_HOLD = false
1367
1368/** Circle in "What is that?": the mode ends, the cursor comes back (Q85). */
1369function leaveInspect(): void {
1370 inspecting = false
1371 aim = null
1372 cursor = null
1373 afterChange()
1374}
1375
1376/**
1377 * An arrow with "What is that?" on: the aim jumps to the next tile that has something to say that way, nothing wraps round;
1378 * pillars and walls included (Q82). Up from the top row goes on to the top bar's items, Left and Right move along them, Down comes back to the board.
1379 * With no aim yet (it was turned on by mouse, or the aim was put away), the first press makes it.
1380 */
1381function aimStep(dx: Step, dy: Step): void {
1382 if (!aim?.target) return aimInspect()
1383 const items = hudAimItems()
1384 if (aim.hud) {
1385 if (dy > 0) return aimAt(aim.tiles, aim.target)
1386 const at = items.findIndex((el) => el.dataset.info === aim!.hud)
1387 const rects = items.map((el) => el.getBoundingClientRect()).map((r) => ({ x: r.x, y: r.y, w: r.width, h: r.height }))
1388 const to = items[nextFocus(rects, Math.max(0, at), dx, dy)]
1389 if (to) aim = { ...aim, hud: to.dataset.info as HudItem }
1390 return
1391 }
1392 const next = jumpTarget(aim.tiles, aim.target, dx, dy)
1393 if (dy < 0 && eq(next, aim.target) && items[0]) {
1394 // The item nearest the tile's column on screen.
1395 const x = tilePoint(aim.target).x
1396 const nearest = items.reduce((a, b) => (Math.abs(centreX(b) - x) < Math.abs(centreX(a) - x) ? b : a))
1397 aim = { ...aim, hud: nearest.dataset.info as HudItem }
1398 }
1399 else aimAt(aim.tiles, next)
1400}
1401
1402const centreX = (el: HTMLElement): number => {
1403 const r = el.getBoundingClientRect()
1404 return r.left + r.width / 2
1405}
1406
1407/** The top bar's items the aim can rest on, left to right. */
1408function hudAimItems(): HTMLElement[] {
1409 return [...document.querySelectorAll<HTMLElement>('#hud [data-info]')].filter((el) => el.getClientRects().length > 0)
1410}
1411
1412/** Explains what the aim is on (a tile, or a top-bar item); false if it is on nothing. The mode stays on (Q87). */
1413function aimExplain(): boolean {
1414 // The mode and the aim stay (Q87): when the dialog closes the player is still asking, and can go on to the next thing.
1415 if (aim?.hud) {
1416 showInfo(hudInfo(aim.hud, state))
1417 inspectDialog = dialogOpen()
1418 return true
1419 }
1420 if (!aim?.target) return false
1421 if (state.status !== 'playing' || boardLocked()) return true
1422 showTileInfo(aim.target)
1423 inspectDialog = dialogOpen()
1424 return true
1425}
1426
1427/** The top-bar item the aim is on gets the selector's outline (`.pad-aimed`), the others lose it. Every frame, since the bar is rebuilt on each change. */
1428function syncHudAim(): void {
1429 const on = aimShown() ? aim?.hud : undefined
1430 for (const el of document.querySelectorAll<HTMLElement>('#hud [data-info]')) el.classList.toggle('pad-aimed', !!on && el.dataset.info === on)
1431}
1432
1433/** "What is that?" is on: aim at the nearest tile with something to explain (a plain pillar or wall only if there is nothing else). */
1434function aimInspect(): void {
1435 const tiles = explainableTiles(state, true)
1436 const first = aimStart(state, tiles)
1437 if (first) aimAt(tiles, first)
1438}
1439
1440/** Whether the controller's aim or cursor is on screen: the square and the label go together. */
1441function aimShown(): boolean {
1442 return !!(aim ?? cursor) && inputMode !== null && !demo && !dialogOpen() && !autopilot && state.status === 'playing'
1443}
1444
1445let lastBarKey = ''
1446let lastNote = ''
1447let lastHintKey = ''
1448/** The demo's virtual controller (ui/padOverlay.ts), under its words; shown when the demo plays for a pad. */
1449const demoPad = createPadOverlay('large')
1450demoPad.el.hidden = true
1451document.getElementById('demo-caption')!.after(demoPad.el)
1452/** The keyboard demo's key overlay (ui/keyOverlay.ts), in the controller's place; shown when the demo plays for the keyboard. */
1453const demoKeys = createKeyOverlay('large')
1454demoKeys.el.hidden = true
1455demoPad.el.after(demoKeys.el)
1456/** The dialogs' clips show a small controller instead of the finger while a pad is in use (ui/clip.ts). */
1457function syncClipPad(): void {
1458 setClipPad(padActive ? padKind : null)
1459}
1460/** Hold to confirm (ui/padHold.ts): the ring round a face button's picture on the page, and on the demo's controller. */
1461const holdRing = createHoldRing()
1462/**
1463 * The input the demo was last picked for with its button (Q61), or null: then the demo follows
1464 * what is detected. Session only. It ends with the demo, or when the controller setting changes (one plugged in or
1465 * unplugged: `dropDemoPick`); no click, tap or button press drops it (Pavel, 2026-11-02), only holding the confirm
1466 * button, which ends the demo.
1467 */
1468let demoForced: DemoInput | null = null
1469/** What the demo is shown for now (ui/demoInput.ts): the pick, else the detected input. */
1470const demoInput = (): DemoInput => effectiveDemoInput(demoForced, padActive, padKind)
1471/** The pad family the demo's controller and captions draw: a picked controller's, else the real pad's. */
1472const demoFamily = (): PadFamily => demoInputFamily(demoInput()) ?? padKind
1473/** The demo's last line: "Tap anywhere to play", "Click anywhere to play", or for a pad, hold the confirm face button (bottom; a Nintendo pad's right-hand A), the only way a pad ends the demo. */
1474const demoHint = document.querySelector('#demo-title .demo-hint')!
1475function syncDemoHint(): void {
1476 // The button to press is the real pad's, even when the demo's controller shows another family.
1477 demoHint.innerHTML = padCaptionHtml(demoHintText(demoInput()), padActive ? padKind : demoFamily(), spriteUrl)
1478 syncDemoGlyphs()
1479}
1480/**
1481 * The pad's buttons for the demo's own controls, as pictures on them (Q67: every action shows its button), only while the demo is
1482 * drawn for a controller: d-pad left / right = scene before / after, up = shuffle, down = back to the first, R1 = the next input.
1483 */
1484function syncDemoGlyphs(): void {
1485 const family = padActive ? padKind : demoFamily()
1486 const glyphs: Record<string, string> = {
1487 'demo-prev': dpadGlyph(family, 'left'),
1488 'demo-next': dpadGlyph(family, 'right'),
1489 'demo-shuffle': dpadGlyph(family, 'up'),
1490 'demo-restart': dpadGlyph(family, 'down'),
1491 'demo-input': shoulderGlyph(family, 'r'),
1492 }
1493 for (const [id, glyph] of Object.entries(glyphs)) {
1494 const button = document.getElementById(id)!
1495 let img = button.querySelector<HTMLImageElement>('.pad-glyph')
1496 if (!img) {
1497 img = document.createElement('img')
1498 img.className = 'pad-glyph'
1499 img.alt = ''
1500 img.draggable = false
1501 button.append(img)
1502 }
1503 img.src = spriteUrl(glyph)
1504 }
1505 document.getElementById('demo')!.classList.toggle('pad', isPadInput(demoInput()))
1506}
1507/** The demo's controller, caption and last line drawn for the family now in use (the pad's kind became known, or a pick changed it). */
1508function syncDemoFamily(): void {
1509 demoPad.setFamily(demoFamily())
1510 // The demo's caption names the buttons of the pad it was drawn for: draw it again.
1511 if (demo?.pad && padCaptionNow) setPadCaption(padCaptionNow.text, padCaptionNow.icon, padCaptionNow.mono)
1512 syncDemoHint()
1513}
1514/** A controller was plugged in or unplugged: the demo goes back to following what is detected, drawn at once. */
1515function dropDemoPick(): void {
1516 if (!demoForced) return
1517 demoForced = null
1518 if (demo) {
1519 syncInputButton()
1520 skipTo(demo.index)
1521 }
1522}
1523const pad = createPadInput({
1524 active() {
1525 padWoke = !padActive
1526 setInputMode('pad')
1527 keysFromInspect = false
1528 document.body.classList.add('pad')
1529 syncClipPad()
1530 // The demo's last line shows the button of the pad that is really in hand.
1531 if (padWoke && demo) syncDemoHint()
1532 },
1533 error: (e) => reportError('gamepad', e),
1534 // The kind of pad, for the pictures on the action bar, in dialogs and the banner, and in the cursor's label.
1535 family(f) {
1536 dropDemoPick()
1537 padKind = f
1538 controls.setPadFamily(f)
1539 padDialog.setFamily(f)
1540 // The pad woke mid-demo: its controller shows at once, not from the next scene.
1541 if (demo && isPadInput(demoInput())) demoPad.el.hidden = false
1542 syncDemoFamily()
1543 syncClipPad()
1544 },
1545 gone: dropDemoPick,
1546 toast: (title, text) => showToast(handIcon, title, text),
1547 dir(dx, dy) {
1548 if (demo) {
1549 if (dy === 0 && dx !== 0) return padScene(dx)
1550 // Up shuffles, down goes back to the first scene (the same as the buttons; their pictures say so).
1551 if (dx === 0 && dy !== 0) return padDemoOnce(dy < 0 ? demoShuffleToggle : demoRestart)
1552 return
1553 }
1554 if (dialogOpen()) return padDialog.move(dx, dy)
1555 if (state.status !== 'playing' || boardLocked()) return
1556 if (inspecting) {
1557 // "What is that?": the d-pad jumps between the tiles it can explain, nothing wraps round. With
1558 // no aim yet (it was turned on by key or mouse, or the aim was put away), the first press makes it.
1559 aimStep(dx, dy)
1560 return
1561 }
1562 cursorMove(dx, dy)
1563 },
1564 // A face button held (ui/padHold.ts): the ring fills round its picture; the press follows the moment it is full (the ring stays full until the button is let go or the place changes).
1565 // "What is that?" acts on a single press (Q88): nothing in it can hurt. Holding there is to become an option (Q71).
1566 // The info dialogs it opens are part of the asking: the same (the menu and the like keep the hold).
1567 instant: () => !INSPECT_HOLD && !demo && (dialogOpen() ? inspectDialog : inspecting && state.status === 'playing' && !boardLocked()),
1568 hold(face, progress) {
1569 if (demo) {
1570 // In the demo only the confirm button (Cross / A; a Nintendo pad's right-hand A) does anything: it starts play.
1571 if (face === confirmFace(padKind)) {
1572 // A pad that woke mid-demo (Chrome lists one only after a press) finds the controller still hidden: show it, so the hold is seen.
1573 if (demoPad.el.hidden) demoPad.el.hidden = false
1574 demoPad.hold('move', progress)
1575 }
1576 return
1577 }
1578 // On the board the press that only woke the pad shows no ring, as it does nothing but show the cursor; a dialog or the banner acts on it, so it has its ring.
1579 const onBoard = !dialogOpen() && state.status === 'playing'
1580 // The autopilot has the board: nothing to confirm there, so no ring.
1581 if ((padWoke && onBoard) || boardLocked()) return
1582 holdRing.show(face, progress, dialogOpen() ? '#dialog' : onBoard ? '#actions' : '#banner')
1583 },
1584 holdEnd() {
1585 holdRing.hide()
1586 demoPad.hold('move', null)
1587 },
1588 // A face button let go too early: in a dialog, a hint that it must be held.
1589 tap(face) {
1590 if (!demo && dialogOpen()) padDialog.tap(face)
1591 },
1592 press(button, face) {
1593 if (demo) {
1594 // The confirm face button (Cross / A; a Nintendo pad's A on the right), held until its ring is full, starts play (and, still held, does nothing in the game: gamepad.ts); the other face buttons do nothing here. Any other button that acts ends it, as a tap or Esc does.
1595 if (face !== undefined) {
1596 if (face === confirmFace(padKind)) endDemo()
1597 } else if (button === 'info') cycleDemoInput()
1598 else if (button !== 'reset') endDemo()
1599 return
1600 }
1601 if (button === 'menu') {
1602 if (!dialogOpen()) showMenu()
1603 else if (document.getElementById('dialog-title')?.textContent === MENU_TITLE) closeDialog()
1604 return
1605 }
1606 if (dialogOpen()) {
1607 // In a dialog: face buttons press its buttons, LB / RB switch tabs.
1608 if (face !== undefined) padDialog.face(face)
1609 else if (button === 'cancel') padDialog.tab(-1)
1610 else if (button === 'info') padDialog.tab(1)
1611 return
1612 }
1613 // The end-of-level or end-of-run banner: its buttons sit on the face buttons.
1614 if (face !== undefined && padDialog.banner(face)) return
1615 if (state.status !== 'playing') return
1616 // The autopilot has the board: L1 stops it, the right stick's click recentres the view, the rest waits.
1617 if (boardLocked()) {
1618 if (button === 'cancel') stopAutopilot()
1619 else if (button === 'reset') resetPan()
1620 return
1621 }
1622 if (button === 'reset') resetPan()
1623 else if (button === 'cancel') padBack()
1624 else if (button === 'info') {
1625 // RB or View: "What is that?" on, and aimed.
1626 // Q85: it turns the mode on; leaving is Circle, explaining is Cross.
1627 cursor = null
1628 if (inspecting) {
1629 if (!aim) aimInspect()
1630 } else {
1631 inspect()
1632 if (inspecting) aimInspect()
1633 }
1634 } else if (face !== undefined) {
1635 // Move/Go (Cross, A) explains the aimed tile while "What is that?" is on; the other face buttons do nothing then.
1636 if (inspecting) {
1637 // Cross (confirm) views, Circle (cancel) leaves (Q85); a press each (Q88).
1638 if (button === 'move') aimExplain()
1639 else if (button === 'wait') leaveInspect()
1640 } else {
1641 // The press that woke the pad (a key or mouse had put the cursor away) only shows the cursor.
1642 const woke = padWoke
1643 ensureCursor()
1644 if (!woke && (button === 'move' || button === 'wait' || button === 'bomb' || button === 'charge')) padDo(barButtonFor(button))
1645 }
1646 }
1647 },
1648 look(dx, dy) {
1649 if (demo) return
1650 if (dialogOpen()) return padDialog.scroll(dy, timeDelta)
1651 panView(dx * LOOK_TILES_PER_SECOND * timeDelta, -dy * LOOK_TILES_PER_SECOND * timeDelta)
1652 },
1653 lookEnd: () => endPan(),
1654}, undefined, undefined, () => `${demo ? 'demo' : dialogOpen() ? 'dialog' : boardLocked() ? 'autopilot' : state.status}`)
1655/** A pad button's picture, for the cursor's label and hint (style.css `.pad-glyph`). */
1656function buttonPicture(src: string): HTMLImageElement {
1657 const img = document.createElement('img')
1658 img.className = 'pad-glyph'
1659 img.alt = ''
1660 img.draggable = false
1661 img.src = src
1662 return img
1663}
1664/** A key's cap, for the same label and hint when the keyboard plays the board (style.css `.key-cap`). */
1665function keyCap(text: string): HTMLElement {
1666 const cap = document.createElement('kbd')
1667 cap.className = 'key-cap'
1668 cap.textContent = text
1669 return cap
1670}
1671/** The one-line note over the action bar: what confirming the controller's target does (style.css `#pad-label`). */
1672const padLabel = document.createElement('div')
1673padLabel.id = 'pad-label'
1674padLabel.hidden = true
1675padLabel.setAttribute('role', 'status')
1676document.body.append(padLabel)
1677/** The legend for L1 and R1, above the label (style.css `#pad-hint`): shown while the cursor is. */
1678const padHint = document.createElement('div')
1679padHint.id = 'pad-hint'
1680padHint.hidden = true
1681document.body.append(padHint)
1682// A dialog opening puts the cursor away, so closing the menu does not bring the old one back.
1683document.getElementById('dialog')!.addEventListener('dialogopen', () => {
1684 cursor = null
1685})
1686/** A click, tap or key (not a mouse move) gives the board back to the mouse, touch or keyboard: the marker goes, with the cursor and the aim, so Esc and clicks are not swallowed by them. */
1687function handBack(next: InputMode): void {
1688 const was = padActive
1689 const before = inputMode
1690 cursorPinned = false
1691 setInputMode(next)
1692 if (next === null) keysFromInspect = false
1693 if (was && demo) syncDemoHint()
1694 // Staying with the keyboard's cursor keeps it, and the aim; any other change starts them afresh.
1695 if (!(before === 'keys' && next === 'keys')) {
1696 aim = null
1697 cursor = null
1698 }
1699 document.body.classList.toggle('pad', padActive)
1700 syncClipPad()
1701}
1702// Any key or press hands the board over, except the keys the keyboard's cursor itself uses (ui/keyCursor.ts `modeAfterKey`).
1703addEventListener(
1704 'keydown',
1705 (e) => {
1706 // A modifier on its own, or a Cmd shortcut (a screenshot's Cmd+Shift+4), is not play: it leaves the pad's cursor alone (Q81).
1707 if (ignoredForMode(e)) return
1708 const next = modeAfterKey(inputMode, e, inspecting, dialogOpen() || !!demo)
1709 keyWoke = next === 'keys' && inputMode !== 'keys'
1710 // An arrow or `?` that woke the aim while "What is that?" was on, or `?` itself, is not the cursor's: it goes with "What is that?".
1711 if (keyWoke) keysFromInspect = keyRole(e) !== 'hop'
1712 else if (next === 'keys' && keyRole(e) === 'hop') keysFromInspect = false
1713 handBack(next)
1714 },
1715 true,
1716)
1717addEventListener('pointerdown', () => handBack(null), true)
1718// A mouse or touch click on a button of the game leaves it focused in Chrome, so Space would press it again instead of waiting.
1719// A click from the keyboard (detail 0) keeps its focus, as do the dialogs' and the demo's buttons.
1720addEventListener('click', (e) => {
1721 const button = (e.target as Element | null)?.closest?.('button')
1722 if (e.detail > 0 && button && !button.closest('#dialog, .demo-nav')) button.blur()
1723})
1724// Moving the mouse does NOT hand the board back (Pavel, 2026-11-02: it switched out of the pad's cursor whenever the mouse was
1725// touched, e.g. to take a screenshot): only a click or tap does (`pointerdown` above), or a key.
1726// LittleJS would also read the pad (its own d-pad-as-keys); this game does it itself.
1727setGamepadsEnable(false)
1728
1729if (!resumeSaved()) startRun(Number(params.get('seed')) || randomSeed(), testing ? urlOptions() : preferredOptions())
1730
1731/**
1732 * The replay page (Q103, dev only): `?replay=<name>` plays a bug report's run (made by
1733 * `npm run report-replay`) in a loop, with two players on the one board, side by side: the red
1734 * one (left) plays the report's recorded turns (the replay), the blue one (right) the same turns
1735 * until the autopilot took over and then today's autopilot (the simulation). Each turn both take
1736 * one. The label says how long they stay in step and when they split. The board shown is the
1737 * replay's; the simulation's bombs are outlined in blue.
1738 */
1739const REPLAY_STEP_SECONDS = 0.55
1740const REPLAY_HOLD_SECONDS = 3
1741let replayScript: ReplayScript | null = null
1742interface ReplayRun {
1743 i: number
1744 at: number
1745 /** The blue player's state and animation (the red one's are `state` and `anim`). */
1746 blue: GameState
1747 blueAnim: ReturnType<typeof newAnimation>
1748 red: Action[]
1749 sim: Action[]
1750 /** The first turn (counted from the autopilot taking over) at which they were not on the same tile, or null. */
1751 split: number | null
1752 held: boolean
1753}
1754let replayRun: ReplayRun | null = null
1755let replayLabel: HTMLElement | null = null
1756let rulesCompare: RuleComparison | null = null
1757let rulesRef = ''
1758if (replayOn) {
1759 replayLabel = document.createElement('div')
1760 replayLabel.style.cssText = 'position:fixed;left:50%;transform:translateX(-50%);bottom:12px;z-index:50;padding:6px 14px;border-radius:8px;font:600 17px system-ui;color:#fff;background:#444;text-align:center;pointer-events:none;white-space:pre-line'
1761 document.body.append(replayLabel)
1762 // Whatever goes wrong here (no such replay, a record the rules refuse, rules not extracted) is told on the page, not left as a blank one.
1763 try {
1764 const response = await fetch(`/__replay/${encodeURIComponent(params.get('replay')!)}.json`)
1765 if (!response.ok) throw new Error(`no such replay: ${params.get('replay')}`)
1766 const loaded = (await response.json()) as RunRecord
1767 const ref = params.get('rules') ?? ''
1768 if (/^[0-9a-f]{7,40}$/.test(ref)) {
1769 // The whole run under the rules of an older commit (red) and today's (blue): `rules-versions/<ref>/src/sim`, from tools/report-replay.mjs.
1770 rulesRef = ref
1771 let oldState, oldTurn
1772 try {
1773 ;[oldState, oldTurn] = await Promise.all([import(/* @vite-ignore */ `/rules-versions/${ref}/src/sim/state.ts`), import(/* @vite-ignore */ `/rules-versions/${ref}/src/sim/turn.ts`)])
1774 } catch {
1775 throw new Error(`the rules of ${ref} are not extracted: run npm run report-replay -- <mail> --rules ${ref}`)
1776 }
1777 rulesCompare = compareRules({ newRun: oldState.newRun, step: oldTurn.step }, { newRun, step }, loaded)
1778 } else replayScript = buildReplay(loaded)
1779 } catch (error) {
1780 console.error('replay:', error)
1781 replayLabel.textContent = String(error instanceof Error ? error.message : error)
1782 replayLabel.style.background = '#b3261e'
1783 }
1784}
1785
1786/** Starts a loop: both players on the room as it was, side by side. */
1787function replayStart(): void {
1788 const r = replayScript!
1789 resetView()
1790 state = r.start
1791 afterChange()
1792 replayRun = { i: 0, at: time + 1.2, blue: r.start, blueAnim: newAnimation(), red: [...r.prefix, ...r.old], sim: [...r.prefix, ...r.neu], split: null, held: false }
1793}
1794
1795const lifeText = (s: GameState): string => `${s.status === 'playing' ? 'alive' : s.status}, ${s.lives} ${s.lives === 1 ? 'life' : 'lives'}`
1796
1797/** How the rules comparison plays: the quiet stretch where the games agree is jumped through, then each turn near the split (or the end) is shown. */
1798const RULES_LEAD_TURNS = 6
1799const RULES_TAIL_TURNS = 15
1800interface RulesRun {
1801 i: number
1802 end: number
1803 at: number
1804 blue: GameState
1805 blueAnim: ReturnType<typeof newAnimation>
1806 held: boolean
1807}
1808let rulesRun: RulesRun | null = null
1809
1810/** The state of one side after turn `k` (-1: the start); a side that stopped keeps its last. */
1811const sideState = (side: RuleComparison['red'], k: number): GameState => (k < 0 ? side.start : side.results[Math.min(k, side.results.length - 1)]?.state ?? side.start)
1812
1813function rulesStart(): void {
1814 const c = rulesCompare!
1815 const total = c.actions.length
1816 const from = c.split === null ? Math.max(0, total - 8) : Math.max(0, c.split - RULES_LEAD_TURNS)
1817 resetView()
1818 state = sideState(c.red, from - 1)
1819 afterChange()
1820 replayLabel!.textContent = `Rules of ${rulesRef} (red) against today's (blue), the whole run: ${c.split === null ? 'no difference' : `they split at turn ${c.split + 1}`}`
1821 rulesRun = { i: from, end: c.split === null ? total : Math.min(total, c.split + RULES_TAIL_TURNS), at: time + 1.2, blue: sideState(c.blue, from - 1), blueAnim: newAnimation(), held: false }
1822}
1823
1824function rulesTick(): void {
1825 const c = rulesCompare!
1826 if (!rulesRun) return rulesStart()
1827 const run = rulesRun
1828 if (time < run.at) return
1829 const total = c.actions.length
1830 const names = `Red: rules of ${rulesRef} Blue: today's rules`
1831 if (run.i >= run.end) {
1832 if (run.held) return rulesStart()
1833 run.held = true
1834 run.at = time + REPLAY_HOLD_SECONDS
1835 const status = (s: GameState) => `${s.status === 'playing' ? 'alive' : s.status}, ${s.lives} ${s.lives === 1 ? 'life' : 'lives'}, level ${s.level}`
1836 replayLabel!.textContent = `${c.split === null ? 'The two versions agree on the whole run' : `Split at turn ${c.split + 1} of ${total}: ${c.why}`}\nRed: ${status(state)} Blue: ${status(run.blue)}`
1837 return
1838 }
1839 const action = c.actions[run.i]!
1840 const red = c.red.results[run.i]
1841 if (red) animateTurn(red, action)
1842 const blue = c.blue.results[run.i]
1843 if (blue) {
1844 animateBoard(run.blueAnim, run.blue, blue, action, time)
1845 run.blue = blue.state
1846 }
1847 danger = predictDanger(state)
1848 run.i++
1849 const split = c.split !== null && run.i > c.split
1850 replayLabel!.textContent = `Turn ${run.i} of ${total}, level ${state.level}: ${split ? `split since turn ${c.split! + 1} (${c.why})` : 'in step'}\n${names}`
1851 replayLabel!.style.background = split ? '#7b3a8a' : '#555'
1852 run.at = time + REPLAY_STEP_SECONDS
1853}
1854
1855function replayTick(): void {
1856 if (rulesCompare) return rulesTick()
1857 if (!replayScript) return
1858 const r = replayScript
1859 if (!replayRun) return replayStart()
1860 const run = replayRun
1861 if (time < run.at) return
1862 const turns = Math.max(run.red.length, run.sim.length)
1863 // Done: held a moment with how each ended, then round again.
1864 if (run.i >= turns) {
1865 if (run.held) return replayStart()
1866 run.held = true
1867 run.at = time + REPLAY_HOLD_SECONDS
1868 return
1869 }
1870 const fromFork = run.i - r.prefix.length + 1
1871 const redAction = run.red[run.i]
1872 if (redAction && state.status === 'playing') animateTurn(step(state, redAction), redAction)
1873 const blueAction = run.sim[run.i]
1874 if (blueAction && run.blue.status === 'playing') {
1875 const result = step(run.blue, blueAction)
1876 animateBoard(run.blueAnim, run.blue, result, blueAction, time)
1877 run.blue = result.state
1878 }
1879 danger = predictDanger(state)
1880 run.i++
1881 const together = eq(state.player, run.blue.player) && state.status === run.blue.status
1882 if (!together && run.split === null) run.split = Math.max(1, fromFork)
1883 const done = run.i >= turns
1884 replayLabel!.textContent =
1885 (run.i <= r.prefix.length ? `Turn ${run.i}: same recorded turns, in step` : run.split === null ? `${fromFork} turns after the autopilot took over: in step` : `${fromFork} turns after the autopilot took over: split since turn ${run.split}`) +
1886 `\nRed (replay): ${done ? lifeText(state) : 'recorded turns'} Blue (simulation): ${done ? lifeText(run.blue) : "today's autopilot"}`
1887 replayLabel!.style.background = run.split === null ? '#555' : '#7b3a8a'
1888 run.at = time + REPLAY_STEP_SECONDS
1889}
1890
1891/**
1892 * The demo, before the game starts (user request, 2026-10-23: some players never played the
1893 * original). Scenes made up on the spot (sim/demo.ts) are replayed view-only, each with a caption,
1894 * the top bar hidden; the run waits behind it. Any tap or key ends it, and does nothing else. Not
1895 * on a test URL, unless it has `?demo`.
1896 * Since 2026-10-24 (user request) it shows the controls too: a finger plays every turn as the
1897 * player would — a tap on a tile (a ripple where it lands), a long press for a bomb (a ring
1898 * closing in, then the bomb's preview), an action button and then a tile, Go — on the real
1899 * action bar, which shows during the demo, and the board answers as in play (the picked button's
1900 * tiles, the preview, auto-move's way). The scenes about the controls come first.
1901 */
1902/** How long the finger takes to get somewhere, a tap lasts, and a button stays picked before its tile. */
1903const FINGER_MOVE_SECONDS = 0.45
1904const TAP_SECONDS = 0.18
1905const PICKED_SECONDS = 0.45
1906/** After a turn: time for its animations before the finger moves on. */
1907const DEMO_SETTLE_SECONDS = 0.55
1908/** How long a scene's last turn stays on screen, and how long before a scene's first move. */
1909const DEMO_HOLD_SECONDS = 1.8
1910const DEMO_LEAD_SECONDS = 0.8
1911/** One beat of the demo's script: something done, then a pause before the next. */
1912interface Beat {
1913 run: () => void
1914 then: number
1915}
1916/** `rest`: the finger is still to be put beside the player, once the camera shows the new scene. */
1917/**
1918 * `index`: the scene playing, in DEMO_ORDER. `upcoming`: the one to play next, being made in the
1919 * background (`scene` undefined until it is).
1920 */
1921let demo: {
1922 scene: Scene | null
1923 index: number
1924 upcoming: { index: number; scene: Scene | null | undefined } | null
1925 next: number
1926 at: number
1927 real: GameState
1928 beats: Beat[]
1929 rest: boolean
1930 /** Playing for a gamepad (Q58): PAD_DEMO_ORDER, the virtual controller instead of the finger. */
1931 pad: boolean
1932 /** Playing for the keyboard (Q65): KEY_DEMO_ORDER, the key overlay instead of the finger. */
1933 keys: boolean
1934 /** The input it is drawn for (ui/demoInput.ts): touch's finger, the mouse's pointer, or a controller's family. */
1935 input: DemoInput
1936 /** The pad demo's cursor, shown on the board as the pad's own is (null: not shown). */
1937 cursor: CursorState | null
1938} | null = null
1939// The scenes' seeds: from `?seed` for a repeatable demo, otherwise random.
1940let demoSeed = Number(params.get('seed')) || randomSeed()
1941/**
1942 * Besides the scenes made in the sim, seven acted out on a still room: a tour of the top bar and
1943 * "What is that?" on an enemy (user request, 2026-10-24; they come after the controls' scenes),
1944 * and the pad's own five (Q58: the cursor, L1, R1, the stick, Start).
1945 */
1946type StillItem = 'topBar' | 'whatIsThat' | 'padCursor' | 'padL1' | 'padR1' | 'padStick' | 'padLook' | 'padStart' | KeyStill
1947type DemoItem = SceneKind | StillItem
1948/** The still scenes (the pad's own, Q58, and the keyboard's, Q65): the sim scene whose room each plays on, without its turns. */
1949const STILL_ROOMS: Record<StillItem, SceneKind> = {
1950 topBar: 'tapMove', whatIsThat: 'enemies', padCursor: 'tapMove', padL1: 'tapMove', padR1: 'enemies', padStick: 'tapMove', padLook: 'enemies', padStart: 'tapMove',
1951 keyCursor: 'tapMove', keyInspect: 'enemies', keyLook: 'tapMove', keyMenu: 'tapMove',
1952}
1953const isStill = (item: DemoItem): item is StillItem => item in STILL_ROOMS
1954const isKeyStill = (item: StillItem): item is KeyStill => (KEY_STILLS as readonly string[]).includes(item)
1955/**
1956 * Every scene, in one fixed order, looped (user request, 2026-10-24; they were shuffled): the
1957 * controls first, then the rest in the table's order (sim/demo.ts), a way to die after every
1958 * four (they took turns until then: user request, 2026-10-23).
1959 */
1960const DEMO_ORDER: readonly DemoItem[] = [
1961 ...CONTROL_KINDS,
1962 'topBar',
1963 'whatIsThat',
1964 ...LIVING_KINDS.flatMap((k, i) => (i % 4 === 3 && DYING_KINDS[(i - 3) / 4] ? [k, DYING_KINDS[(i - 3) / 4]!] : [k])),
1965 ...DYING_KINDS.slice(Math.floor(LIVING_KINDS.length / 4)),
1966 // Developer mode's edge-case checks (sim/testScenes.ts), with `?dev`.
1967 ...(params.has('dev') ? TEST_KINDS : []),
1968]
1969/** The gamepad's demo (Q58): the controller's own scenes first, then the touch demo's without the touch-only ones (ui/padDemo.ts `padDemoOrder`). */
1970const PAD_DEMO_ORDER: readonly DemoItem[] = padDemoOrder(DEMO_ORDER)
1971/** The keyboard's demo (Q65): its own scenes (the Shift cursor, "What is that?", looking around, the menu) after the controls, then the touch demo's without the touch-only ones (ui/keyDemo.ts `keyDemoOrder`). */
1972const KEY_DEMO_ORDER: readonly DemoItem[] = keyDemoOrder(DEMO_ORDER)
1973/** Which order of scenes an input plays: touch and mouse share one. */
1974type OrderKind = 'touch' | 'pad' | 'keys'
1975const orderKind = (pad: boolean, keys: boolean): OrderKind => (pad ? 'pad' : keys ? 'keys' : 'touch')
1976const orderFor = (kind: OrderKind): readonly DemoItem[] => (kind === 'pad' ? PAD_DEMO_ORDER : kind === 'keys' ? KEY_DEMO_ORDER : DEMO_ORDER)
1977/** The kind of order the demo plays in now. */
1978const demoKind = (): OrderKind => orderKind(!!demo?.pad, !!demo?.keys)
1979/** The order the demo plays in now. */
1980const demoOrder = (): readonly DemoItem[] => orderFor(demoKind())
1981/** Where the demo is, kept so it carries on from there next time (not for a test URL: from 1). The pad's and the keyboard's orders keep their own places. */
1982const DEMO_KEYS: Record<OrderKind, string> = { touch: storageKey('demoAt'), pad: storageKey('demoAtPad'), keys: storageKey('demoAtKeys') }
1983
1984function savedDemoIndex(kind = demoKind()): number {
1985 try {
1986 const n = Number(localStorage.getItem(DEMO_KEYS[kind]))
1987 return !testing && Number.isInteger(n) && n >= 0 && n < orderFor(kind).length ? n : 0
1988 } catch {
1989 return 0
1990 }
1991}
1992
1993function saveDemoIndex(i: number): void {
1994 if (testing) return
1995 try {
1996 localStorage.setItem(DEMO_KEYS[demoKind()], String(i))
1997 } catch {
1998 // No storage: the demo starts from the beginning next time.
1999 }
2000}
2001
2002/**
2003 * Shuffle (user request, 2026-10-31): the scenes in a random order instead of DEMO_ORDER's, the
2004 * choice kept across visits (also not for a test URL). `scenePlan` is the order they play in: the
2005 * indices into DEMO_ORDER (the pad's: PAD_DEMO_ORDER), in order or shuffled; ◀ ▶ and the automatic next scene follow it.
2006 */
2007const SHUFFLE_KEY = storageKey('demoShuffle')
2008let demoShuffle = ((): boolean => {
2009 try {
2010 return !testing && localStorage.getItem(SHUFFLE_KEY) === '1'
2011 } catch {
2012 return false
2013 }
2014})()
2015let scenePlan: number[] = []
2016
2017/** `scenePlan` made again: in order, or shuffled with `first` (the scene playing) kept at the front. */
2018function makeScenePlan(first: number): void {
2019 scenePlan = demoOrder().map((_, i) => i)
2020 if (!demoShuffle) return
2021 const rest = scenePlan.filter((i) => i !== first)
2022 for (let i = rest.length - 1; i > 0; i--) {
2023 const j = Math.floor(Math.random() * (i + 1))
2024 ;[rest[i], rest[j]] = [rest[j]!, rest[i]!]
2025 }
2026 scenePlan = [first, ...rest]
2027}
2028
2029/** The scene `delta` places from scene `index` in the plan, wrapping round. */
2030function adjacentScene(index: number, delta: number): number {
2031 const n = scenePlan.length
2032 return scenePlan[(scenePlan.indexOf(index) + delta + n) % n]!
2033}
2034
2035function setDemoShuffle(on: boolean): void {
2036 demoShuffle = on
2037 if (!testing) {
2038 try {
2039 localStorage.setItem(SHUFFLE_KEY, on ? '1' : '0')
2040 } catch {
2041 // No storage: it is off again next time.
2042 }
2043 }
2044 const playing = demo?.index ?? 0
2045 makeScenePlan(playing)
2046 shuffleButton.setAttribute('aria-pressed', String(on))
2047 // The scene made ready for next was chosen under the old order.
2048 if (demo) {
2049 demo.upcoming = null
2050 prepareScene(adjacentScene(playing, 1))
2051 demoCount.textContent = `${scenePlan.indexOf(playing) + 1} / ${scenePlan.length}`
2052 }
2053}
2054const demoPanel = document.getElementById('demo')!
2055/** The bars round the view, so every screen sees the same 9 × 13 tiles (render/camera.ts). */
2056const viewMask = document.getElementById('view-mask')!
2057/** The demo's title, on top above the TV (user request, 2026-10-25: title, TV, caption). */
2058const demoTitle = document.getElementById('demo-title')!
2059const demoCount = document.getElementById('demo-count')!
2060/** The bar of the scene's turns, with its moment marked (ui/timeline.ts). */
2061const demoTime = bindTimeline(document.getElementById('demo-time')!)
2062// The demo's buttons: the scene before, the scene after, and back to the first.
2063document.getElementById('demo-prev')!.addEventListener('click', () => demo && skipTo(adjacentScene(demo.index, -1)))
2064document.getElementById('demo-next')!.addEventListener('click', () => demo && skipTo(adjacentScene(demo.index, 1)))
2065function demoRestart(): void {
2066 // Back to the first scene; shuffled, the order is made again from there.
2067 makeScenePlan(0)
2068 skipTo(0)
2069}
2070document.getElementById('demo-restart')!.addEventListener('click', demoRestart)
2071const shuffleButton = document.getElementById('demo-shuffle')!
2072function demoShuffleToggle(): void {
2073 if (demo) setDemoShuffle(!demoShuffle)
2074}
2075shuffleButton.addEventListener('click', demoShuffleToggle)
2076const demoCaption = document.getElementById('demo-caption')!
2077const actionBar = document.getElementById('actions')!
2078const finger = document.getElementById('demo-finger')!
2079const youTag = document.getElementById('demo-you')!
2080/**
2081 * The demo plays on an old CRT, as if someone were playing on a touch-screen TV (user request,
2082 * 2026-10-24). Two layers: LittleJS's post-processing shades the board (the canvas) with a CRT's
2083 * signal — colour fringes, a little static — and #demo-crt lies over the whole
2084 * screen part, board, top bar and buttons alike (they are HTML, which no shader reaches): scan
2085 * lines, a vignette, the tube's rounded glass and its bezel. The finger is in front of the glass.
2086 */
2087const crtScreen = document.getElementById('demo-crt')!
2088/** The CRT's widest shape, width to height: an old TV's 4:3. */
2089const TV_ASPECT = 4 / 3
2090// Colour fringes and faint static. No glow: the board is pale and bright all over, and any glow
2091// (postProcessTV's, or one only past 0.8) washed it out. First, interference (`vacuum` below):
2092// with `interference` above 0 the lines of the picture jump sideways, some far (half as far as at
2093// first: user request, 2026-10-24, too disruptive), and it darkens a little; the static is
2094// `staticNoise`, louder meanwhile.
2095const CRT_SHADER = postProcessEffects(
2096 ` // interference
2097 float _torn = step(.75, fract(sin(floor(p.y / 4.) * 12.9898 + floor(iTime * 24.)) * 43758.5453));
2098 uv.x += interference * (_torn * .015 + .003 * sin(uv.y * 90. + iTime * 70.));
2099 c = texture(iChannel0, uv);
2100 c.rgb *= 1. - .12 * interference;`,
2101 postProcessChromatic(0.0025),
2102 postProcessNoise('staticNoise', 1),
2103)
2104/**
2105 * Now and then, as if someone switched a vacuum cleaner on in the next room and off again (user
2106 * request, 2026-10-24): for half a second (1.6 s was too distracting) the picture tears and
2107 * fills with static, the HTML parts of the screen too (`.interference`, style.css). Every 12 to
2108 * 25 s, the first after 8 s.
2109 */
2110const VACUUM_SECONDS = 0.5
2111let vacuumAt = Infinity
2112
2113/** Called every frame of the demo: the interference's strength now, for the shader and the screen. */
2114function vacuum(): void {
2115 if (vacuumAt === Infinity) vacuumAt = time + 8
2116 if (time > vacuumAt + VACUUM_SECONDS) vacuumAt = time + 12 + Math.random() * 13
2117 // Not with reduced motion: it flickers hard.
2118 const on = time >= vacuumAt && !matchMedia('(prefers-reduced-motion: reduce)').matches
2119 // A motor's buzz isn't steady: the strength flutters while it runs.
2120 const strength = on ? 0.6 + 0.4 * Math.random() : 0
2121 if (postProcess) {
2122 postProcess.values.interference = strength
2123 postProcess.values.staticNoise = 0.03 + 0.3 * strength
2124 }
2125 crtScreen.classList.toggle('interference', on)
2126}
2127/** The finger, or for the mouse demo (Q61) the Cursor Pack's pointer (cursor_none) in its place (style.css `#demo-finger.mouse`). */
2128function drawPointer(): void {
2129 const mouse = demo?.input === 'mouse'
2130 finger.classList.toggle('mouse', mouse)
2131 finger.innerHTML = `<img src="${mouse ? mouseIcon : handIcon}" alt="" draggable="false">`
2132}
2133drawPointer()
2134/** The input button (Q61): names what the demo is shown for, and one click picks the next (ui/demoInput.ts). */
2135const inputButton = document.getElementById('demo-input')!
2136function syncInputButton(): void {
2137 const label = demoInputLabel(demoInput())
2138 if (inputButton.textContent !== label) {
2139 inputButton.textContent = label
2140 // Setting the text removed the pad's picture on the button: put it back.
2141 syncDemoGlyphs()
2142 }
2143}
2144function cycleDemoInput(): void {
2145 if (!demo) return
2146 demoForced = nextDemoInput(demoInput())
2147 syncInputButton()
2148 // Starts the scene again, drawn for the new input (the pad's scenes from their first, as when a pad takes the demo).
2149 skipTo(demo.index)
2150}
2151inputButton.addEventListener('click', cycleDemoInput)
2152/**
2153 * In the demo the action bar sits right under the board, as wide as it, and the caption panel
2154 * apart at the bottom: what you play on together, what explains it separately (user request,
2155 * 2026-10-24). DEMO_GAP: the bar's distance from the board, and the least space above the panel.
2156 */
2157const DEMO_GAP = 8
2158const DEMO_PANEL_GAP = 24
2159
2160/** Places the action bar under the board (a zoomed scene: under the view, the board runs past it), for this frame's camera. */
2161function placeDemoBar(view: { x: number; y: number; width: number; height: number }): void {
2162 const r = mainCanvas.getBoundingClientRect()
2163 const topLeft = worldToScreen(vec2(0, state.height))
2164 const bottomRight = worldToScreen(vec2(state.width, 0))
2165 if (demoZoom > 1) {
2166 actionBar.style.left = `${r.left + view.x}px`
2167 actionBar.style.width = `${view.width}px`
2168 actionBar.style.top = `${r.top + view.y + view.height + DEMO_GAP}px`
2169 return
2170 }
2171 actionBar.style.left = `${r.left + topLeft.x}px`
2172 actionBar.style.width = `${bottomRight.x - topLeft.x}px`
2173 actionBar.style.top = `${r.top + bottomRight.y + DEMO_GAP}px`
2174}
2175/**
2176 * The icon beside each scene's caption (user request, 2026-10-23), so the picture and the idea go
2177 * together: for a power-up or charge, the one it has on the board, in the top bar and the almanac.
2178 */
2179const SCENE_ICONS: Record<SceneKind, string> = {
2180 ...(Object.fromEntries(TEST_KINDS.map((k) => [k, TEST_SCENES[k].icon])) as Record<TestKind, string>),
2181 tapMove: 'icon_move', holdBomb: 'bomb', bombButton: 'bomb', wait: 'icon_wait', autoMove: 'icon_move',
2182 kill: 'bomb', arrows: 'slime_normal_walk_a', escape: 'icon_move', chain: 'bomb', lingering: 'fireball', enemies: 'slime_normal_rest',
2183 coins: 'hud_coin', gem: 'gem_green',
2184 bombPlus: POWER_UP_ICONS.bombPlus, firePlus: POWER_UP_ICONS.firePlus, fusePlus: POWER_UP_ICONS.fusePlus,
2185 run: POWER_UP_ICONS.run, kick: POWER_UP_ICONS.kick, throw: POWER_UP_ICONS.throw, teleport: CHARGE_ICONS.teleport,
2186 diagonal: POWER_UP_ICONS.diagonal, detonate: POWER_UP_ICONS.detonator, wallPass: POWER_UP_ICONS.wallPass,
2187 bombPass: POWER_UP_ICONS.bombPass, flamePass: CHARGE_ICONS.flamePass, star: POWER_UP_ICONS.star,
2188 chargeSwap: CHARGE_ICONS.nuke,
2189 pierce: CHARGE_ICONS.pierce, power: CHARGE_ICONS.power, nuke: CHARGE_ICONS.nuke, shield: CHARGE_ICONS.shield,
2190 freeze: CHARGE_ICONS.freeze, decoy: CHARGE_ICONS.decoy,
2191 exit: 'door_open_top', doorBlast: 'door_closed_top', lastEnemy: 'door_open_top', crate: 'block_planks', pillar: 'block_blue',
2192 rainbowGem: 'gem_green', immortal: 'star',
2193 dieIntoBlast: 'fireball', dieNotFar: 'fireball', dieBlockedIn: 'bomb', dieChain: 'bomb', dieEnemy: 'slime_normal_rest',
2194 dieDetonator: POWER_UP_ICONS.detonator, dieRun: POWER_UP_ICONS.run,
2195 oilSlide: 'oil',
2196 rpg: POWER_UP_ICONS.rpg, proximity: POWER_UP_ICONS.proximity, slide: POWER_UP_ICONS.slide, icbm: CHARGE_ICONS.icbm,
2197 acid: CHARGE_ICONS.acid, rubble: 'rock',
2198 bush: 'grass_a', bushFire: 'grass_a', metal: 'block_metal', oil: 'oil', spikes: 'spikes', barrel: 'block_strong_danger',
2199}
2200
2201
2202/**
2203 * Starts making scene `index` of DEMO_ORDER in the background (ui/scenes.ts), to play once the
2204 * current one ends: the next one, made while this one plays, or one picked with ◀ ▶.
2205 */
2206function prepareScene(index: number): void {
2207 if (!demo) return
2208 const order = demoOrder()
2209 const i = (index + order.length) % order.length
2210 const upcoming: NonNullable<typeof demo.upcoming> = { index: i, scene: undefined }
2211 demo.upcoming = upcoming
2212 const item = order[i]!
2213 // The still scenes play on the room of a sim scene, without its turns.
2214 void makeScene(isStill(item) ? STILL_ROOMS[item] : item, demoSeed++).then((scene) => {
2215 upcoming.scene = scene
2216 })
2217}
2218
2219/**
2220 * The upcoming scene, once made (called each frame until it is). One that couldn't be made (none
2221 * found, rare) is passed over for the one after it.
2222 */
2223function nextScene(): void {
2224 if (!demo) return
2225 // A pad took the demo, or gave it back: the other order of scenes (the pad's from its start, the touch one from its saved place).
2226 const switched = syncDemoInput()
2227 if (switched !== null) return prepareScene(switched)
2228 if (!demo.upcoming) prepareScene(adjacentScene(demo.index, 1))
2229 const { index, scene } = demo.upcoming!
2230 if (scene === undefined) return
2231 if (scene === null) return prepareScene(adjacentScene(index, 1))
2232 const item = demoOrder()[index]!
2233 demo.index = index
2234 saveDemoIndex(index)
2235 demoCount.textContent = `${scenePlan.indexOf(index) + 1} / ${scenePlan.length}`
2236 prepareScene(adjacentScene(index, 1))
2237 resetView()
2238 hover = null
2239 armed = null
2240 demo.scene = scene
2241 demo.next = 0
2242 demo.beats = []
2243 demo.at = time + DEMO_LEAD_SECONDS
2244 demo.cursor = null
2245 demoPad.releaseAll()
2246 demoKeys.releaseAll()
2247 state = scene.start
2248 if (isStill(item)) {
2249 demo.scene = { ...scene, actions: [], gestures: [] }
2250 // The tour's room holds a power-up and a charge, so the top bar's last row has something to show.
2251 if (item === 'topBar') state = { ...state, powers: { ...state.powers, kick: true }, charge: { kind: 'pierce', left: 3 } }
2252 demo.beats = item === 'topBar' ? topBarTour() : item === 'whatIsThat' ? whatIsThat() : isKeyStill(item) ? keyBeats(item) : padBeats(item)
2253 demoTime.set(0, 0)
2254 } else {
2255 demoTime.set(0, scene.actions.length, scene.moment)
2256 const icon = SCENE_ICONS[item]
2257 const { caption, padCaption, keyCaption } = SCENES[item]
2258 if (demo.pad && padCaption) setPadCaption(padCaption, spriteUrl(icon), MONO_ICONS.has(icon))
2259 else if (demo.keys && keyCaption) setCaption(spriteUrl(icon), MONO_ICONS.has(icon), keyCaptionHtml(keyCaption, spriteUrl))
2260 else setCaption(spriteUrl(icon), MONO_ICONS.has(icon), caption)
2261 }
2262 afterChange()
2263 // The finger comes back beside the player, where the eye should be (user request, 2026-10-24).
2264 demo.rest = true
2265}
2266
2267/** The demo's caption: an icon (an image URL; `mono` for a one-colour ink icon) and the words. */
2268function setCaption(icon: string, mono: boolean, text: string): void {
2269 padCaptionNow = null
2270 demoCaption.innerHTML = `<img class="demo-icon${mono ? ' mono' : ''}" src="${icon}" alt="" draggable="false"><span>${text}</span>`
2271}
2272
2273/** The top bar's items the tour points at, in order: the item, its icon, and what to say. */
2274const TOUR: { selector: string; icon: string; text: string }[] = [
2275 { selector: '#hud-lives .hud-item', icon: 'hud_heart', text: 'Your lives. Lose one and the level starts over; lose them all and the run ends.' },
2276 { selector: '#hud-level .hud-item', icon: 'flag_green_a', text: `The level you’re on, out of ${LAST_LEVEL}.` },
2277 { selector: '#hud-enemies .hud-item', icon: 'slime_normal_rest', text: 'Enemies left. Once they’re all gone, the exit opens.' },
2278 { selector: '#hud-coins .hud-item', icon: 'hud_coin', text: 'Your coins.' },
2279 { selector: '#hud-turn .hud-item', icon: 'icon_wait', text: `Turns taken, and the level’s par: clear it within par for ${PAR_BONUS} bonus coins.` },
2280 { selector: '#hud-stats', icon: 'bomb', text: 'How many bombs you can have out at once, how far their blast reaches, and how many turns their fuse lasts.' },
2281 { selector: '#hud-extras', icon: POWER_UP_ICONS.kick, text: 'The power-ups and charges you have. In a game, tap anything up here to read more.' },
2282]
2283const TOUR_SECONDS = 2.6
2284const INFO_SECONDS = 3.2
2285
2286/**
2287 * The top bar's tour: the finger points at each item in turn (no tap: each would open a dialog),
2288 * the item ringed, the caption saying what it is. The ring moves with the finger (user request,
2289 * 2026-10-24: it used to jump ahead): it changes over half-way along the glide, about where the
2290 * finger crosses from one item to the next; for the first item, once the finger is there.
2291 */
2292function topBarTour(): Beat[] {
2293 const beats: Beat[] = TOUR.flatMap(({ selector, icon, text }, i) => {
2294 const el = () => document.querySelector<HTMLElement>(selector)!
2295 const glide = i === 0 ? FINGER_MOVE_SECONDS : FINGER_MOVE_SECONDS / 2
2296 return [
2297 {
2298 run: () => {
2299 const r = el().getBoundingClientRect()
2300 placeFinger(vec2(r.left + r.width / 2, r.bottom - 4))
2301 },
2302 then: glide,
2303 },
2304 { run: () => (spotlight(el()), setCaption(spriteUrl(icon), MONO_ICONS.has(icon), text)), then: TOUR_SECONDS - glide },
2305 ]
2306 })
2307 setCaption(spriteUrl(TOUR[0]!.icon), false, 'The top bar.')
2308 return [...beats, { run: () => (spotlight(null), finger.classList.add('away')), then: 0.6 }]
2309}
2310
2311/** Rings one element of the page for the tour, or (null) none. */
2312function spotlight(el: HTMLElement | null): void {
2313 for (const lit of document.querySelectorAll('.demo-spot')) lit.classList.remove('demo-spot')
2314 el?.classList.add('demo-spot')
2315}
2316
2317/**
2318 * "What is that?": the finger taps the ? button, then an enemy, and the real info dialog opens
2319 * (main.ts `inspect`, `tapTile`); after a while it taps Got it. In the demo the dialog opens over
2320 * the scene only (dialog.ts `setDialogInDemo`).
2321 */
2322function whatIsThat(): Beat[] {
2323 setCaption(questionIcon, true, 'Not sure what something is? Tap ?, then tap it: an enemy, a tile, a bomb, anything.')
2324 const button = document.getElementById('info-button')!
2325 const enemy = state.enemies[0]!.pos
2326 return [
2327 { run: () => placeFinger(centreOf(button)), then: FINGER_MOVE_SECONDS },
2328 { run: fingerDown, then: TAP_SECONDS },
2329 { run: () => (fingerUp(), inspect()), then: PICKED_SECONDS },
2330 { run: () => placeFinger(tilePoint(enemy)), then: FINGER_MOVE_SECONDS },
2331 { run: fingerDown, then: TAP_SECONDS },
2332 { run: () => (fingerUp(), tapTile(enemy)), then: INFO_SECONDS },
2333 { run: () => placeFinger(centreOf(document.querySelector('#dialog-buttons button:last-child')!)), then: FINGER_MOVE_SECONDS },
2334 { run: fingerDown, then: TAP_SECONDS },
2335 { run: () => (fingerUp(), closeDialog(), finger.classList.add('away')), then: DEMO_HOLD_SECONDS },
2336 ]
2337}
2338
2339/**
2340 * The demo for a gamepad (Q58): the virtual controller (ui/padOverlay.ts) lights the buttons that
2341 * play each turn, and a dashed cursor on the board shows where the d-pad has put it, instead of the
2342 * finger. These beats call the game's own functions directly, never the pad's handlers (which
2343 * would end the demo, as a real press does).
2344 */
2345// A press stays lit PAD_LIT_SECONDS (ui/padOverlay.ts) and lets go before the next one starts.
2346
2347/** The pad demo's cursor on `tile`, over the tiles it can rest on (null: not shown). */
2348function demoCursorAt(tile: Vec | null): void {
2349 if (demo) demo.cursor = tile ? { tile, tiles: actionableTiles(state), actions: tileActions(state, tile) } : null
2350}
2351
2352/** A press lit on the virtual controller, for `seconds` (default PAD_LIT_SECONDS). */
2353function showPress(press: PadPress, seconds?: number): void {
2354 const { part, stickDir } = pressPart(press)
2355 demoPad.press(part, seconds, stickDir)
2356}
2357
2358/** A d-pad press as two beats: the button lit and let go, then the cursor moves (`move`) and rests PAD_AFTER_SECONDS (or `rest`) so it can be seen. */
2359function padHopBeats(press: PadPress, move: () => void, rest = PAD_AFTER_SECONDS): Beat[] {
2360 return [{ run: () => showPress(press), then: PAD_LIT_SECONDS }, { run: move, then: rest }]
2361}
2362
2363/** The pad version of a turn (padDemo.ts `padPlan`): the cursor on the player, d-pad hops to the tile, the face button, then the turn. Null: the pad cannot show it (a dialog's choice), so the turn is just played. */
2364function padBeatsFor(g: Gesture, action: Action, last: boolean): Beat[] | null {
2365 const plan = padPlan(state, action, g)
2366 if (!plan) return null
2367 const beats: Beat[] = [{ run: () => demoCursorAt(state.player), then: 0.4 }]
2368 const play = playBeat(action, last)
2369 for (const { press, cursor: at } of plan) {
2370 if (press.kind !== 'face') {
2371 beats.push(...padHopBeats(press, () => demoCursorAt(at)))
2372 continue
2373 }
2374 const el = actionBar.querySelector<HTMLElement>(`[data-action=${press.button}]`)
2375 beats.push({ run: () => (showPress(press), el?.classList.add('pressed')), then: PAD_LIT_SECONDS })
2376 beats.push({ ...play, run: () => (el?.classList.remove('pressed'), play.run()) })
2377 }
2378 return beats
2379}
2380
2381/** The pad caption on show (a scene's text with `{...}` pictures), kept to draw again if the kind of pad becomes known mid-scene. */
2382let padCaptionNow: { text: string; icon: string; mono: boolean } | null = null
2383
2384function setPadCaption(text: string, icon: string, mono = false): void {
2385 setCaption(icon, mono, padCaptionHtml(text, demoFamily(), spriteUrl))
2386 padCaptionNow = { text, icon, mono }
2387}
2388
2389/** The real menu over the scene, with the run's own state in it (it names the level, turn and seed), not the scene's. */
2390function menuOverScene(): void {
2391 const scene = state
2392 state = demo!.real
2393 showMenu()
2394 state = scene
2395}
2396
2397/** The looking-around scenes (stick, look): the room shown this many times larger than the view, and how long a stick push is lit while the view slides. */
2398const LOOK_ZOOM = 2
2399const LOOK_SECONDS = PAD_LIT_SECONDS + 0.4
2400
2401/** The scripts of the pad's own scenes, on a still room. */
2402function padBeats(item: Exclude<StillItem, 'topBar' | 'whatIsThat' | KeyStill>): Beat[] {
2403 const move = spriteUrl('icon_move')
2404 const hold: Beat = { run: () => demoCursorAt(null), then: DEMO_HOLD_SECONDS }
2405 const start: Beat = { run: () => demoCursorAt(state.player), then: 0.6 }
2406 const hops = (count: number): Beat[] =>
2407 padHops(state, count).flatMap(({ press, to }) => padHopBeats(press, () => demoCursorAt(to), 0.5))
2408 if (item === 'padCursor') {
2409 setPadCaption(PAD_STILL_CAPTIONS.padCursor, move)
2410 return [start, ...hops(5), hold]
2411 }
2412 if (item === 'padL1') {
2413 setPadCaption(PAD_STILL_CAPTIONS.padL1, move)
2414 return [start, ...hops(2), { run: () => showPress({ kind: 'shoulder', side: 'l' }), then: PAD_LIT_SECONDS }, { run: () => demoCursorAt(state.player), then: 1.2 }, hold]
2415 }
2416 if (item === 'padR1') {
2417 setPadCaption(PAD_STILL_CAPTIONS.padR1, questionIcon, true)
2418 const enemy = state.enemies[0]!.pos
2419 // The d-pad direction toward the enemy: the aim jumps between the tiles that have something to explain.
2420 const ex = enemy.x - state.player.x
2421 const ey = enemy.y - state.player.y
2422 const toward: PadPress = Math.abs(ex) >= Math.abs(ey) ? { kind: 'dir', dx: ex < 0 ? -1 : 1, dy: 0 } : { kind: 'dir', dx: 0, dy: ey < 0 ? -1 : 1 }
2423 return [
2424 { run: () => (showPress({ kind: 'shoulder', side: 'r' }), inspect()), then: PAD_LIT_SECONDS },
2425 ...padHopBeats(toward, () => (demo!.cursor = { tile: enemy, tiles: explainableTiles(state), actions: [] }), 0.5),
2426 { run: () => showPress({ kind: 'face', button: 'move' }), then: PAD_LIT_SECONDS },
2427 { run: () => (demoCursorAt(null), tapTile(enemy)), then: INFO_SECONDS },
2428 { run: () => (showPress({ kind: 'face', button: 'wait' }), closeDialog()), then: DEMO_HOLD_SECONDS },
2429 ]
2430 }
2431 if (item === 'padStick' || item === 'padLook') {
2432 // The demo shows a room whole, so these two zoom in (updateCamera's `zoom`): the stick then slides the view like it does in play.
2433 demoZoom = LOOK_ZOOM
2434 const look = (dir: 'left' | 'right' | 'up' | 'down', dx: number, dy: number): Beat => ({
2435 run: () => (showPress({ kind: 'stick', dir }, LOOK_SECONDS), panView(dx, dy), endPan()),
2436 then: LOOK_SECONDS + 0.3,
2437 })
2438 // Pressing the stick in takes the view back to the player.
2439 const back: Beat = { run: () => (showPress({ kind: 'stick', dir: 'press' }), resetPan()), then: DEMO_HOLD_SECONDS }
2440 if (item === 'padStick') {
2441 setPadCaption(PAD_STILL_CAPTIONS.padStick, move)
2442 const n = Math.ceil(state.width / (2 * LOOK_ZOOM))
2443 return [{ ...start, run: () => demoCursorAt(null) }, look('left', -n, 0), look('right', 2 * n, 0), look('up', 0, n), look('down', 0, -2 * n), back]
2444 }
2445 // padLook: toward the enemy, a stick push for each way (the view stays where the room lets it).
2446 setPadCaption(PAD_STILL_CAPTIONS.padLook, questionIcon, true)
2447 const enemy = state.enemies[0]!.pos
2448 const ex = enemy.x - state.player.x
2449 const ey = enemy.y - state.player.y
2450 return [
2451 { ...start, run: () => demoCursorAt(null) },
2452 ...(ex ? [look(ex < 0 ? 'left' : 'right', ex, 0)] : []),
2453 ...(ey ? [look(ey < 0 ? 'up' : 'down', 0, -ey)] : []),
2454 back,
2455 ]
2456 }
2457 // padStart: the real menu opens over the scene (the run's own state in it, not the scene's) and closes again.
2458 setPadCaption(PAD_STILL_CAPTIONS.padStart, spriteUrl('icon_wait'))
2459 return [
2460 { run: () => (showPress({ kind: 'start' }), menuOverScene()), then: 2.4 },
2461 { run: () => (showPress({ kind: 'start' }), closeDialog()), then: DEMO_HOLD_SECONDS },
2462 ]
2463}
2464
2465/** A key lit on the key overlay, for `seconds` (default PAD_LIT_SECONDS): a Shift + arrow lights both. */
2466function showKey(press: KeyPress, seconds?: number): void {
2467 for (const part of pressParts(press)) demoKeys.press(part, seconds)
2468}
2469
2470/** The keyboard's cursor hop as two beats, like the pad's: the keys lit, then the cursor moves (null: put away) and rests so it can be seen. */
2471function keyHopBeats(press: KeyPress, cursor: Vec | null, rest = PAD_AFTER_SECONDS): Beat[] {
2472 return [{ run: () => showKey(press), then: PAD_LIT_SECONDS }, { run: () => demoCursorAt(cursor), then: rest }]
2473}
2474
2475/**
2476 * The keyboard version of a turn (keyDemo.ts `keyPlan`): the fewest key presses that play it, each
2477 * lit on the key overlay; the action bar's button is marked with its key, a picked key shows its
2478 * tiles, and Shift + arrow shows the dashed cursor. Null: the keys cannot show it (a dialog's choice), so the turn is just played.
2479 */
2480function keyBeatsFor(g: Gesture, action: Action, last: boolean): Beat[] | null {
2481 const plan = keyPlan(state, action, g)
2482 if (!plan) return null
2483 const play = playBeat(action, last)
2484 const beats: Beat[] = [{ run: () => demoCursorAt(null), then: 0.3 }]
2485 for (const [i, { press, cursor, picked }] of plan.entries()) {
2486 const el = press.kind === 'key' ? actionBar.querySelector<HTMLElement>(`[data-action=${press.button}]`) : null
2487 if (i === plan.length - 1) {
2488 beats.push({ run: () => (showKey(press), el?.classList.add('pressed')), then: PAD_LIT_SECONDS })
2489 beats.push({ ...play, run: () => (el?.classList.remove('pressed'), play.run()) })
2490 } else if (press.kind === 'hop') {
2491 beats.push(...keyHopBeats(press, cursor))
2492 } else {
2493 // An action key picked: its button marked, then its tiles light up.
2494 beats.push({ run: () => (showKey(press), el?.classList.add('pressed')), then: PAD_LIT_SECONDS })
2495 beats.push({ run: () => (el?.classList.remove('pressed'), (selected = picked), afterChange()), then: PICKED_SECONDS })
2496 }
2497 }
2498 return beats
2499}
2500
2501/** The scripts of the keyboard's own scenes, on a still room. */
2502function keyBeats(item: KeyStill): Beat[] {
2503 const move = spriteUrl('icon_move')
2504 const text = KEY_STILL_CAPTIONS[item]
2505 const hold: Beat = { run: () => demoCursorAt(null), then: DEMO_HOLD_SECONDS }
2506 const caption = (icon: string, mono = false): void => setCaption(icon, mono, keyCaptionHtml(text, spriteUrl))
2507 if (item === 'keyCursor') {
2508 caption(move)
2509 const hops = padHops(state, 5).flatMap(({ press, to }) => (press.kind === 'dir' ? [{ press: { kind: 'hop', dx: press.dx, dy: press.dy } as KeyPress, to }] : []))
2510 // The first Shift + arrow only shows the cursor, on you; the next ones hop it.
2511 const wake = hops[0] ? keyHopBeats(hops[0].press, state.player, 0.5) : []
2512 return [...wake, ...hops.flatMap(({ press, to }) => keyHopBeats(press, to, 0.5)), hold]
2513 }
2514 if (item === 'keyInspect') {
2515 caption(questionIcon, true)
2516 const enemy = state.enemies[0]!.pos
2517 // The arrow toward the enemy: the aim jumps between the tiles that have something to explain.
2518 const ex = enemy.x - state.player.x
2519 const ey = enemy.y - state.player.y
2520 const toward: KeyPress = Math.abs(ex) >= Math.abs(ey) ? { kind: 'arrow', dx: ex < 0 ? -1 : 1, dy: 0 } : { kind: 'arrow', dx: 0, dy: ey < 0 ? -1 : 1 }
2521 return [
2522 { run: () => (showKey({ kind: 'inspect' }), inspect()), then: PAD_LIT_SECONDS },
2523 { run: () => showKey(toward), then: PAD_LIT_SECONDS },
2524 { run: () => (demo!.cursor = { tile: enemy, tiles: explainableTiles(state), actions: [] }), then: 0.5 },
2525 { run: () => showKey({ kind: 'enter' }), then: PAD_LIT_SECONDS },
2526 { run: () => (demoCursorAt(null), tapTile(enemy)), then: INFO_SECONDS },
2527 { run: () => (showKey({ kind: 'esc' }), closeDialog()), then: DEMO_HOLD_SECONDS },
2528 ]
2529 }
2530 if (item === 'keyLook') {
2531 // Zoomed in, like the stick's scenes: Alt + arrow slides the view like it does in play.
2532 demoZoom = LOOK_ZOOM
2533 caption(move)
2534 const n = Math.ceil(state.width / (2 * LOOK_ZOOM))
2535 const look = (dx: Step, dy: Step, tx: number, ty: number): Beat => ({
2536 run: () => (showKey({ kind: 'pan', dx, dy }, LOOK_SECONDS), panView(tx, ty), endPan()),
2537 then: LOOK_SECONDS + 0.3,
2538 })
2539 return [
2540 { run: () => demoCursorAt(null), then: 0.6 },
2541 look(-1, 0, -n, 0), look(1, 0, 2 * n, 0), look(0, -1, 0, n), look(0, 1, 0, -2 * n),
2542 // Home takes the view back to the player.
2543 { run: () => (showKey({ kind: 'home' }), resetPan()), then: DEMO_HOLD_SECONDS },
2544 ]
2545 }
2546 // keyMenu: the real menu opens over the scene (the run's own state in it, not the scene's) and closes again.
2547 caption(spriteUrl('icon_wait'))
2548 return [
2549 { run: () => (showKey({ kind: 'esc' }), menuOverScene()), then: 2.4 },
2550 { run: () => (showKey({ kind: 'esc' }), closeDialog()), then: DEMO_HOLD_SECONDS },
2551 ]
2552}
2553
2554/**
2555 * The input changed under the demo (a pad or the keyboard took it, or a mouse, touch or key gave
2556 * it back): the other order of scenes (the pad's and the keyboard's from their first, the touch one
2557 * from where it was kept), the controller or key overlay shown, or the finger. Returns the scene to
2558 * start with, or null if nothing changed. Only between scenes (nextScene, skipTo), never mid-script.
2559 */
2560function syncDemoInput(): number | null {
2561 if (!demo) return null
2562 const input = demoInput()
2563 const pad = isPadInput(input)
2564 const keys = isKeysInput(input)
2565 // The same kind of scenes: the finger becomes the mouse's pointer (or back), or the controller takes another family.
2566 if (demo.input !== input) {
2567 demo.input = input
2568 drawPointer()
2569 syncDemoFamily()
2570 }
2571 if (demo.pad === pad && demo.keys === keys) return null
2572 demo.pad = pad
2573 demo.keys = keys
2574 demo.cursor = null
2575 demoPad.releaseAll()
2576 demoPad.el.hidden = !pad
2577 demoKeys.releaseAll()
2578 demoKeys.el.hidden = !keys
2579 finger.classList.add('away')
2580 const at = pad || keys ? 0 : savedDemoIndex('touch')
2581 makeScenePlan(at)
2582 demo.index = at
2583 demo.upcoming = null
2584 demoCount.textContent = `${scenePlan.indexOf(at) + 1} / ${scenePlan.length}`
2585 return at
2586}
2587
2588/**
2589 * The demo: when the game opens, or from the menu; from where it was last time (user request,
2590 * 2026-10-24), its ↺ button back to the start.
2591 */
2592function startDemo(): void {
2593 const input = demoInput()
2594 const pad = isPadInput(input)
2595 const keys = isKeysInput(input)
2596 const at = savedDemoIndex(orderKind(pad, keys))
2597 demo = { scene: null, index: at, upcoming: null, next: 0, at: 0, real: state, beats: [], rest: false, pad, keys, input, cursor: null }
2598 drawPointer()
2599 makeScenePlan(at)
2600 shuffleButton.setAttribute('aria-pressed', String(demoShuffle))
2601 prepareScene(at)
2602 document.body.classList.add('demo')
2603 demoPanel.hidden = false
2604 demoTitle.hidden = false
2605 demoPad.el.hidden = !pad
2606 demoKeys.el.hidden = !keys
2607 syncDemoFamily()
2608 syncInputButton()
2609 finger.hidden = false
2610 finger.classList.add('away')
2611 youTag.hidden = false
2612 crtScreen.hidden = false
2613 setDialogInDemo(true)
2614 if (postProcess) postProcess.enabled = true
2615 nextScene()
2616}
2617
2618/** Called every frame: the script's next beat when it is due, the next turn's script, or the next scene. */
2619function demoTick(): void {
2620 // The button's name follows the input in use.
2621 if (demo) syncInputButton()
2622 if (!demo || time < demo.at) return
2623 if (!demo.beats.length) {
2624 if (!demo.scene || demo.next >= demo.scene.actions.length) return nextScene()
2625 const i = demo.next++
2626 const [g, action, last] = [demo.scene.gestures[i]!, demo.scene.actions[i]!, demo.next === demo.scene.actions.length] as const
2627 // For a pad or the keyboard: its presses; a turn it cannot show (a dialog's choice) is just played.
2628 demo.beats = demo.pad
2629 ? (padBeatsFor(g, action, last) ?? [playBeat(action, last)])
2630 : demo.keys ? (keyBeatsFor(g, action, last) ?? [playBeat(action, last)]) : beatsFor(g, action, last)
2631 // The bar fills over the turn's beats, so it moves all the time rather than a turn at a time.
2632 demoTime.set(demo.next, demo.scene.actions.length, demo.scene.moment, demo.beats.reduce((sum, b) => sum + b.then, 0))
2633 }
2634 const beat = demo.beats.shift()!
2635 beat.run()
2636 demo.at = time + beat.then
2637}
2638
2639/** The beat that plays a turn, after the finger (or the pad's cursor) has done its part. */
2640function playBeat(action: Action, last: boolean): Beat {
2641 return {
2642 run: () => {
2643 fingerUp()
2644 hover = null
2645 armed = null
2646 selected = null
2647 if (demo) demo.cursor = null
2648 animateTurn(step(state, action), action)
2649 afterChange()
2650 if (last) finger.classList.add('away')
2651 },
2652 then: last ? DEMO_HOLD_SECONDS : DEMO_SETTLE_SECONDS,
2653 }
2654}
2655
2656/**
2657 * The script for one turn: the finger plays its gesture, as the real controls take it (main.ts
2658 * pointer handlers, `tapTile`, `pick`), and the turn is played when the finger lets go.
2659 */
2660function beatsFor(g: Gesture, action: Action, last: boolean): Beat[] {
2661 const play = playBeat(action, last)
2662 /** The finger to a tile and down on it, then (with `press`) whatever pressing it shows. */
2663 const onTile = (tile: Vec, press = () => {}, hold = TAP_SECONDS): Beat[] => [
2664 { run: () => placeFinger(tilePoint(tile)), then: FINGER_MOVE_SECONDS },
2665 { run: () => (fingerDown(), (hover = tile), press()), then: hold },
2666 ]
2667 if (g.kind === 'tap') return [...onTile(g.tile), play]
2668 if (g.kind === 'hold') {
2669 // A long press: held still, it arms the bomb (the preview shows), placed on release.
2670 return [...onTile(g.tile, ringUnderFinger, LONG_PRESS_MS / 1000), { run: () => (armed = g.tile), then: 0.6 }, play]
2671 }
2672 if (g.kind === 'walk') {
2673 // A far tile: auto-move's way shows, and its first move is made.
2674 return [...onTile(g.tile, () => ((destination = g.tile), afterChange())), play]
2675 }
2676 if (g.kind === 'pick') {
2677 // A test scene's answer to the dialog that opens (a gem's choice, or a charge's): shown, then taken.
2678 // After a pause, so the turn before (a slide) is seen to come to rest; between two it is the
2679 // settle after the one closing.
2680 return [
2681 { run: () => {}, then: 1.2 },
2682 { run: () => (state.offer ? openOffer(state, state.offer, () => {}) : openChargeOffer(state, () => {})), then: 2.4 },
2683 { ...play, run: () => (closeDialog(), play.run()) },
2684 ]
2685 }
2686 if (g.kind === 'choice') {
2687 // The charge picked up asks, as in play: the dialog opens over the scene (not dimming the
2688 // caption), and the finger taps Take (swap) or Keep.
2689 return [
2690 { run: () => openChargeOffer(state, () => {}), then: INFO_SECONDS },
2691 { run: () => placeFinger(centreOf(document.querySelector(`#dialog-buttons button:${g.swap ? 'last' : 'first'}-child`)!)), then: FINGER_MOVE_SECONDS },
2692 { run: fingerDown, then: TAP_SECONDS },
2693 { ...play, run: () => (closeDialog(), play.run()) },
2694 ]
2695 }
2696 const button = g.kind === 'go' ? 'move' : g.button
2697 const el = actionBar.querySelector<HTMLElement>(`[data-action=${button}]`)!
2698 const pressButton: Beat[] = [
2699 { run: () => placeFinger(centreOf(el)), then: FINGER_MOVE_SECONDS },
2700 { run: () => (fingerDown(), el.classList.add('pressed')), then: TAP_SECONDS },
2701 ]
2702 const release = () => el.classList.remove('pressed')
2703 if (g.kind === 'go' || !g.tile) return [...pressButton, { ...play, run: () => (release(), play.run()) }]
2704 // A button, then its tile: picked, the tiles it can use light up; the finger moves on to one.
2705 const tile = g.tile
2706 return [
2707 ...pressButton,
2708 { run: () => (release(), fingerUp(), (selected = g.button), afterChange()), then: PICKED_SECONDS },
2709 ...onTile(tile),
2710 play,
2711 ]
2712}
2713
2714/** Where a tile's centre is on screen, in CSS pixels. */
2715function tilePoint(p: Vec) {
2716 const r = mainCanvas.getBoundingClientRect()
2717 return worldToScreen(toWorld(state, p)).add(vec2(r.left, r.top))
2718}
2719
2720// For tools/screenshots.mjs, which taps tiles: the board scrolls, so only the game knows where one is.
2721;(window as unknown as { tilePoint: (x: number, y: number) => Vec }).tilePoint = (x, y) => {
2722 const p = tilePoint({ x, y })
2723 return { x: p.x, y: p.y }
2724}
2725
2726function centreOf(el: Element) {
2727 const r = el.getBoundingClientRect()
2728 return vec2(r.left + r.width / 2, r.top + r.height / 2)
2729}
2730
2731/**
2732 * Each frame of the demo: the "You" tag over the player, wherever they are drawn (it's not
2733 * obvious which of the moving figures is you; user request, 2026-10-24), and at a scene's start
2734 * the finger, appearing just below and right of the player, so the eye starts where the action is.
2735 */
2736function placeDemoMarks(): void {
2737 if (!demo) return
2738 // Hugging the top of the player's head, always over them (user request, 2026-10-24).
2739 const tip = worldToScreen(playerPos(state, anim.playerMove).add(vec2(0, 0.4)))
2740 const r = mainCanvas.getBoundingClientRect()
2741 youTag.style.transform = `translate(${r.left + tip.x}px, ${r.top + tip.y}px)`
2742 if (!demo.rest) return
2743 demo.rest = false
2744 // The pad's and the keyboard's demos have no finger (the controller or keys and the cursor show the way).
2745 if (demo.pad || demo.keys) return
2746 placeFinger(worldToScreen(toWorld(state, state.player).add(vec2(0.55, -0.55))).add(vec2(r.left, r.top)), false)
2747 finger.classList.remove('away')
2748}
2749
2750/** The finger's tip to `at`: gliding there, or (`glide` false) at once. */
2751function placeFinger(at: { x: number; y: number }, glide = true): void {
2752 finger.classList.toggle('glide', glide)
2753 finger.style.transform = `translate(${at.x}px, ${at.y}px)`
2754}
2755
2756/** The finger presses: it sinks a little, and a ripple spreads where it touched. */
2757function fingerDown(): void {
2758 finger.classList.add('down')
2759 const ripple = placeMark('demo-ripple')
2760 ripple.addEventListener('animationend', () => ripple.remove(), { once: true })
2761}
2762
2763/** The finger lifts, and any long press's ring goes. */
2764function fingerUp(): void {
2765 finger.classList.remove('down')
2766 for (const ring of document.querySelectorAll('.demo-ring')) ring.remove()
2767}
2768
2769/** A long press: a ring closes in on the finger while it holds. */
2770function ringUnderFinger(): void {
2771 placeMark('demo-ring').style.setProperty('--hold', `${LONG_PRESS_MS}ms`)
2772}
2773
2774/** A mark under the finger: a ripple or a ring. */
2775function placeMark(className: string): HTMLElement {
2776 const mark = document.createElement('div')
2777 mark.className = className
2778 mark.style.transform = finger.style.transform
2779 document.body.append(mark)
2780 return mark
2781}
2782
2783/**
2784 * ◀ ▶ (or ← →), and ↺ (0): leaves the scene playing for scene `index`, as soon as it is made.
2785 * Anything under way stops: the finger's script, a dialog, the tour's ring.
2786 */
2787function skipTo(index: number): void {
2788 if (!demo) return
2789 // A pad took the demo, or gave it back: the other order (the pad's from its start, the touch one from its saved place).
2790 const start = syncDemoInput() ?? index
2791 demo.beats = []
2792 demo.scene = null
2793 demo.at = 0
2794 demoTime.set(0, 0)
2795 if (dialogOpen()) closeDialog()
2796 spotlight(null)
2797 fingerUp()
2798 for (const b of actionBar.querySelectorAll('.pressed')) b.classList.remove('pressed')
2799 finger.classList.add('away')
2800 // The pad scenes' leftovers: a lit button, the cursor, "What is that?" left on.
2801 demoPad.releaseAll()
2802 demoKeys.releaseAll()
2803 demo.cursor = null
2804 inspecting = false
2805 // The looking-around scenes' zoom and pan.
2806 demoZoom = 1
2807 resetPan()
2808 prepareScene(start)
2809}
2810
2811function endDemo(): void {
2812 if (!demo) return
2813 state = demo.real
2814 demo = null
2815 // A pick of the input to show lasts only as long as the demo.
2816 demoForced = null
2817 // The pad's R1 scene calls inspect(), and a press that ends the demo can come inside its window.
2818 inspecting = false
2819 demoPad.releaseAll()
2820 demoPad.el.hidden = true
2821 demoKeys.releaseAll()
2822 demoKeys.el.hidden = true
2823 document.body.classList.remove('demo')
2824 demoPanel.hidden = true
2825 demoTitle.hidden = true
2826 finger.hidden = true
2827 youTag.hidden = true
2828 if (dialogOpen()) closeDialog()
2829 setDialogInDemo(false)
2830 crtScreen.hidden = true
2831 crtScreen.classList.remove('interference')
2832 vacuumAt = Infinity
2833 if (postProcess) postProcess.enabled = false
2834 spotlight(null)
2835 for (const mark of document.querySelectorAll('.demo-ripple, .demo-ring')) mark.remove()
2836 for (const b of actionBar.querySelectorAll('.pressed')) b.classList.remove('pressed')
2837 actionBar.style.left = actionBar.style.width = actionBar.style.top = ''
2838 hover = null
2839 armed = null
2840 resetView()
2841 afterChange()
2842 // A run resumed with a choice still open (a gem's, a charge's, the shop): the demo closed its
2843 // dialog, so it opens again.
2844 showOffer()
2845 showChargeOffer()
2846 if (state.shop) openShopDialog()
2847 // What changed since the player last played, once the demo is out of the way.
2848 // After the demo's own dialog has finished closing (dialog.ts); not over a choice that reopened.
2849 if (news.length) {
2850 setTimeout(() => {
2851 if (dialogOpen()) return
2852 openChanges(news, true)
2853 news = []
2854 }, 250)
2855 }
2856}
2857
2858// The tap or key that ends the demo goes no further: not to the board, nor to a button that
2859// appears under the finger once the demo is gone (its click is swallowed too).
2860let swallowClick = 0
2861addEventListener(
2862 'pointerdown',
2863 (e) => {
2864 // The demo's own buttons (◀ ▶ ↺) work, and keep it going.
2865 if (!demo || (e.target as Element).closest?.('.demo-nav')) return
2866 endDemo()
2867 swallowClick = performance.now() + 600
2868 e.stopPropagation()
2869 e.preventDefault()
2870 },
2871 true,
2872)
2873for (const type of ['pointerup', 'click'] as const) {
2874 addEventListener(
2875 type,
2876 (e) => {
2877 if (performance.now() > swallowClick) return
2878 e.stopPropagation()
2879 e.preventDefault()
2880 },
2881 true,
2882 )
2883}
2884addEventListener(
2885 'keydown',
2886 (e) => {
2887 if (!demo) return
2888 // The game never sees a key while the demo plays. Only a click or tap ends it (user request,
2889 // 2026-10-31: Ctrl or Shift started the game); the keys that work the demo's own buttons
2890 // (Tab, Enter, Space on one) keep their default.
2891 e.stopPropagation()
2892 if (!(['Tab', 'Enter', 'Space'].includes(e.code) && (e.target as Element).closest?.('.demo-nav'))) e.preventDefault()
2893 // Escape, or Enter not on one of the demo's buttons, ends it, so a player with no pointer can play.
2894 if (e.code === 'Escape' || (e.code === 'Enter' && !(e.target as Element).closest?.('.demo-nav'))) return endDemo()
2895 // ← and → go through the scenes.
2896 if ((e.key === 'ArrowLeft' || e.key === 'ArrowRight') && !e.repeat) {
2897 skipTo(adjacentScene(demo.index, e.key === 'ArrowLeft' ? -1 : 1))
2898 }
2899 },
2900 true,
2901)
2902
2903// What came after the version the player last played, shown when the demo ends (endDemo). Test
2904// URLs neither show it nor change what is remembered.
2905let news = testing ? [] : newSinceLastVisit()
2906if (!testing || params.has('demo')) startDemo()
2907
2908// LittleJS's development build writes its version and frame rate top left; the top bar used to
2909// cover it, but the demo hides the bar (only the development build has it at all).
2910setDebugWatermark(false)
2911// Sharp on high-density screens, capped so phones don't pay for 3×.
2912setCanvasPixelRatio(Math.min(devicePixelRatio, Number(params.get('ratio')) || 2))
2913// WebGL. It was Canvas 2D from 2026-10-19 to 2026-10-24, because screenshots of the WebGL canvas
2914// showed a black strip that isn't in the drawing; that was Playwright's headless shell, and the
2915// screenshots now use the full Chromium (decisions.md).
2916setGLEnable(params.get('gl') !== '0')
2917// No antialiasing: on an Android phone, Chrome ran the game at 12 fps with it and 60 without
2918// (user's measurements, 2026-10-24); the board is sprites on a square grid, which it hardly smooths.
2919// Speed experiments, with the frame-rate meter (`?fps`): `?aa` turns antialiasing back on,
2920// `?gl=0` draws with Canvas 2D (no demo CRT, no clips), `?ratio=1` at 1 pixel per CSS pixel.
2921glSetAntialias(params.has('aa'))
2922// The sprites are smooth vector-style art drawn smaller than their 128 px, not pixel art.
2923setTilesPixelated(false)
2924
2925/**
2926 * A frame's work, made safe: an error is reported (once while it repeats) and the game goes on.
2927 * LittleJS's development build stops its loop at the first error, which froze the game on a
2928 * phone with the player half-drawn (user report, 2026-10-23).
2929 */
2930function guarded(where: string, frame: () => void): () => void {
2931 let last = ''
2932 return () => {
2933 try {
2934 frame()
2935 last = ''
2936 } catch (error) {
2937 if (String(error) !== last) reportError(where, error)
2938 last = String(error)
2939 }
2940 }
2941}
2942
2943await engineInit(
2944 () => {
2945 setShowSplashScreen(false)
2946 initFeedback()
2947 // Post-processing: the CRT in the demo, and off in play. A pass-through in play cost a copy of
2948 // the whole frame every frame, which Android Chrome ran at under 1 fps (user report, 2026-10-24).
2949 const crt = new PostProcessPlugin(CRT_SHADER)
2950 crt.values = { interference: 0, staticNoise: 0.03 }
2951 crt.enabled = !!demo
2952 setCanvasClearColor(BACKGROUND)
2953 // Pressing shows the preview on the tile under the finger; releasing commits to it.
2954 // Sliding to another tile before letting go changes the choice. Holding still on a tile
2955 // next to the player, with nothing picked, arms a bomb there instead (placed on release).
2956 mainCanvas.addEventListener('pointerdown', (e) => {
2957 mouseHover = null
2958 hover = tileUnder(e)
2959 drag = { x: e.clientX, y: e.clientY, panning: false }
2960 startPress(hover)
2961 })
2962 mainCanvas.addEventListener('pointermove', (e) => {
2963 if (e.pointerType !== 'mouse' && !e.buttons) return
2964 // With nothing picked, a drag moves the view (user request, 2026-10-25: a swipe to look
2965 // round the board teleported instead); letting go then does nothing. With an action picked,
2966 // sliding still changes the tile chosen, as before.
2967 if (e.buttons && drag && !selected && !demo) {
2968 const moved = Math.hypot(e.clientX - drag.x, e.clientY - drag.y)
2969 if (drag.panning || moved > DRAG_PIXELS) {
2970 if (!drag.panning) startPress(null)
2971 drag.panning = true
2972 hover = null
2973 panView(-(e.clientX - drag.x) / cameraScale, (e.clientY - drag.y) / cameraScale)
2974 drag.x = e.clientX
2975 drag.y = e.clientY
2976 return
2977 }
2978 }
2979 const p = tileUnder(e)
2980 if (e.buttons && !(p && pressTile && eq(p, pressTile))) startPress(null)
2981 hover = p
2982 mouseHover = e.pointerType === 'mouse' && !e.buttons ? p : null
2983 })
2984 mainCanvas.addEventListener('pointerup', (e) => {
2985 const panned = drag?.panning
2986 drag = null
2987 endPan()
2988 if (panned) return startPress(null)
2989 const p = tileUnder(e)
2990 if (e.pointerType !== 'mouse') hover = null
2991 const bombAt = armed
2992 startPress(null)
2993 if (bombAt && p && eq(p, bombAt)) act({ id: 'bomb', target: bombAt })
2994 else if (p) tapTile(p)
2995 })
2996 mainCanvas.addEventListener('pointerleave', () => {
2997 hover = null
2998 mouseHover = null
2999 drag = null
3000 endPan()
3001 startPress(null)
3002 })
3003 // A long press shouldn't open the browser's context menu.
3004 mainCanvas.addEventListener('contextmenu', (e) => e.preventDefault())
3005 },
3006 timed(guarded('update', () => {
3007 if (!replayOn) pad.poll(performance.now())
3008 demoTick()
3009 reopenPendingChoice()
3010 // In the demo the board and the action bar under it fit above the caption panel, with room
3011 // between them and it.
3012 const panel = demoPanel.getBoundingClientRect().height
3013 const view = updateCamera(state, demo ? { top: controls.insets().top, bottom: panel + DEMO_PANEL_GAP + DEMO_GAP + controls.insets().bottom } : controls.insets(), snapCamera, !!demo, demoZoom)
3014 // The bars round the view (style.css #view-mask), in play only, and in the demo's zoomed scenes: its other rooms are seen whole.
3015 viewMask.hidden = !!demo && demoZoom === 1
3016 viewMask.style.cssText = `left:${view.x}px;top:${view.y}px;width:${view.width}px;height:${view.height}px`
3017 if (demo) placeDemoBar(view)
3018 // The caption panel's height, for what sits above it (style.css: the CRT screen, the dialog),
3019 // and the TV's sides: at most 4:3 wide, black bars beside it on a wide screen (user request,
3020 // 2026-10-24: it looked very wide on a desktop).
3021 document.body.style.setProperty('--demo-panel', `${panel}px`)
3022 const title = demo ? demoTitle.getBoundingClientRect().height : 0
3023 document.body.style.setProperty('--demo-title', `${title}px`)
3024 const tv = Math.min(innerWidth, (innerHeight - panel - title) * TV_ASPECT)
3025 document.body.style.setProperty('--tv-left', `${(innerWidth - tv) / 2}px`)
3026 placeDemoMarks()
3027 if (demo) vacuum()
3028 // The controller's cursor is always on while the pad plays the board; its label sits over the action bar.
3029 ensureCursor()
3030 const labelCursor = aimShown() ? cursor : null
3031 // The pad demo's cursor marks the bar's buttons too, but has no label (the demo's panel sits where it would).
3032 const shownCursor = labelCursor ?? (demo?.pad || demo?.keys ? demo.cursor : null)
3033 const lines = labelCursor ? cursorLines(labelCursor.actions) : []
3034 // Empty (so the box is hidden) when there are no lines to show.
3035 // The keyboard's cursor names its keys (key caps) where the pad's draws the buttons' pictures.
3036 const keysUi = inputMode === 'keys'
3037 const note = labelCursor ? (lines.length ? JSON.stringify([keysUi ? 'keys' : padKind, lines]) : '') : aimShown() ? (keysUi ? `Enter|${aim!.label}` : aim!.label) : ''
3038 // The action bar's buttons show what the cursor's tile offers: greyed, or marked when it costs a life.
3039 // "What is that?" explains, it does not act: the bar's four buttons are greyed (Q83).
3040 const barInspect = !shownCursor && aimShown() && inspecting
3041 const barKey = barInspect ? 'inspect' : shownCursor ? `${shownCursor.tile.x},${shownCursor.tile.y}:${shownCursor.actions.map((a) => (a.possible ? (a.deadly ? 'd' : 'o') : 'x')).join('')}` : ''
3042 if (barKey !== lastBarKey) {
3043 lastBarKey = barKey
3044 controls.setPadCursor(barInspect ? { move: 'off', bomb: 'off', charged: 'off', wait: 'off' } : shownCursor ? Object.fromEntries(shownCursor.actions.map((a) => [a.button, a.possible ? (a.deadly ? 'danger' : 'ok') : 'off'])) : null)
3045 }
3046 if (lastNote !== note) {
3047 lastNote = note
3048 // The cursor's lines each start with the button's own picture (the keyboard's: its key cap); the inspect aim's is plain text (the keyboard's: after the Enter key cap).
3049 if (labelCursor) {
3050 padLabel.replaceChildren(
3051 ...lines.map((l) => {
3052 const row = document.createElement('div')
3053 row.className = 'pad-line'
3054 const text = document.createElement('span')
3055 text.textContent = l.label
3056 row.append(keysUi ? keyCap(KEY_CAPS[l.button]) : buttonPicture(spriteUrl(glyphFor(padKind, l.button === 'charged' ? 'charge' : l.button))), text)
3057 return row
3058 }),
3059 )
3060 } else if (keysUi && note) {
3061 const row = document.createElement('div')
3062 row.className = 'pad-line'
3063 const text = document.createElement('span')
3064 text.textContent = aim!.label
3065 row.append(keyCap('Enter'), text)
3066 padLabel.replaceChildren(row)
3067 } else if (aim && note) {
3068 // The pad's confirm button explains and the cancel button leaves (Q85): each with its picture; a held press only if INSPECT_HOLD (Q88).
3069 const verb = INSPECT_HOLD ? 'Hold' : 'Press'
3070 const row = (face: FaceIndex, words: string): HTMLElement => {
3071 const el = document.createElement('div')
3072 el.className = 'pad-line'
3073 const text = document.createElement('span')
3074 text.textContent = words
3075 const pic = buttonPicture(spriteUrl(glyphFor(padKind, face === confirmFace(padKind) ? 'move' : 'wait')))
3076 el.append(pic, text)
3077 return el
3078 }
3079 padLabel.replaceChildren(row(confirmFace(padKind), `${verb} to find out what this is`), row(cancelFace(padKind), `${verb} to leave inspection mode`))
3080 } else padLabel.textContent = note
3081 padLabel.hidden = !note
3082 }
3083 if (note) padLabel.style.bottom = `${controls.insets().bottom + 8}px`
3084 // The hint for L1 and R1, just above the label (or the bar, with no label): shown with the cursor.
3085 const l1 = l1Hint({ inspecting, picked: !!(selected || destination), onPlayer: !cursor || eq(cursor.tile, state.player) })
3086 // The keyboard's Esc, on the player with nothing to undo, puts the cursor away (padTiles `l1Hint` says "Back to you" for the pad).
3087 const escText = keysUi && inspecting ? 'Leave inspection mode' : keysUi && l1.text === IDLE_L1_TEXT && l1.dim ? 'Hide cursor' : l1.text
3088 const hintKey = aimShown() ? `${keysUi ? 'keys' : padKind}|${escText}|${l1.dim && !keysUi}|${inspecting}` : ''
3089 if (hintKey !== lastHintKey) {
3090 lastHintKey = hintKey
3091 padHint.hidden = !hintKey
3092 if (hintKey) {
3093 // The shoulder button's picture, a symbol of what it does (a magnifying glass for inspection, an undo arrow for going back), and the words.
3094 const item = (side: 'l' | 'r', symbol: string, text: string, dim: boolean): HTMLElement => {
3095 const el = document.createElement('span')
3096 el.className = dim ? 'pad-hint-item dim' : 'pad-hint-item'
3097 const sym = document.createElement('img')
3098 sym.className = 'pad-hint-symbol'
3099 sym.alt = ''
3100 sym.draggable = false
3101 sym.src = symbol
3102 el.append(buttonPicture(spriteUrl(shoulderGlyph(padKind, side))), sym, text)
3103 return el
3104 }
3105 const keyItem = (cap: string, text: string): HTMLElement => {
3106 const el = document.createElement('span')
3107 el.className = 'pad-hint-item'
3108 el.append(keyCap(cap), text)
3109 return el
3110 }
3111 if (keysUi) padHint.replaceChildren(keyItem('Shift + arrows', inspecting ? 'Next tile' : 'Pick a tile'), keyItem('Esc', escText), keyItem('?', inspecting ? 'Explain it' : 'Activate inspection mode'))
3112 else padHint.replaceChildren(item('l', returnIcon, l1.text, l1.dim), item('r', zoomIcon, 'Activate inspection mode', inspecting))
3113 }
3114 }
3115 // The hint sits at the top of the board, just under the top bar (Q84; the label has the bottom).
3116 if (hintKey) padHint.style.top = `${controls.insets().top + 8}px`
3117 snapCamera = false
3118 pruneAnimation(anim, time)
3119 autopilotTick()
3120 autoStep()
3121 replayTick()
3122 })),
3123 () => {},
3124 timed(guarded('draw', () => {
3125 // A dialog's clip first: it uses the canvas for a moment, then clears it (ui/clip.ts).
3126 drawClips()
3127 syncHudAim()
3128 // The controller's cursor: the pad's own, or the pad demo's.
3129 const shown = aimShown() ? cursor : demo?.pad || demo?.keys ? demo.cursor : null
3130 drawBoard({
3131 state, danger, selected, hover: aimShown() && !cursor && aim?.target && !aim.hud ? aim.target : hover, target: aimShown() ? (aim?.hud ? null : cursor?.tile ?? aim!.target ?? state.player) : (shown?.tile ?? null), destination, path, armed, ...anim, deadly: deadlyTargets, refused,
3132 // The controller's cursor: every actionable tile blue, and the result of the first action there, faded.
3133 preview: previewNow(),
3134 lit: shown?.tiles, faded: shown?.far && !selected ? shown.far.map((f) => f.tile) : undefined, deadlyCursor: !!shown && cursorDeadly(shown.actions), ghost: shown ? ghostAction(shown.actions, shown.tile, state.player) : null,
3135 // A deadly next move shows as a blocked one: the first tile red.
3136 route: destination && !deadlyAutoMove ? autoRoute(state, destination, safeStep) : null,
3137 pathBlocked: (!!path?.[0] && !safeStep(path[0])) || deadlyAutoMove,
3138 rival: replayRun || rulesRun ? ((r) => ({ state: r.blue, move: r.blueAnim.playerMove, pose: r.blueAnim.playerPose, facingLeft: r.blueAnim.facingLeft }))((replayRun ?? rulesRun)!) : undefined,
3139 })
3140 // What is out of sight on a board bigger than the view: arrows at its edge.
3141 if (!demo) drawIndicators(state, viewRect())
3142 countFrame()
3143 })),
3144 () => {},
3145 TEXTURES,
3146)
3147
3148// The tab of a local or next copy says so (user requests, 2026-10-31), so it isn't mistaken for the released one.
3149document.title = TITLE
3150loadNextInfo()
3151
3152// Installable and playable offline (static/sw.js). Only in the built game: in development it
3153// would serve stale copies over Vite's live reloading.
3154if (import.meta.env.PROD && 'serviceWorker' in navigator) {
3155 navigator.serviceWorker.register('./sw.js').catch(() => {
3156 // No service worker (a private window, an old browser): the game still runs, just not offline.
3157 })
3158}