explodorama / files / src / main.ts
main.ts
5551eb37 2026-10-20 Pavel Safronov | 1 | import { |
e6bb7c42 2026-10-25 Pavel Safronov | 2 | cameraScale, |
5551eb37 2026-10-20 Pavel Safronov | 3 | engineInit, |
| 4 | mainCanvas, | |
| 5 | screenToWorld, | |
| 6 | setCanvasClearColor, | |
| 7 | setCanvasPixelRatio, | |
52d68b59 2026-10-24 Pavel Safronov | 8 | setDebugWatermark, |
ebcd0a8f 2026-11-02 Pavel Safronov | 9 | setGamepadsEnable, |
5551eb37 2026-10-20 Pavel Safronov | 10 | setGLEnable, |
0b63d158 2026-10-25 Pavel Safronov | 11 | glSetAntialias, |
5551eb37 2026-10-20 Pavel Safronov | 12 | setShowSplashScreen, |
ebcd0a8f 2026-11-02 Pavel Safronov | 13 | timeDelta, |
644cfd0b 2026-10-20 Pavel Safronov | 14 | setTilesPixelated, |
e3384c6b 2026-10-24 Pavel Safronov | 15 | PostProcessPlugin, |
| 16 | postProcess, | |
| 17 | postProcessChromatic, | |
| 18 | postProcessEffects, | |
| 19 | postProcessNoise, | |
5551eb37 2026-10-20 Pavel Safronov | 20 | time, |
| 21 | vec2, | |
36c5d6a2 2026-10-24 Pavel Safronov | 22 | worldToScreen, |
5551eb37 2026-10-20 Pavel Safronov | 23 | } from 'littlejsengine' |
e6bb7c42 2026-10-25 Pavel Safronov | 24 | import { BACKGROUND, drawBoard, drawIndicators, playerPos, toTile, toWorld } from './render/board' |
33a0256b 2026-10-24 Pavel Safronov | 25 | import { animateTurn as animateBoard, clearAnimation, newAnimation, pruneAnimation } from './render/animate' |
ebcd0a8f 2026-11-02 Pavel Safronov | 26 | import { endPan, keepView, panView, resetPan, revealTile, updateCamera, viewRect } from './render/camera' |
52d68b59 2026-10-24 Pavel Safronov | 27 | import { CHARGE_ICONS, MONO_ICONS, POWER_UP_ICONS, TEXTURES } from './render/sprites' |
5551eb37 2026-10-20 Pavel Safronov | 28 | import { isAvailable, isValidTarget, ACTIONS } from './sim/actions' |
bb3823d2 2026-10-31 Pavel Safronov | 29 | import { approachTile, canPick, membersOf, pickChoices, pickTargets, resolvePick, tapChoices, tapMeaning } from './sim/picks' |
82adee18 2026-10-20 Pavel Safronov | 30 | import { enemyAt } from './sim/enemies' |
e6bb7c42 2026-10-25 Pavel Safronov | 31 | import { autoMoveAction, autoRoute, playerPath, safeStep as isSafeStep } from './sim/path' |
92cc4ee3 2026-11-02 Pavel Safronov | 32 | import { autopilotPlan } from './sim/autopilot' |
e3d8dc49 2026-10-27 Pavel Safronov | 33 | import { isDeadly, predictDanger, type Danger } from './sim/danger' |
27f24fed 2026-10-20 Pavel Safronov | 34 | import { newRecord, replay, SIM_VERSION, type RunRecord } from './sim/record' |
720cc9d3 2026-11-03 Pavel Safronov | 35 | import { cellAt, eq, inBounds, isBurning, newRun, PAR_BONUS, turnNumber } from './sim/state' |
e3384c6b 2026-10-24 Pavel Safronov | 36 | import { LAST_LEVEL } from './sim/levelgen' |
92cc4ee3 2026-11-02 Pavel Safronov | 37 | import { step } from './sim/turn' |
d3e4ed26 2026-10-25 Pavel Safronov | 38 | import { CHARGE_IDS, DEFAULT_OPTIONS, DIRS, type Action, type ActionId, type ChargeId, type Dir, type GameEvent, type GameState, type RunOptions, type Vec } from './sim/types' |
99869630 2026-10-23 Pavel Safronov | 39 | import { openAlmanac } from './ui/almanac' |
b89399da 2026-11-01 Pavel Safronov | 40 | import { chargeToast, showToast } from './ui/toast' |
| 41 | import { CHARGE_AMOUNT } from './sim/charges' | |
5607bea2 2026-10-31 Pavel Safronov | 42 | import { VERSION } from './changelog' |
b89399da 2026-11-01 Pavel Safronov | 43 | import { channelBox, channelLine, loadNextInfo, newSinceLastVisit, openChanges } from './ui/whatsnew' |
bb3823d2 2026-10-31 Pavel Safronov | 44 | import { CHANNEL, storageKey, TITLE } from './channel' |
5551eb37 2026-10-20 Pavel Safronov | 45 | import { createControls } from './ui/controls' |
720cc9d3 2026-11-03 Pavel Safronov | 46 | import { nextFocus, type Step } from './ui/spatial' |
ebcd0a8f 2026-11-02 Pavel Safronov | 47 | import { createPadInput } from './ui/gamepad' |
805e7a9f 2026-11-03 Pavel Safronov | 48 | import { dpadGlyph, glyphFor, padCaptionHtml, shoulderGlyph } from './ui/padGlyph' |
720cc9d3 2026-11-03 Pavel Safronov | 49 | import { cancelFace, confirmFace, type FaceIndex, type PadFamily } from './ui/padMap' |
92cc4ee3 2026-11-02 Pavel Safronov | 50 | import { PAD_STILL_CAPTIONS, padDemoOrder, padHops, padPlan, pressPart, type PadPress } from './ui/padDemo' |
| 51 | import { createPadOverlay, PAD_AFTER_SECONDS, PAD_LIT_SECONDS } from './ui/padOverlay' | |
| 52 | import { KEY_STILL_CAPTIONS, KEY_STILLS, keyCaptionHtml, keyDemoOrder, keyPlan, pressParts, type KeyPress, type KeyStill } from './ui/keyDemo' | |
| 53 | import { createKeyOverlay } from './ui/keyOverlay' | |
| 54 | import { createHoldRing } from './ui/holdRing' | |
ebcd0a8f 2026-11-02 Pavel Safronov | 55 | import { padDialog } from './ui/padDialog' |
720cc9d3 2026-11-03 Pavel Safronov | 56 | import { aimStart, explainableTiles, jumpTarget } from './ui/padTarget' |
92cc4ee3 2026-11-02 Pavel Safronov | 57 | import { routePreview, type RouteView } from './ui/routePreview' |
720cc9d3 2026-11-03 Pavel Safronov | 58 | import { 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' |
| 59 | import { defaultButton, escStep, KEY_CAPS, keyButton, keyDirection, ignoredForMode, keyRole, modeAfterKey, panStep, targetTakesKey, type InputMode } from './ui/keyCursor' | |
ebcd0a8f 2026-11-02 Pavel Safronov | 60 | import { closeDialog, dialogOpen, MENU_TITLE, openDialog, setDialogInDemo, type DialogButton } from './ui/dialog' |
720cc9d3 2026-11-03 Pavel Safronov | 61 | import { actionInfo, CHARGE_TEXT, chargeInfo, hudInfo, tileInfo, type HudItem, type Info } from './ui/info' |
d3e4ed26 2026-10-25 Pavel Safronov | 62 | import { openChargeOffer } from './ui/chargeOffer' |
bb3823d2 2026-10-31 Pavel Safronov | 63 | import { openTapChoice } from './ui/tapChoice' |
b8849d36 2026-11-01 Pavel Safronov | 64 | import { bindTimeline } from './ui/timeline' |
52d68b59 2026-10-24 Pavel Safronov | 65 | import { openOffer } from './ui/offer' |
e78b7781 2026-10-25 Pavel Safronov | 66 | import { showShop, updateShop } from './ui/shop' |
e6bb7c42 2026-10-25 Pavel Safronov | 67 | import { shopAfter } from './sim/shop' |
92cc4ee3 2026-11-02 Pavel Safronov | 68 | import { startClip, drawClips, setClipPad } from './ui/clip' |
33a0256b 2026-10-24 Pavel Safronov | 69 | import { makeScene } from './ui/scenes' |
0580e85e 2026-10-24 Pavel Safronov | 70 | import { openScores, recordScore } from './ui/scores' |
| 71 | import { countsForScores, scoreEntry } from './sim/scores' | |
33a0256b 2026-10-24 Pavel Safronov | 72 | import { device, recentErrors, reportError } from './devlog' |
0580e85e 2026-10-24 Pavel Safronov | 73 | import { hashSeed } from './sim/rng' |
351348d1 2026-11-04 Pavel Safronov | 74 | import { buildReplay, type ReplayScript } from './sim/replayCompare' |
| 75 | import { compareRules, type RuleComparison } from './sim/ruleCompare' | |
8edb5cb9 2026-11-01 Pavel Safronov | 76 | import { TEST_KINDS, TEST_SCENES, type TestKind } from './sim/testScenes' |
33a0256b 2026-10-24 Pavel Safronov | 77 | import { CONTROL_KINDS, DYING_KINDS, LIVING_KINDS, SCENES, type Gesture, type Scene, type SceneKind } from './sim/demo' |
a4fab0fb 2026-10-21 Pavel Safronov | 78 | import { spriteUrl } from './render/sprites' |
92cc4ee3 2026-11-02 Pavel Safronov | 79 | import { demoHintText, demoInputFamily, demoInputLabel, effectiveDemoInput, isKeysInput, isPadInput, nextDemoInput, type DemoInput } from './ui/demoInput' |
0b63d158 2026-10-25 Pavel Safronov | 80 | import { countFrame, timed } from './ui/fps' |
| 81 | import { canVibrate, feedbackSettings, initFeedback, setFeedbackSettings, turnFeedback, vibrate } from './ui/feedback' | |
a4fab0fb 2026-10-21 Pavel Safronov | 82 | import playIcon from '../assets/kenney-ui/Extra/icon_play_light.png' |
| 83 | import newRunIcon from '../assets/kenney-ui/Extra/icon_repeat_light.png' | |
36c5d6a2 2026-10-24 Pavel Safronov | 84 | import handIcon from '../assets/kenney-ui-rpg/cursorHand_beige.svg' |
92cc4ee3 2026-11-02 Pavel Safronov | 85 | import mouseIcon from '../assets/kenney-cursor/cursor_none.png' |
e3384c6b 2026-10-24 Pavel Safronov | 86 | import questionIcon from '../assets/kenney-icons/question.png' |
720cc9d3 2026-11-03 Pavel Safronov | 87 | import returnIcon from '../assets/kenney-icons/return.png' |
| 88 | import zoomIcon from '../assets/kenney-icons/zoom.png' | |
5551eb37 2026-10-20 Pavel Safronov | 89 | |
| 90 | // `?seed=123&level=4` gives a reproducible board (screenshots, bug reports). Otherwise a random | |
| 91 | // seed — chosen here, never inside the sim. | |
| 92 | const params = new URLSearchParams(location.search) | |
| 93 | const startLevel = Number(params.get('level')) || 1 | |
351348d1 2026-11-04 Pavel Safronov | 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. | |
| 96 | const replayOn = import.meta.env.DEV && params.has('replay') | |
| 97 | if (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 | } | |
d3e4ed26 2026-10-25 Pavel Safronov | 102 | // `?charge=nuke` starts carrying that charge (state.ts `newRun`), to try it. |
| 103 | const startCharge = (CHARGE_IDS as readonly string[]).includes(params.get('charge') ?? '') ? (params.get('charge') as ChargeId) : null | |
5551eb37 2026-10-20 Pavel Safronov | 104 | |
| 105 | let record: RunRecord | |
| 106 | let state: GameState | |
| 107 | let danger: Danger | |
| 108 | let selected: ActionId | null = null | |
ea380ee8 2026-10-22 Pavel Safronov | 109 | /** "What is that?" is on: the next tap on the board explains the tile instead of acting. */ |
| 110 | let inspecting = false | |
5551eb37 2026-10-20 Pavel Safronov | 111 | let hover: Vec | null = null |
92cc4ee3 2026-11-02 Pavel Safronov | 112 | /** The tile under a mouse pointer with no button down (never a finger): a far walkable one previews auto-move's way. */ |
| 113 | let mouseHover: Vec | null = null | |
ebcd0a8f 2026-11-02 Pavel Safronov | 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 | */ | |
| 118 | interface Aim { | |
| 119 | button: 'inspect' | |
| 120 | tiles: Vec[] | |
| 121 | target: Vec | null | |
| 122 | label: string | |
720cc9d3 2026-11-03 Pavel Safronov | 123 | /** On a top-bar item instead of a tile (Up from the top row; Q82): what pressing again explains. */ |
| 124 | hud?: HudItem | |
ebcd0a8f 2026-11-02 Pavel Safronov | 125 | } |
| 126 | let aim: Aim | null = null | |
33a0256b 2026-10-24 Pavel Safronov | 127 | /** What animates the board between turns (render/animate.ts). */ |
| 128 | const anim = newAnimation() | |
5551eb37 2026-10-20 Pavel Safronov | 129 | let snapCamera = true |
82adee18 2026-10-20 Pavel Safronov | 130 | /** Auto-move: a far tile the player tapped. Each press of Move takes one step along `path` toward it. */ |
| 131 | let destination: Vec | null = null | |
| 132 | let path: Vec[] | null = null | |
e3d8dc49 2026-10-27 Pavel Safronov | 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 | */ | |
| 137 | let deadlyWait = false | |
| 138 | let deadlyTargets: Vec[] = [] | |
| 139 | let deadlyAutoMove = false | |
| 140 | /** A tap refused because it would cost a life: the tile flashes red for a moment. */ | |
| 141 | let refused: { tile: Vec; at: number } | null = null | |
82adee18 2026-10-20 Pavel Safronov | 142 | /** Long press: the adjacent tile a held finger has armed for a bomb, placed on release. */ |
| 143 | let armed: Vec | null = null | |
| 144 | let pressTimer: number | undefined | |
| 145 | let pressTile: Vec | null = null | |
| 146 | const LONG_PRESS_MS = 450 | |
00ad7d0a 2026-10-24 Pavel Safronov | 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 | */ | |
| 151 | const AUTO_STEP_SECONDS = 0.5 | |
720cc9d3 2026-11-03 Pavel Safronov | 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). */ |
| 153 | const AUTOPILOT_STEP_SECONDS = 0.4 | |
92cc4ee3 2026-11-02 Pavel Safronov | 154 | const 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. */ | |
| 156 | const AUTOPILOT_STALL_WAITS = 6 | |
ea380ee8 2026-10-22 Pavel Safronov | 157 | let lastActionAt = -Infinity |
92cc4ee3 2026-11-02 Pavel Safronov | 158 | /** Armed while enemies stand; the level's last kill fires it once (`autopilotTick`). */ |
| 159 | let 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`). */ | |
| 161 | let autopilot = false | |
| 162 | /** When its first move may come (the start beat), and how many waits in a row it has made. */ | |
| 163 | let autopilotStartAt = 0 | |
| 164 | let autopilotWaits = 0 | |
0b61e800 2026-10-23 Pavel Safronov | 165 | /** Kinds of action charge picked up this run: only the first of each kind opens a dialog. */ |
| 166 | const chargesSeen = new Set<ChargeId>() | |
| 167 | ||
| 168 | /** Remembers a charge picked up; returns its kind the first time this run, otherwise null. */ | |
| 169 | function 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 | } | |
5551eb37 2026-10-20 Pavel Safronov | 174 | |
92cc4ee3 2026-11-02 Pavel Safronov | 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. */ |
| 176 | let demoZoom = 1 | |
| 177 | ||
52d68b59 2026-10-24 Pavel Safronov | 178 | /** A fresh view of the board: no animations under way, nothing picked, the camera snapped. */ |
| 179 | function resetView(): void { | |
92cc4ee3 2026-11-02 Pavel Safronov | 180 | stopAutopilot() |
| 181 | demoZoom = 1 | |
33a0256b 2026-10-24 Pavel Safronov | 182 | clearAnimation(anim) |
a4fab0fb 2026-10-21 Pavel Safronov | 183 | selected = null |
ea380ee8 2026-10-22 Pavel Safronov | 184 | inspecting = false |
a4fab0fb 2026-10-21 Pavel Safronov | 185 | destination = null |
ebcd0a8f 2026-11-02 Pavel Safronov | 186 | aim = null |
| 187 | cursor = null | |
a4fab0fb 2026-10-21 Pavel Safronov | 188 | snapCamera = true |
d94649ba 2026-11-01 Pavel Safronov | 189 | resetPan() |
52d68b59 2026-10-24 Pavel Safronov | 190 | } |
| 191 | ||
| 192 | function startRun(seed: number, options: RunOptions): void { | |
| 193 | chargesSeen.clear() | |
32dd53cd 2026-10-25 Pavel Safronov | 194 | lastTurns = [] |
52d68b59 2026-10-24 Pavel Safronov | 195 | resetView() |
d3e4ed26 2026-10-25 Pavel Safronov | 196 | record = newRecord(seed, startLevel, startCharge, options) |
| 197 | state = newRun(seed, startLevel, startCharge, options) | |
e6bb7c42 2026-10-25 Pavel Safronov | 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 | } | |
27f24fed 2026-10-20 Pavel Safronov | 205 | save() |
5551eb37 2026-10-20 Pavel Safronov | 206 | afterChange() |
| 207 | } | |
| 208 | ||
3cf3607b 2026-10-24 Pavel Safronov | 209 | /** The modes picked last time, the default for the next new run. */ |
bb3823d2 2026-10-31 Pavel Safronov | 210 | const OPTIONS_KEY = storageKey('newRunOptions') |
beb9f2b7 2026-10-21 Pavel Safronov | 211 | |
| 212 | function preferredOptions(): RunOptions { | |
| 213 | try { | |
3cf3607b 2026-10-24 Pavel Safronov | 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 } | |
beb9f2b7 2026-10-21 Pavel Safronov | 217 | } catch { |
| 218 | return { ...DEFAULT_OPTIONS } | |
| 219 | } | |
| 220 | } | |
| 221 | ||
| 222 | function 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 | ||
27f24fed 2026-10-20 Pavel Safronov | 230 | /** |
| 231 | * Mid-run save (PLAN.md §11): the run record goes to localStorage after every action, and is | |
d3e4ed26 2026-10-25 Pavel Safronov | 232 | * replayed on the next visit. A URL that sets up a test board (`?seed`, `?level`, `?charge`) |
27f24fed 2026-10-20 Pavel Safronov | 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 | */ | |
bb3823d2 2026-10-31 Pavel Safronov | 236 | const SAVE_KEY = storageKey('run') |
beb9f2b7 2026-10-21 Pavel Safronov | 237 | |
00ad7d0a 2026-10-24 Pavel Safronov | 238 | /** A test URL's options: `?dev`, `?immortal` (for screenshots and bug reports). */ |
beb9f2b7 2026-10-21 Pavel Safronov | 239 | function urlOptions(): RunOptions { |
00ad7d0a 2026-10-24 Pavel Safronov | 240 | return { dev: params.has('dev'), immortal: params.has('immortal') } |
beb9f2b7 2026-10-21 Pavel Safronov | 241 | } |
351348d1 2026-11-04 Pavel Safronov | 242 | const testing = ['seed', 'level', 'charge', 'dev', 'immortal', 'shop', 'replay'].some((p) => params.has(p)) |
27f24fed 2026-10-20 Pavel Safronov | 243 | |
| 244 | function 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. */ | |
| 254 | function 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 | |
0b61e800 2026-10-23 Pavel Safronov | 259 | chargesSeen.clear() |
| 260 | const s = replay(saved, firstCharge) | |
27f24fed 2026-10-20 Pavel Safronov | 261 | if (s.status === 'gameOver' || s.status === 'won') return false |
| 262 | record = saved | |
| 263 | state = s | |
| 264 | afterChange() | |
d3e4ed26 2026-10-25 Pavel Safronov | 265 | // Closed with a gem's choice (or a charge's) still open, or in the shop: it opens again. |
52d68b59 2026-10-24 Pavel Safronov | 266 | showOffer() |
d3e4ed26 2026-10-25 Pavel Safronov | 267 | showChargeOffer() |
e78b7781 2026-10-25 Pavel Safronov | 268 | if (state.shop) openShopDialog() |
27f24fed 2026-10-20 Pavel Safronov | 269 | return true |
| 270 | } catch { | |
| 271 | return false | |
| 272 | } | |
| 273 | } | |
| 274 | ||
5551eb37 2026-10-20 Pavel Safronov | 275 | function randomSeed(): number { |
| 276 | return Math.floor(Math.random() * 2 ** 31) | |
| 277 | } | |
| 278 | ||
a4fab0fb 2026-10-21 Pavel Safronov | 279 | /** The actions that move the player. One that doesn't land on the auto-move path means "changed my mind". */ |
| 280 | const MOVES: ReadonlySet<Action['id']> = new Set(['move', 'run', 'diagonal', 'teleport']) | |
| 281 | ||
28670c3e 2026-10-24 Pavel Safronov | 282 | /** `auto`: auto-move's own move, which may leave the walking way (a Teleport) without ending it. */ |
| 283 | function act(action: Action, auto = false): void { | |
52d68b59 2026-10-24 Pavel Safronov | 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 | |
bb3823d2 2026-10-31 Pavel Safronov | 286 | if (state.chargeOffer && action.id !== 'sellCharge' && action.id !== 'swapCharge' && action.id !== 'fireCharge') return |
92cc4ee3 2026-11-02 Pavel Safronov | 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 | |
ea380ee8 2026-10-22 Pavel Safronov | 289 | lastActionAt = time |
28670c3e 2026-10-24 Pavel Safronov | 290 | if (!auto && MOVES.has(action.id) && 'target' in action && !path?.some((t) => eq(t, action.target!))) destination = null |
52d68b59 2026-10-24 Pavel Safronov | 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 | } | |
0580e85e 2026-10-24 Pavel Safronov | 299 | const wasGoing = state.status !== 'gameOver' && state.status !== 'won' |
32dd53cd 2026-10-25 Pavel Safronov | 300 | const before = state |
52d68b59 2026-10-24 Pavel Safronov | 301 | animateTurn(result, action) |
0b63d158 2026-10-25 Pavel Safronov | 302 | turnFeedback(result.events, action, anim) |
52d68b59 2026-10-24 Pavel Safronov | 303 | record.actions.push(action) |
32dd53cd 2026-10-25 Pavel Safronov | 304 | rememberTurn(before, action, result.events) |
0580e85e 2026-10-24 Pavel Safronov | 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)) { | |
bb3823d2 2026-10-31 Pavel Safronov | 308 | const rank = recordScore({ ...scoreEntry(state, record, new Date()), channel: CHANNEL === 'local' ? 'local' : CHANNEL, commit: import.meta.env.VITE_COMMIT || undefined }) |
0580e85e 2026-10-24 Pavel Safronov | 309 | controls.setBannerNote(rank ? `New high score: #${rank}!` : '') |
| 310 | } | |
52d68b59 2026-10-24 Pavel Safronov | 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) | |
8edb5cb9 2026-11-01 Pavel Safronov | 314 | // The next of several gems taken on one turn. |
| 315 | if (e.type === 'offerAgain') window.setTimeout(showOffer, 150) | |
d3e4ed26 2026-10-25 Pavel Safronov | 316 | // A charge picked up while carrying another: sell it, or swap. |
| 317 | if (e.type === 'chargeOffered') window.setTimeout(showChargeOffer, PICKUP_DIALOG_MS) | |
52d68b59 2026-10-24 Pavel Safronov | 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) | |
b89399da 2026-11-01 Pavel Safronov | 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 | } | |
d3e4ed26 2026-10-25 Pavel Safronov | 330 | if (charge && !result.events.some((x) => x.type === 'chargeOffered')) window.setTimeout(() => state.offer || state.chargeOffer || showInfo(chargeInfo(charge)), PICKUP_DIALOG_MS) |
52d68b59 2026-10-24 Pavel Safronov | 331 | } |
| 332 | selected = null | |
| 333 | inspecting = false | |
| 334 | save() | |
| 335 | afterChange() | |
e78b7781 2026-10-25 Pavel Safronov | 336 | // The shop, after every few levels: opened by going on from the level, then kept up to date. |
d3e4ed26 2026-10-25 Pavel Safronov | 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) | |
e78b7781 2026-10-25 Pavel Safronov | 339 | if (action.id === 'continue' && state.shop) openShopDialog() |
| 340 | else if (action.id === 'buy' || action.id === 'reroll') updateShop(state) | |
d3e4ed26 2026-10-25 Pavel Safronov | 341 | // A charge bought while carrying another asked first; the shop comes back once it is answered. |
bb3823d2 2026-10-31 Pavel Safronov | 342 | else if ((action.id === 'sellCharge' || action.id === 'swapCharge' || action.id === 'fireCharge') && state.shop) openShopDialog() |
e78b7781 2026-10-25 Pavel Safronov | 343 | } |
| 344 | ||
| 345 | /** The shop's dialog (ui/shop.ts): buying and restocking are turns of the run record, taking no time. */ | |
| 346 | function openShopDialog(): void { | |
| 347 | showShop(state, { | |
| 348 | buy: (kind) => act({ id: 'buy', kind }), | |
| 349 | reroll: () => act({ id: 'reroll' }), | |
| 350 | leave: () => act({ id: 'continue' }), | |
| 351 | }) | |
52d68b59 2026-10-24 Pavel Safronov | 352 | } |
| 353 | ||
32dd53cd 2026-10-25 Pavel Safronov | 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 | */ | |
| 358 | const REPLAY_TURNS = 4 | |
| 359 | let lastTurns: { prev: GameState; action: Action }[] = [] | |
| 360 | const bannerClip = document.getElementById('banner-clip') as HTMLCanvasElement | |
| 361 | ||
| 362 | function 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 | ||
52d68b59 2026-10-24 Pavel Safronov | 374 | /** A turn played: the new state, and everything that animates it (also for the demo). */ |
| 375 | function animateTurn(result: ReturnType<typeof step>, action: Action): void { | |
33a0256b 2026-10-24 Pavel Safronov | 376 | const newLevel = animateBoard(anim, state, result, action, time) |
d94649ba 2026-11-01 Pavel Safronov | 377 | const from = state.player |
5551eb37 2026-10-20 Pavel Safronov | 378 | state = result.state |
d94649ba 2026-11-01 Pavel Safronov | 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) | |
ebcd0a8f 2026-11-02 Pavel Safronov | 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 | |
33a0256b 2026-10-24 Pavel Safronov | 385 | if (newLevel) snapCamera = true |
00ad7d0a 2026-10-24 Pavel Safronov | 386 | if (newLevel || result.events.some((e) => e.type === 'levelCleared')) destination = null |
5551eb37 2026-10-20 Pavel Safronov | 387 | } |
| 388 | ||
52d68b59 2026-10-24 Pavel Safronov | 389 | /** How long after taking a gem (or a new charge) its dialog opens: the walk there and the rise away first. */ |
0b61e800 2026-10-23 Pavel Safronov | 390 | const PICKUP_DIALOG_MS = 450 |
99869630 2026-10-23 Pavel Safronov | 391 | |
52d68b59 2026-10-24 Pavel Safronov | 392 | /** The gem's choice, if one is waiting. Choosing takes no turn. */ |
| 393 | function showOffer(): void { | |
e6bb7c42 2026-10-25 Pavel Safronov | 394 | if (state.offer) openOffer(state, state.offer, (kind) => act({ id: 'choose', kind })) |
52d68b59 2026-10-24 Pavel Safronov | 395 | } |
| 396 | ||
d3e4ed26 2026-10-25 Pavel Safronov | 397 | /** The choice a charge picked up while carrying another asks for (one charge at a time). */ |
| 398 | function showChargeOffer(): void { | |
bb3823d2 2026-10-31 Pavel Safronov | 399 | if (state.chargeOffer && !state.offer) openChargeOffer(state, (choice) => act({ id: choice === 'fire' ? 'fireCharge' : choice === 'swap' ? 'swapCharge' : 'sellCharge' })) |
d3e4ed26 2026-10-25 Pavel Safronov | 400 | } |
| 401 | ||
720cc9d3 2026-11-03 Pavel Safronov | 402 | /** Since when a gem's or charge's choice has been waiting with no dialog showing (0: not waiting); see `reopenPendingChoice`. */ |
| 403 | let 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 | */ | |
| 411 | function reopenPendingChoice(): void { | |
351348d1 2026-11-04 Pavel Safronov | 412 | if (demo || replayOn || !(state.offer || state.chargeOffer) || dialogOpen()) { |
720cc9d3 2026-11-03 Pavel Safronov | 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 | ||
5551eb37 2026-10-20 Pavel Safronov | 423 | function afterChange(): void { |
ebcd0a8f 2026-11-02 Pavel Safronov | 424 | if (aim && (state.status !== 'playing' || !inspecting)) aim = null |
92cc4ee3 2026-11-02 Pavel Safronov | 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) | |
5551eb37 2026-10-20 Pavel Safronov | 427 | danger = predictDanger(state) |
82adee18 2026-10-20 Pavel Safronov | 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 | |
92cc4ee3 2026-11-02 Pavel Safronov | 433 | // After the danger and the destination: the cursor's faded tiles are safe ones (`farTiles`). |
| 434 | refreshCursor() | |
e3d8dc49 2026-10-27 Pavel Safronov | 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) | |
5551eb37 2026-10-20 Pavel Safronov | 446 | } |
| 447 | ||
82adee18 2026-10-20 Pavel Safronov | 448 | /** |
ea380ee8 2026-10-22 Pavel Safronov | 449 | * Two taps: pick an action, then a highlighted tile. With "What is that?" on: explain the tile. |
e3d8dc49 2026-10-27 Pavel Safronov | 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); | |
a4fab0fb 2026-10-21 Pavel Safronov | 453 | * - the auto-move flag: one step toward it (like Go), with auto-move's safety check; |
28670c3e 2026-10-24 Pavel Safronov | 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; | |
ea380ee8 2026-10-22 Pavel Safronov | 459 | * - a wall or crate, or any floor you can't walk to: clears the flag if there is one; |
99869630 2026-10-23 Pavel Safronov | 460 | * - one of the player's bombs, with the Detonator: set it off (user request, 2026-10-22); |
bb3823d2 2026-10-31 Pavel Safronov | 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); | |
99869630 2026-10-23 Pavel Safronov | 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. | |
82adee18 2026-10-20 Pavel Safronov | 466 | */ |
5551eb37 2026-10-20 Pavel Safronov | 467 | function tapTile(p: Vec): void { |
ebcd0a8f 2026-11-02 Pavel Safronov | 468 | aim = null |
92cc4ee3 2026-11-02 Pavel Safronov | 469 | if (state.status !== 'playing' || boardLocked()) return |
ea380ee8 2026-10-22 Pavel Safronov | 470 | if (inspecting) { |
| 471 | inspecting = false | |
| 472 | afterChange() | |
| 473 | showTileInfo(p) | |
| 474 | } else if (selected) { | |
99869630 2026-10-23 Pavel Safronov | 475 | // The picked button stands for a group (Move also runs, teleports...): the tile decides which. |
| 476 | const id = resolvePick(state, selected, p) | |
bb3823d2 2026-10-31 Pavel Safronov | 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 }) | |
5551eb37 2026-10-20 Pavel Safronov | 480 | else { |
| 481 | selected = null | |
| 482 | afterChange() | |
| 483 | } | |
36c5d6a2 2026-10-24 Pavel Safronov | 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) | |
e3d8dc49 2026-10-27 Pavel Safronov | 487 | if (means === 'deadly') refuse(p) |
36c5d6a2 2026-10-24 Pavel Safronov | 488 | else if (means === 'detonate' || means === 'kick') act({ id: means, target: p }) |
| 489 | else if (means === 'blocked') return | |
bb3823d2 2026-10-31 Pavel Safronov | 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() | |
36c5d6a2 2026-10-24 Pavel Safronov | 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 | } | |
82adee18 2026-10-20 Pavel Safronov | 502 | } |
| 503 | } | |
| 504 | ||
e3d8dc49 2026-10-27 Pavel Safronov | 505 | /** A tap or key that would cost a life, not taken: the tile flashes red, and a short buzz. */ |
| 506 | function refuse(p: Vec): void { | |
| 507 | refused = { tile: p, at: time } | |
| 508 | vibrate([30, 40, 30]) | |
| 509 | } | |
| 510 | ||
ea380ee8 2026-10-22 Pavel Safronov | 511 | /** The "What is that?" button: on, or off again (dropping any action picked). */ |
| 512 | function inspect(): void { | |
92cc4ee3 2026-11-02 Pavel Safronov | 513 | if (state.status !== 'playing' || boardLocked()) return |
ebcd0a8f 2026-11-02 Pavel Safronov | 514 | aim = null |
ea380ee8 2026-10-22 Pavel Safronov | 515 | inspecting = !inspecting |
| 516 | selected = null | |
| 517 | afterChange() | |
| 518 | } | |
| 519 | ||
52d68b59 2026-10-24 Pavel Safronov | 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 | */ | |
a4fab0fb 2026-10-21 Pavel Safronov | 524 | function setDestination(p: Vec): void { |
92cc4ee3 2026-11-02 Pavel Safronov | 525 | // "Walk there" from a tile's info dialog, opened while the autopilot waited: the player takes over. |
| 526 | stopAutopilot() | |
a4fab0fb 2026-10-21 Pavel Safronov | 527 | destination = p |
| 528 | afterChange() | |
52d68b59 2026-10-24 Pavel Safronov | 529 | stepTowardDestination() |
a4fab0fb 2026-10-21 Pavel Safronov | 530 | } |
| 531 | ||
| 532 | /** The info dialog for a tile; with "Walk there" when it is something to walk to. */ | |
| 533 | function showTileInfo(p: Vec): void { | |
| 534 | const info = tileInfo(state, p) | |
| 535 | if (!info) return | |
720cc9d3 2026-11-03 Pavel Safronov | 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 | } }] : []) | |
a4fab0fb 2026-10-21 Pavel Safronov | 543 | } |
| 544 | ||
32dd53cd 2026-10-25 Pavel Safronov | 545 | /** An info dialog; with a clip, that kind of scene looping above the words (ui/clip.ts). */ |
| 546 | function showInfo(info: Info, extra: DialogButton[] = []): void { | |
| 547 | const clip = info.clip | |
a4fab0fb 2026-10-21 Pavel Safronov | 548 | openDialog({ |
| 549 | icon: spriteUrl(info.icon), | |
| 550 | title: info.title, | |
33a0256b 2026-10-24 Pavel Safronov | 551 | body: (clip ? '<canvas class="clip" aria-hidden="true"></canvas>' : '') + info.body.map((t) => `<p>${t}</p>`).join(''), |
a4fab0fb 2026-10-21 Pavel Safronov | 552 | buttons: [...extra, { label: 'Got it', kind: 'plain' }], |
| 553 | }) | |
33a0256b 2026-10-24 Pavel Safronov | 554 | if (clip) startClip(document.querySelector<HTMLCanvasElement>('#dialog-body canvas.clip')!, clip) |
a4fab0fb 2026-10-21 Pavel Safronov | 555 | } |
| 556 | ||
2f667e95 2026-10-27 Pavel Safronov | 557 | const REPO_URL = 'https://code.fuzzyworld.net/explodorama' |
33a0256b 2026-10-24 Pavel Safronov | 558 | /** |
ba8e4ea0 2026-10-27 Pavel Safronov | 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. | |
33a0256b 2026-10-24 Pavel Safronov | 562 | */ |
ba8e4ea0 2026-10-27 Pavel Safronov | 563 | const BUG_EMAIL = 'bugs-k7q4+explodorama@fuzzyworld.net' |
33a0256b 2026-10-24 Pavel Safronov | 564 | /** Longer email links than this may be cut off by some mail apps: the run record then stays out. */ |
| 565 | const MAILTO_MAX = 1900 | |
| 566 | ||
| 567 | /** What a bug report says about the game and this run, to reproduce it (the run record replays it). */ | |
| 568 | function 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 [ | |
5607bea2 2026-10-31 Pavel Safronov | 573 | `Version ${VERSION} (rules ${SIM_VERSION})${commit}`, |
33a0256b 2026-10-24 Pavel Safronov | 574 | `Device: ${device()}; ${navigator.userAgent}`, |
720cc9d3 2026-11-03 Pavel Safronov | 575 | `Seed ${state.seed}, level ${state.level}, ${state.turn} turns taken, ${state.lives} lives, ${state.status}; modes: ${modes}`, |
33a0256b 2026-10-24 Pavel Safronov | 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 | /** | |
ba8e4ea0 2026-10-27 Pavel Safronov | 582 | * Report a bug (user request, 2026-10-24): an email to BUG_EMAIL, filled in with the |
33a0256b 2026-10-24 Pavel Safronov | 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 | */ | |
| 586 | function reportBug(): void { | |
| 587 | const full = bugDetails(true) | |
| 588 | const mailto = (details: string) => | |
5607bea2 2026-10-31 Pavel Safronov | 589 | `mailto:${BUG_EMAIL}?subject=${encodeURIComponent(`Bug report: version ${VERSION} (rules ${SIM_VERSION}), level ${state.level}`)}` + |
33a0256b 2026-10-24 Pavel Safronov | 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 | } | |
0b25f1ef 2026-10-24 Pavel Safronov | 599 | |
2f667e95 2026-10-27 Pavel Safronov | 600 | /** ", commit 0580e85" linked to its page on code.fuzzyworld.net, with a note if built with uncommitted changes. */ |
0b25f1ef 2026-10-24 Pavel Safronov | 601 | function commitLine(): string { |
| 602 | const commit = import.meta.env.VITE_COMMIT | |
| 603 | if (!commit) return '' | |
2f667e95 2026-10-27 Pavel Safronov | 604 | const link = `<a href="${REPO_URL}/commit/${commit}.html" target="_blank" rel="noopener">${commit}</a>` |
0b25f1ef 2026-10-24 Pavel Safronov | 605 | return `, commit ${link}${import.meta.env.VITE_COMMIT_DIRTY ? ' plus local changes' : ''}` |
| 606 | } | |
| 607 | ||
33a0256b 2026-10-24 Pavel Safronov | 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 | |
0bfdc8e6 2026-10-25 Pavel Safronov | 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. | |
33a0256b 2026-10-24 Pavel Safronov | 614 | */ |
a4fab0fb 2026-10-21 Pavel Safronov | 615 | function showMenu(): void { |
720cc9d3 2026-11-03 Pavel Safronov | 616 | const where = state.status === 'playing' ? `Level ${state.level}, turn ${turnNumber(state)}. ` : '' |
33a0256b 2026-10-24 Pavel Safronov | 617 | const modes = [state.options.dev && 'Developer', state.options.immortal && 'Immortal'].filter(Boolean) |
a4fab0fb 2026-10-21 Pavel Safronov | 618 | openDialog({ |
ebcd0a8f 2026-11-02 Pavel Safronov | 619 | title: MENU_TITLE, |
a4fab0fb 2026-10-21 Pavel Safronov | 620 | body: |
| 621 | `<p>${where}Seed ${state.seed}. The game saves after every move, so you can close it and come back.</p>` + | |
33a0256b 2026-10-24 Pavel Safronov | 622 | (modes.length ? `<p class="mode">${modes.length > 1 ? 'Modes' : 'Mode'}: ${modes.join(', ')}.</p>` : '') + |
b89399da 2026-11-01 Pavel Safronov | 623 | channelBox() + |
0b63d158 2026-10-25 Pavel Safronov | 624 | feedbackChoices() + |
5607bea2 2026-10-31 Pavel Safronov | 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()}. ` + | |
bb3823d2 2026-10-31 Pavel Safronov | 628 | `<a href="${REPO_URL}/" target="_blank" rel="noopener">Source code</a></p>` + |
| 629 | (channelLine() ? `<p class="credit">${channelLine()}</p>` : ''), | |
a4fab0fb 2026-10-21 Pavel Safronov | 630 | buttons: [ |
| 631 | { label: 'Resume', kind: 'primary', icon: playIcon }, | |
0bfdc8e6 2026-10-25 Pavel Safronov | 632 | { label: 'New', kind: 'danger', icon: newRunIcon, onClick: newRunDialog }, |
8edb5cb9 2026-11-01 Pavel Safronov | 633 | { label: 'Almanac', kind: 'plain', onClick: () => openAlmanac(undefined, state.options.dev) }, |
0bfdc8e6 2026-10-25 Pavel Safronov | 634 | { label: 'Scores', kind: 'plain', onClick: openScores }, |
805e7a9f 2026-11-03 Pavel Safronov | 635 | { label: 'Changes', kind: 'plain', onClick: () => openChanges() }, |
0bfdc8e6 2026-10-25 Pavel Safronov | 636 | { label: 'Demo', kind: 'plain', onClick: startDemo }, |
| 637 | { label: 'Bug', kind: 'plain', onClick: reportBug }, | |
a4fab0fb 2026-10-21 Pavel Safronov | 638 | ], |
0bfdc8e6 2026-10-25 Pavel Safronov | 639 | className: 'menu', |
a4fab0fb 2026-10-21 Pavel Safronov | 640 | }) |
b89399da 2026-11-01 Pavel Safronov | 641 | document.querySelector<HTMLElement>('#dialog-body .channel-switch')?.addEventListener('click', (e) => { |
| 642 | location.href = (e.currentTarget as HTMLElement).dataset.go! | |
| 643 | }) | |
5607bea2 2026-10-31 Pavel Safronov | 644 | document.getElementById('changes-link')!.addEventListener('click', (e) => { |
| 645 | e.preventDefault() | |
| 646 | closeDialog(() => openChanges()) | |
| 647 | }) | |
0b63d158 2026-10-25 Pavel Safronov | 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', () => { | |
92cc4ee3 2026-11-02 Pavel Safronov | 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() | |
0b63d158 2026-10-25 Pavel Safronov | 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. */ | |
| 666 | function 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>` + | |
92cc4ee3 2026-11-02 Pavel Safronov | 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>` + | |
0b63d158 2026-10-25 Pavel Safronov | 676 | (canVibrate() ? `<label><input type="checkbox" name="vibration"${checked(f.vibration)}><span><b>Vibration</b></span></label>` : '') + |
| 677 | `</fieldset>` | |
| 678 | ) | |
a4fab0fb 2026-10-21 Pavel Safronov | 679 | } |
| 680 | ||
beb9f2b7 2026-10-21 Pavel Safronov | 681 | /** |
00ad7d0a 2026-10-24 Pavel Safronov | 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.) | |
beb9f2b7 2026-10-21 Pavel Safronov | 685 | */ |
| 686 | function 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 | : '' | |
a4fab0fb 2026-10-21 Pavel Safronov | 693 | openDialog({ |
beb9f2b7 2026-10-21 Pavel Safronov | 694 | title: 'New run', |
| 695 | body: | |
| 696 | warning + | |
00ad7d0a 2026-10-24 Pavel Safronov | 697 | `<fieldset class="choices"><legend>Modes</legend>` + |
0b63d158 2026-10-25 Pavel Safronov | 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>` + |
00ad7d0a 2026-10-24 Pavel Safronov | 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>` + |
0580e85e 2026-10-24 Pavel Safronov | 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>` + | |
beb9f2b7 2026-10-21 Pavel Safronov | 702 | `</fieldset>`, |
a4fab0fb 2026-10-21 Pavel Safronov | 703 | buttons: [ |
beb9f2b7 2026-10-21 Pavel Safronov | 704 | { label: 'Start', kind: ongoing ? 'danger' : 'primary', icon: newRunIcon, onClick: startChosenRun }, |
| 705 | { label: ongoing ? 'Keep playing' : 'Cancel', kind: 'plain' }, | |
a4fab0fb 2026-10-21 Pavel Safronov | 706 | ], |
| 707 | }) | |
| 708 | } | |
| 709 | ||
beb9f2b7 2026-10-21 Pavel Safronov | 710 | function startChosenRun(): void { |
| 711 | const form = document.getElementById('dialog-body')! | |
| 712 | const on = (name: string) => form.querySelector<HTMLInputElement>(`input[name=${name}]`)!.checked | |
00ad7d0a 2026-10-24 Pavel Safronov | 713 | const options: RunOptions = { dev: on('dev'), immortal: on('immortal') } |
beb9f2b7 2026-10-21 Pavel Safronov | 714 | rememberOptions(options) |
0580e85e 2026-10-24 Pavel Safronov | 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 | */ | |
| 722 | function 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 | |
beb9f2b7 2026-10-21 Pavel Safronov | 727 | } |
| 728 | ||
36c5d6a2 2026-10-24 Pavel Safronov | 729 | /** Whether auto-move may take this step (path.ts `safeStep`). */ |
82adee18 2026-10-20 Pavel Safronov | 730 | function safeStep(p: Vec): boolean { |
36c5d6a2 2026-10-24 Pavel Safronov | 731 | return isSafeStep(state, danger, p) |
82adee18 2026-10-20 Pavel Safronov | 732 | } |
| 733 | ||
ea380ee8 2026-10-22 Pavel Safronov | 734 | /** |
28670c3e 2026-10-24 Pavel Safronov | 735 | * One move of auto-move: a step, a Run or a Teleport, whichever gets closest (`autoMoveAction`), |
e3d8dc49 2026-10-27 Pavel Safronov | 736 | * only onto safe tiles, and never one that would cost a life (`isDeadly`). True if it moved. |
ea380ee8 2026-10-22 Pavel Safronov | 737 | */ |
82adee18 2026-10-20 Pavel Safronov | 738 | function stepTowardDestination(): boolean { |
28670c3e 2026-10-24 Pavel Safronov | 739 | if (!destination) return false |
| 740 | const action = autoMoveAction(state, destination, safeStep) | |
e3d8dc49 2026-10-27 Pavel Safronov | 741 | if (!action || isDeadly(state, action)) return false |
28670c3e 2026-10-24 Pavel Safronov | 742 | act(action, true) |
82adee18 2026-10-20 Pavel Safronov | 743 | return true |
5551eb37 2026-10-20 Pavel Safronov | 744 | } |
| 745 | ||
92cc4ee3 2026-11-02 Pavel Safronov | 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 | */ | |
| 750 | const AUTOPILOT_KEY = storageKey('autoFinish') | |
| 751 | function autopilotOn(): boolean { | |
41566e2d 2026-11-02 Pavel Safronov | 752 | try { |
92cc4ee3 2026-11-02 Pavel Safronov | 753 | return localStorage.getItem(AUTOPILOT_KEY) !== '0' |
41566e2d 2026-11-02 Pavel Safronov | 754 | } catch { |
| 755 | return true | |
| 756 | } | |
| 757 | } | |
92cc4ee3 2026-11-02 Pavel Safronov | 758 | function setAutopilot(on: boolean): void { |
41566e2d 2026-11-02 Pavel Safronov | 759 | try { |
92cc4ee3 2026-11-02 Pavel Safronov | 760 | localStorage.setItem(AUTOPILOT_KEY, on ? '1' : '0') |
41566e2d 2026-11-02 Pavel Safronov | 761 | } catch { |
| 762 | // Storage is blocked: the setting lasts until the page closes (it reads back as on). | |
| 763 | } | |
| 764 | } | |
| 765 | ||
92cc4ee3 2026-11-02 Pavel Safronov | 766 | /** The note for a controller (or anything else) that cannot switch fullscreen: a pad press is no browser gesture. */ |
| 767 | function fullscreenHint(): void { | |
| 768 | showToast(handIcon, 'Fullscreen', 'Press F11, or use the fullscreen button') | |
41566e2d 2026-11-02 Pavel Safronov | 769 | } |
92cc4ee3 2026-11-02 Pavel Safronov | 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. */ | |
| 772 | function 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. */ | |
| 782 | const fullscreenButton = document.getElementById('fullscreen-button')! | |
| 783 | function 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 | } | |
| 791 | fullscreenButton.hidden = !document.fullscreenEnabled | |
| 792 | fullscreenButton.addEventListener('click', toggleFullscreen) | |
| 793 | document.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. */ | |
| 796 | const autopilotNote = document.createElement('div') | |
| 797 | autopilotNote.id = 'autopilot' | |
| 798 | autopilotNote.hidden = true | |
| 799 | const autopilotText = document.createElement('span') | |
| 800 | autopilotText.setAttribute('role', 'status') | |
| 801 | const autopilotHint = document.createElement('span') | |
| 802 | autopilotHint.className = 'autopilot-hint' | |
| 803 | const autopilotStop = document.createElement('button') | |
| 804 | autopilotStop.type = 'button' | |
| 805 | autopilotStop.textContent = 'Stop' | |
| 806 | autopilotStop.addEventListener('click', () => stopAutopilot()) | |
| 807 | autopilotNote.append(autopilotText, autopilotHint, autopilotStop) | |
| 808 | document.body.append(autopilotNote) | |
| 809 | /** What the note shows now, so it is rebuilt only when it changes. */ | |
| 810 | let autopilotShown = '' | |
| 811 | const 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. */ | |
| 814 | function 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. */ | |
| 819 | function 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')) | |
41566e2d 2026-11-02 Pavel Safronov | 827 | } |
92cc4ee3 2026-11-02 Pavel Safronov | 828 | autopilotNote.style.top = `${controls.insets().top + 8}px` |
| 829 | autopilotNote.hidden = false | |
41566e2d 2026-11-02 Pavel Safronov | 830 | } |
| 831 | ||
| 832 | /** | |
92cc4ee3 2026-11-02 Pavel Safronov | 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. | |
41566e2d 2026-11-02 Pavel Safronov | 838 | */ |
92cc4ee3 2026-11-02 Pavel Safronov | 839 | function 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() | |
41566e2d 2026-11-02 Pavel Safronov | 853 | } |
| 854 | ||
92cc4ee3 2026-11-02 Pavel Safronov | 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. */ |
| 856 | function 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 | } | |
41566e2d 2026-11-02 Pavel Safronov | 866 | |
92cc4ee3 2026-11-02 Pavel Safronov | 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. */ |
| 868 | function autopilotTick(): void { | |
351348d1 2026-11-04 Pavel Safronov | 869 | // The replay page plays alone (Q103). |
| 870 | if (replayOn) return | |
92cc4ee3 2026-11-02 Pavel Safronov | 871 | if (demo || state.status !== 'playing') return stopAutopilot() |
41566e2d 2026-11-02 Pavel Safronov | 872 | if (state.enemies.length > 0) { |
92cc4ee3 2026-11-02 Pavel Safronov | 873 | autopilotArmed = true |
| 874 | return stopAutopilot() | |
41566e2d 2026-11-02 Pavel Safronov | 875 | } |
92cc4ee3 2026-11-02 Pavel Safronov | 876 | if (autopilotArmed) { |
| 877 | autopilotArmed = false | |
| 878 | if (autopilotOn()) startAutopilot() | |
41566e2d 2026-11-02 Pavel Safronov | 879 | } |
92cc4ee3 2026-11-02 Pavel Safronov | 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) | |
41566e2d 2026-11-02 Pavel Safronov | 891 | } |
| 892 | ||
ea380ee8 2026-10-22 Pavel Safronov | 893 | /** Once every enemy is gone, the flag needs no more taps: auto-move steps on its own. */ |
| 894 | function autoStep(): void { | |
92cc4ee3 2026-11-02 Pavel Safronov | 895 | if (demo || autopilot || state.status !== 'playing' || !destination || state.enemies.length > 0) return |
ea380ee8 2026-10-22 Pavel Safronov | 896 | if (selected || inspecting || dialogOpen() || time - lastActionAt < AUTO_STEP_SECONDS) return |
| 897 | stepTowardDestination() | |
| 898 | } | |
| 899 | ||
99869630 2026-10-23 Pavel Safronov | 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 | */ | |
| 904 | function pick(id: ActionId, pressed = selected === id): void { | |
92cc4ee3 2026-11-02 Pavel Safronov | 905 | if (boardLocked()) return |
ebcd0a8f 2026-11-02 Pavel Safronov | 906 | aim = null |
99869630 2026-10-23 Pavel Safronov | 907 | if (inspecting) { |
| 908 | inspecting = false | |
| 909 | afterChange() | |
| 910 | return showInfo(actionInfo(state, id)) | |
| 911 | } | |
| 912 | if (!canPick(state, id)) return | |
82adee18 2026-10-20 Pavel Safronov | 913 | if (id === 'move' && destination && !selected && stepTowardDestination()) return |
e6bb7c42 2026-10-25 Pavel Safronov | 914 | // Wait happens at once. Everything else is picked first, then aimed at a tile. |
d3e4ed26 2026-10-25 Pavel Safronov | 915 | const now = membersOf(id).find((a) => isAvailable(state, a) && !ACTIONS[a].needsTarget) |
e6bb7c42 2026-10-25 Pavel Safronov | 916 | if (now) return act({ id: now }) |
5f066960 2026-10-26 Pavel Safronov | 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. | |
e6bb7c42 2026-10-25 Pavel Safronov | 919 | const self = pressed && selected ? resolvePick(state, selected, state.player) : null |
5f066960 2026-10-26 Pavel Safronov | 920 | if (self === 'freeze') return act({ id: self, target: state.player }) |
e6bb7c42 2026-10-25 Pavel Safronov | 921 | selected = pressed ? null : id |
| 922 | afterChange() | |
5551eb37 2026-10-20 Pavel Safronov | 923 | } |
| 924 | ||
c37a2b0b 2026-10-20 Pavel Safronov | 925 | /** |
| 926 | * A direction key: uses the picked action that way (a bomb or kick next to the player, a throw | |
27f24fed 2026-10-20 Pavel Safronov | 927 | * or run 2 tiles away), or moves if nothing is picked. |
c37a2b0b 2026-10-20 Pavel Safronov | 928 | */ |
5551eb37 2026-10-20 Pavel Safronov | 929 | function direction(d: Dir): void { |
| 930 | if (state.status !== 'playing') return | |
ebcd0a8f 2026-11-02 Pavel Safronov | 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. */ | |
| 936 | interface 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 | ||
| 945 | function directionPlan(d: Dir): StepPlan | null { | |
7f242d36 2026-10-25 Pavel Safronov | 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. | |
99869630 2026-10-23 Pavel Safronov | 949 | const picked: ActionId = selected && ACTIONS[selected].needsTarget ? selected : 'move' |
7f242d36 2026-10-25 Pavel Safronov | 950 | const reaches = selected ? Array.from({ length: Math.max(state.width, state.height) }, (_, i) => i + 1) : [1] |
| 951 | for (const reach of reaches) { | |
99869630 2026-10-23 Pavel Safronov | 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) | |
e3d8dc49 2026-10-27 Pavel Safronov | 954 | if (!id) continue |
bb3823d2 2026-10-31 Pavel Safronov | 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) | |
ebcd0a8f 2026-11-02 Pavel Safronov | 957 | if (options.length > 1) return { id, p, ask: options } |
bb3823d2 2026-10-31 Pavel Safronov | 958 | // Nothing picked and one safe option on a bomb: that one, as a tap takes it (even if a deadlier one comes first). |
ebcd0a8f 2026-11-02 Pavel Safronov | 959 | if (!selected && options.length === 1) return { id: options[0]!, p } |
e3d8dc49 2026-10-27 Pavel Safronov | 960 | // Nothing picked, a key is a shortcut, as a tap is: never one that costs a life. |
ebcd0a8f 2026-11-02 Pavel Safronov | 961 | if (!selected && isDeadly(state, { id, target: p })) return { id, p, refuse: true } |
| 962 | return { id, p } | |
99869630 2026-10-23 Pavel Safronov | 963 | } |
ebcd0a8f 2026-11-02 Pavel Safronov | 964 | return null |
| 965 | } | |
| 966 | ||
| 967 | function 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 }) | |
5551eb37 2026-10-20 Pavel Safronov | 973 | } |
| 974 | ||
| 975 | const controls = createControls({ | |
| 976 | pick, | |
0580e85e 2026-10-24 Pavel Safronov | 977 | banner(second) { |
c37a2b0b 2026-10-20 Pavel Safronov | 978 | if (state.status === 'dead' || state.status === 'cleared') act({ id: 'continue' }) |
0580e85e 2026-10-24 Pavel Safronov | 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() | |
5551eb37 2026-10-20 Pavel Safronov | 982 | }, |
a4fab0fb 2026-10-21 Pavel Safronov | 983 | info: (item) => showInfo(hudInfo(item, state)), |
ea380ee8 2026-10-22 Pavel Safronov | 984 | inspect, |
720cc9d3 2026-11-03 Pavel Safronov | 985 | cancel, |
a4fab0fb 2026-10-21 Pavel Safronov | 986 | menu: showMenu, |
5551eb37 2026-10-20 Pavel Safronov | 987 | }) |
| 988 | ||
| 989 | const 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 | ||
82adee18 2026-10-20 Pavel Safronov | 996 | const ACTION_KEYS: Record<string, ActionId> = { |
00ad7d0a 2026-10-24 Pavel Safronov | 997 | // M picks Move, as B picks Bomb (user request, 2026-10-23; the button said "WASD"). |
| 998 | KeyM: 'move', | |
52d68b59 2026-10-24 Pavel Safronov | 999 | KeyB: 'bomb', KeyR: 'run', KeyX: 'detonate', KeyK: 'kick', KeyT: 'throw', KeyG: 'diagonal', KeyP: 'teleport', |
7f242d36 2026-10-25 Pavel Safronov | 1000 | // Added 2026-10-24: F fires an RPG bomb, I an ICBM, L slides. |
| 1001 | KeyF: 'rpg', KeyI: 'icbm', KeyL: 'slide', | |
d3e4ed26 2026-10-25 Pavel Safronov | 1002 | // V uses the charge held (2026-10-24, one charge at a time): next to B for Bomb. |
| 1003 | KeyV: 'charged', | |
82adee18 2026-10-20 Pavel Safronov | 1004 | } |
| 1005 | ||
00ad7d0a 2026-10-24 Pavel Safronov | 1006 | /** Q / E / Z / C: a diagonal step up-left, up-right, down-left, down-right (with Diagonal). */ |
82adee18 2026-10-20 Pavel Safronov | 1007 | const 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 } } |
c37a2b0b 2026-10-20 Pavel Safronov | 1008 | |
ebcd0a8f 2026-11-02 Pavel Safronov | 1009 | /** A diagonal step (Q / E / Z / C), as a key takes it: never one that costs a life. */ |
| 1010 | function stepDiagonal(diag: Vec): void { | |
| 1011 | runStep(diagonalPlan(diag)) | |
| 1012 | } | |
| 1013 | ||
| 1014 | function 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 | */ | |
| 1025 | function 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 | ||
5551eb37 2026-10-20 Pavel Safronov | 1036 | window.addEventListener('keydown', (e) => { |
a4fab0fb 2026-10-21 Pavel Safronov | 1037 | // The dialog has the keyboard while it is open (Esc closes it, Tab moves between its buttons). |
92cc4ee3 2026-11-02 Pavel Safronov | 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 | } | |
a4fab0fb 2026-10-21 Pavel Safronov | 1050 | if (e.repeat || dialogOpen()) return |
92cc4ee3 2026-11-02 Pavel Safronov | 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 | |
5551eb37 2026-10-20 Pavel Safronov | 1058 | const d = KEY_DIRS[e.code] |
92cc4ee3 2026-11-02 Pavel Safronov | 1059 | const keys = inputMode === 'keys' |
| 1060 | const dir = keyDirection(e.code) | |
00ad7d0a 2026-10-24 Pavel Safronov | 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 | |
92cc4ee3 2026-11-02 Pavel Safronov | 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) | |
ebcd0a8f 2026-11-02 Pavel Safronov | 1069 | else if (d) direction(d) |
92cc4ee3 2026-11-02 Pavel Safronov | 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]!) | |
5551eb37 2026-10-20 Pavel Safronov | 1073 | else if (e.code === 'Escape') { |
92cc4ee3 2026-11-02 Pavel Safronov | 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() | |
ebcd0a8f 2026-11-02 Pavel Safronov | 1077 | if (!cancel()) showMenu() |
92cc4ee3 2026-11-02 Pavel Safronov | 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() | |
99869630 2026-10-23 Pavel Safronov | 1082 | } |
5551eb37 2026-10-20 Pavel Safronov | 1083 | }) |
92cc4ee3 2026-11-02 Pavel Safronov | 1084 | window.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. | |
| 1088 | addEventListener('blur', () => endPan()) | |
| 1089 | ||
| 1090 | /** Shift + arrow: the keyboard's cursor hops that way. The press that wakes it only shows it, on the player. */ | |
| 1091 | function 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. */ | |
| 1100 | function 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. */ | |
| 1112 | function keyInspect(): void { | |
720cc9d3 2026-11-03 Pavel Safronov | 1113 | if (inspecting && aimExplain()) return |
92cc4ee3 2026-11-02 Pavel Safronov | 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. */ | |
| 1119 | function keyEnter(): void { | |
| 1120 | if (boardLocked()) return | |
720cc9d3 2026-11-03 Pavel Safronov | 1121 | if (inspecting) { |
| 1122 | if (!aimExplain()) aimInspect() | |
| 1123 | return | |
| 1124 | } | |
92cc4ee3 2026-11-02 Pavel Safronov | 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). */ | |
| 1137 | function 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 | } | |
5551eb37 2026-10-20 Pavel Safronov | 1142 | |
82adee18 2026-10-20 Pavel Safronov | 1143 | /** Start timing a long press on `p`, or with null, cancel any press in progress. */ |
e6bb7c42 2026-10-25 Pavel Safronov | 1144 | /** A press on the board that may become a drag: where it is now, and whether it moves the view. */ |
| 1145 | let 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. */ | |
| 1147 | const DRAG_PIXELS = 12 | |
| 1148 | ||
82adee18 2026-10-20 Pavel Safronov | 1149 | function startPress(p: Vec | null): void { |
| 1150 | clearTimeout(pressTimer) | |
| 1151 | armed = null | |
| 1152 | pressTile = p | |
92cc4ee3 2026-11-02 Pavel Safronov | 1153 | if (!p || boardLocked() || selected || inspecting || state.status !== 'playing' || !isValidTarget(state, 'bomb', p)) return |
e3d8dc49 2026-10-27 Pavel Safronov | 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 | |
82adee18 2026-10-20 Pavel Safronov | 1156 | pressTimer = window.setTimeout(() => { |
| 1157 | armed = p | |
| 1158 | navigator.vibrate?.(15) | |
| 1159 | }, LONG_PRESS_MS) | |
| 1160 | } | |
| 1161 | ||
5551eb37 2026-10-20 Pavel Safronov | 1162 | function tileUnder(e: PointerEvent): Vec | null { |
| 1163 | const r = mainCanvas.getBoundingClientRect() | |
e6bb7c42 2026-10-25 Pavel Safronov | 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) | |
5551eb37 2026-10-20 Pavel Safronov | 1169 | return inBounds(state, p) ? p : null |
| 1170 | } | |
| 1171 | ||
ebcd0a8f 2026-11-02 Pavel Safronov | 1172 | /** How fast the right stick moves the view at full push, in tiles a second (the controller, ui/gamepad.ts). */ |
| 1173 | const 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. */ | |
| 1176 | let padActive = false | |
92cc4ee3 2026-11-02 Pavel Safronov | 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 | */ | |
| 1182 | let inputMode: InputMode = null | |
| 1183 | /** This key press woke the keyboard's cursor (the mode was not 'keys' before it): it only shows the cursor. */ | |
| 1184 | let 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. */ | |
| 1186 | let keysFromInspect = false | |
| 1187 | function setInputMode(mode: InputMode): void { | |
| 1188 | inputMode = mode | |
| 1189 | padActive = mode === 'pad' | |
| 1190 | } | |
83b956b0 2026-11-02 Pavel Safronov | 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. */ |
| 1192 | let padWoke = false | |
ebcd0a8f 2026-11-02 Pavel Safronov | 1193 | /** The demo changes scene at most this often from the pad: a held stick repeats faster than a scene can be made. */ |
| 1194 | const PAD_SCENE_MS = 400 | |
| 1195 | let lastPadScene = -Infinity | |
805e7a9f 2026-11-03 Pavel Safronov | 1196 | function padDemoControl(act: () => void): void { |
ebcd0a8f 2026-11-02 Pavel Safronov | 1197 | if (!demo || performance.now() - lastPadScene < PAD_SCENE_MS) return |
| 1198 | lastPadScene = performance.now() | |
805e7a9f 2026-11-03 Pavel Safronov | 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 | */ | |
| 1205 | const PAD_ONCE_GAP_MS = 300 | |
| 1206 | let lastPadOnce = -Infinity | |
| 1207 | function 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 | } | |
| 1213 | function padScene(delta: number): void { | |
| 1214 | padDemoControl(() => skipTo(adjacentScene(demo!.index, delta))) | |
ebcd0a8f 2026-11-02 Pavel Safronov | 1215 | } |
| 1216 | ||
| 1217 | /** "What is that?": the aim on a tile to explain (`aim`), with what pressing again does. */ | |
| 1218 | function 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 | */ | |
92cc4ee3 2026-11-02 Pavel Safronov | 1227 | let 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. */ | |
| 1229 | interface CursorState { | |
| 1230 | tile: Vec | |
| 1231 | tiles: Vec[] | |
| 1232 | far?: FarTile[] | |
| 1233 | actions: TileAction[] | |
| 1234 | } | |
ebcd0a8f 2026-11-02 Pavel Safronov | 1235 | /** The kind of pad in use, for the names in the cursor's label. */ |
| 1236 | let padKind: PadFamily = 'generic' | |
| 1237 | ||
92cc4ee3 2026-11-02 Pavel Safronov | 1238 | /** The faded tiles for the state as it is (`farTiles` walks the whole floor, so once per turn, not per hop). */ |
| 1239 | let farCache: { state: GameState; danger: Danger; destination: Vec | null; far: FarTile[] } | null = null | |
| 1240 | function 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 | */ | |
| 1253 | let previewCache: { state: GameState; danger: Danger; tile: Vec | null; view: RouteView | null } | null = null | |
| 1254 | function 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. */ | |
| 1266 | function cursorCandidates(c: { tiles: readonly Vec[]; far?: readonly FarTile[] }): Vec[] { | |
| 1267 | return [...c.tiles, ...(c.far ?? []).map((f) => f.tile)] | |
| 1268 | } | |
| 1269 | ||
ebcd0a8f 2026-11-02 Pavel Safronov | 1270 | function setCursor(tile: Vec): void { |
92cc4ee3 2026-11-02 Pavel Safronov | 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))) } | |
ebcd0a8f 2026-11-02 Pavel Safronov | 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. */ | |
| 1278 | function refreshCursor(): void { | |
| 1279 | if (!cursor) return | |
92cc4ee3 2026-11-02 Pavel Safronov | 1280 | const tiles = cursorCandidates({ tiles: actionableTiles(state), far: currentFar() }) |
ebcd0a8f 2026-11-02 Pavel Safronov | 1281 | const tile = state.status === 'playing' ? keepCursor(tiles, cursor.tile) : null |
| 1282 | if (tile) setCursor(tile) | |
| 1283 | else cursor = null | |
| 1284 | } | |
| 1285 | ||
aef53511 2026-11-02 Pavel Safronov | 1286 | /** |
92cc4ee3 2026-11-02 Pavel Safronov | 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. | |
aef53511 2026-11-02 Pavel Safronov | 1289 | */ |
| 1290 | function ensureCursor(): void { | |
92cc4ee3 2026-11-02 Pavel Safronov | 1291 | const wanted = cursorWanted({ padActive: inputMode !== null, demo: !!demo, dialogOpen: dialogOpen(), inspecting, offerPending: !!(state.offer || state.chargeOffer), autopilot, status: state.status }) |
aef53511 2026-11-02 Pavel Safronov | 1292 | if (!wanted) cursor = null |
720cc9d3 2026-11-03 Pavel Safronov | 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 | } | |
aef53511 2026-11-02 Pavel Safronov | 1299 | } |
| 1300 | ||
720cc9d3 2026-11-03 Pavel Safronov | 1301 | /** Wait was pressed with the cursor on the player's own tile: the cursor starts there again, not on a walk's destination. */ |
| 1302 | let cursorPinned = false | |
| 1303 | ||
aef53511 2026-11-02 Pavel Safronov | 1304 | /** A d-pad press: the cursor moves to the next actionable tile that way (from the player's tile, if it was resting). */ |
ebcd0a8f 2026-11-02 Pavel Safronov | 1305 | function cursorMove(dx: Step, dy: Step): void { |
aef53511 2026-11-02 Pavel Safronov | 1306 | ensureCursor() |
720cc9d3 2026-11-03 Pavel Safronov | 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)) | |
ebcd0a8f 2026-11-02 Pavel Safronov | 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 | */ | |
| 1320 | function 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 | |
720cc9d3 2026-11-03 Pavel Safronov | 1325 | if (button === 'wait') { |
| 1326 | cursorPinned = eq(c.tile, state.player) | |
| 1327 | return pick('wait', false) | |
| 1328 | } | |
92cc4ee3 2026-11-02 Pavel Safronov | 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 | } | |
ebcd0a8f 2026-11-02 Pavel Safronov | 1334 | selected = button |
| 1335 | afterChange() | |
| 1336 | tapTile(c.tile) | |
| 1337 | } | |
| 1338 | ||
| 1339 | /** | |
720cc9d3 2026-11-03 Pavel Safronov | 1340 | * L1: in "What is that?" puts the aim on you (Q85, Q86); else what is left picked (after a cancelled choice |
aef53511 2026-11-02 Pavel Safronov | 1341 | * dialog) and the flag, as Esc does. The cursor then goes back to the player's own tile. |
ebcd0a8f 2026-11-02 Pavel Safronov | 1342 | */ |
| 1343 | function padBack(): void { | |
720cc9d3 2026-11-03 Pavel Safronov | 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) { | |
ebcd0a8f 2026-11-02 Pavel Safronov | 1348 | selected = null |
| 1349 | destination = null | |
| 1350 | } | |
aef53511 2026-11-02 Pavel Safronov | 1351 | cursor = null |
ebcd0a8f 2026-11-02 Pavel Safronov | 1352 | aim = null |
| 1353 | afterChange() | |
| 1354 | } | |
| 1355 | ||
720cc9d3 2026-11-03 Pavel Safronov | 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 | */ | |
| 1360 | let inspectDialog = false | |
| 1361 | document.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. */ | |
| 1366 | const INSPECT_HOLD = false | |
| 1367 | ||
| 1368 | /** Circle in "What is that?": the mode ends, the cursor comes back (Q85). */ | |
| 1369 | function 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 | */ | |
92cc4ee3 2026-11-02 Pavel Safronov | 1381 | function aimStep(dx: Step, dy: Step): void { |
720cc9d3 2026-11-03 Pavel Safronov | 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 | ||
| 1402 | const 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. */ | |
| 1408 | function hudAimItems(): HTMLElement[] { | |
| 1409 | return [...document.querySelectorAll<HTMLElement>('#hud [data-info]')].filter((el) => el.getClientRects().length > 0) | |
92cc4ee3 2026-11-02 Pavel Safronov | 1410 | } |
| 1411 | ||
720cc9d3 2026-11-03 Pavel Safronov | 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). */ |
| 1413 | function 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. */ | |
| 1428 | function 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). */ | |
ebcd0a8f 2026-11-02 Pavel Safronov | 1434 | function aimInspect(): void { |
720cc9d3 2026-11-03 Pavel Safronov | 1435 | const tiles = explainableTiles(state, true) |
| 1436 | const first = aimStart(state, tiles) | |
ebcd0a8f 2026-11-02 Pavel Safronov | 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. */ | |
| 1441 | function aimShown(): boolean { | |
92cc4ee3 2026-11-02 Pavel Safronov | 1442 | return !!(aim ?? cursor) && inputMode !== null && !demo && !dialogOpen() && !autopilot && state.status === 'playing' |
ebcd0a8f 2026-11-02 Pavel Safronov | 1443 | } |
| 1444 | ||
| 1445 | let lastBarKey = '' | |
| 1446 | let lastNote = '' | |
aef53511 2026-11-02 Pavel Safronov | 1447 | let lastHintKey = '' |
92cc4ee3 2026-11-02 Pavel Safronov | 1448 | /** The demo's virtual controller (ui/padOverlay.ts), under its words; shown when the demo plays for a pad. */ |
| 1449 | const demoPad = createPadOverlay('large') | |
| 1450 | demoPad.el.hidden = true | |
| 1451 | document.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. */ | |
| 1453 | const demoKeys = createKeyOverlay('large') | |
| 1454 | demoKeys.el.hidden = true | |
| 1455 | demoPad.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). */ | |
| 1457 | function 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. */ | |
| 1461 | const 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 | */ | |
| 1468 | let demoForced: DemoInput | null = null | |
| 1469 | /** What the demo is shown for now (ui/demoInput.ts): the pick, else the detected input. */ | |
| 1470 | const 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. */ | |
| 1472 | const 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. */ | |
| 1474 | const demoHint = document.querySelector('#demo-title .demo-hint')! | |
| 1475 | function 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) | |
805e7a9f 2026-11-03 Pavel Safronov | 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 | */ | |
| 1484 | function 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())) | |
92cc4ee3 2026-11-02 Pavel Safronov | 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). */ | |
| 1508 | function 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. */ | |
| 1515 | function dropDemoPick(): void { | |
| 1516 | if (!demoForced) return | |
| 1517 | demoForced = null | |
| 1518 | if (demo) { | |
| 1519 | syncInputButton() | |
| 1520 | skipTo(demo.index) | |
| 1521 | } | |
| 1522 | } | |
ebcd0a8f 2026-11-02 Pavel Safronov | 1523 | const pad = createPadInput({ |
| 1524 | active() { | |
83b956b0 2026-11-02 Pavel Safronov | 1525 | padWoke = !padActive |
92cc4ee3 2026-11-02 Pavel Safronov | 1526 | setInputMode('pad') |
| 1527 | keysFromInspect = false | |
ebcd0a8f 2026-11-02 Pavel Safronov | 1528 | document.body.classList.add('pad') |
92cc4ee3 2026-11-02 Pavel Safronov | 1529 | syncClipPad() |
| 1530 | // The demo's last line shows the button of the pad that is really in hand. | |
| 1531 | if (padWoke && demo) syncDemoHint() | |
ebcd0a8f 2026-11-02 Pavel Safronov | 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) { | |
92cc4ee3 2026-11-02 Pavel Safronov | 1536 | dropDemoPick() |
ebcd0a8f 2026-11-02 Pavel Safronov | 1537 | padKind = f |
| 1538 | controls.setPadFamily(f) | |
| 1539 | padDialog.setFamily(f) | |
92cc4ee3 2026-11-02 Pavel Safronov | 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() | |
ebcd0a8f 2026-11-02 Pavel Safronov | 1544 | }, |
92cc4ee3 2026-11-02 Pavel Safronov | 1545 | gone: dropDemoPick, |
ebcd0a8f 2026-11-02 Pavel Safronov | 1546 | toast: (title, text) => showToast(handIcon, title, text), |
| 1547 | dir(dx, dy) { | |
805e7a9f 2026-11-03 Pavel Safronov | 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 | } | |
ebcd0a8f 2026-11-02 Pavel Safronov | 1554 | if (dialogOpen()) return padDialog.move(dx, dy) |
92cc4ee3 2026-11-02 Pavel Safronov | 1555 | if (state.status !== 'playing' || boardLocked()) return |
ebcd0a8f 2026-11-02 Pavel Safronov | 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. | |
92cc4ee3 2026-11-02 Pavel Safronov | 1559 | aimStep(dx, dy) |
ebcd0a8f 2026-11-02 Pavel Safronov | 1560 | return |
| 1561 | } | |
| 1562 | cursorMove(dx, dy) | |
| 1563 | }, | |
92cc4ee3 2026-11-02 Pavel Safronov | 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). |
720cc9d3 2026-11-03 Pavel Safronov | 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()), | |
92cc4ee3 2026-11-02 Pavel Safronov | 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 | }, | |
ebcd0a8f 2026-11-02 Pavel Safronov | 1592 | press(button, face) { |
| 1593 | if (demo) { | |
92cc4ee3 2026-11-02 Pavel Safronov | 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() | |
805e7a9f 2026-11-03 Pavel Safronov | 1597 | } else if (button === 'info') cycleDemoInput() |
| 1598 | else if (button !== 'reset') endDemo() | |
ebcd0a8f 2026-11-02 Pavel Safronov | 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 | |
92cc4ee3 2026-11-02 Pavel Safronov | 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 | } | |
ebcd0a8f 2026-11-02 Pavel Safronov | 1622 | if (button === 'reset') resetPan() |
| 1623 | else if (button === 'cancel') padBack() | |
| 1624 | else if (button === 'info') { | |
720cc9d3 2026-11-03 Pavel Safronov | 1625 | // RB or View: "What is that?" on, and aimed. |
| 1626 | // Q85: it turns the mode on; leaving is Circle, explaining is Cross. | |
ebcd0a8f 2026-11-02 Pavel Safronov | 1627 | cursor = null |
720cc9d3 2026-11-03 Pavel Safronov | 1628 | if (inspecting) { |
| 1629 | if (!aim) aimInspect() | |
| 1630 | } else { | |
ebcd0a8f 2026-11-02 Pavel Safronov | 1631 | inspect() |
| 1632 | if (inspecting) aimInspect() | |
| 1633 | } | |
| 1634 | } else if (face !== undefined) { | |
92cc4ee3 2026-11-02 Pavel Safronov | 1635 | // Move/Go (Cross, A) explains the aimed tile while "What is that?" is on; the other face buttons do nothing then. |
ebcd0a8f 2026-11-02 Pavel Safronov | 1636 | if (inspecting) { |
720cc9d3 2026-11-03 Pavel Safronov | 1637 | // Cross (confirm) views, Circle (cancel) leaves (Q85); a press each (Q88). |
| 1638 | if (button === 'move') aimExplain() | |
| 1639 | else if (button === 'wait') leaveInspect() | |
83b956b0 2026-11-02 Pavel Safronov | 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() | |
92cc4ee3 2026-11-02 Pavel Safronov | 1644 | if (!woke && (button === 'move' || button === 'wait' || button === 'bomb' || button === 'charge')) padDo(barButtonFor(button)) |
83b956b0 2026-11-02 Pavel Safronov | 1645 | } |
ebcd0a8f 2026-11-02 Pavel Safronov | 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(), | |
92cc4ee3 2026-11-02 Pavel Safronov | 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`). */ | |
| 1656 | function 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`). */ | |
| 1665 | function keyCap(text: string): HTMLElement { | |
| 1666 | const cap = document.createElement('kbd') | |
| 1667 | cap.className = 'key-cap' | |
| 1668 | cap.textContent = text | |
| 1669 | return cap | |
| 1670 | } | |
ebcd0a8f 2026-11-02 Pavel Safronov | 1671 | /** The one-line note over the action bar: what confirming the controller's target does (style.css `#pad-label`). */ |
| 1672 | const padLabel = document.createElement('div') | |
| 1673 | padLabel.id = 'pad-label' | |
| 1674 | padLabel.hidden = true | |
| 1675 | padLabel.setAttribute('role', 'status') | |
| 1676 | document.body.append(padLabel) | |
aef53511 2026-11-02 Pavel Safronov | 1677 | /** The legend for L1 and R1, above the label (style.css `#pad-hint`): shown while the cursor is. */ |
| 1678 | const padHint = document.createElement('div') | |
| 1679 | padHint.id = 'pad-hint' | |
| 1680 | padHint.hidden = true | |
| 1681 | document.body.append(padHint) | |
ebcd0a8f 2026-11-02 Pavel Safronov | 1682 | // A dialog opening puts the cursor away, so closing the menu does not bring the old one back. |
| 1683 | document.getElementById('dialog')!.addEventListener('dialogopen', () => { | |
| 1684 | cursor = null | |
| 1685 | }) | |
92cc4ee3 2026-11-02 Pavel Safronov | 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. */ |
| 1687 | function handBack(next: InputMode): void { | |
| 1688 | const was = padActive | |
| 1689 | const before = inputMode | |
720cc9d3 2026-11-03 Pavel Safronov | 1690 | cursorPinned = false |
92cc4ee3 2026-11-02 Pavel Safronov | 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() | |
ebcd0a8f 2026-11-02 Pavel Safronov | 1701 | } |
92cc4ee3 2026-11-02 Pavel Safronov | 1702 | // Any key or press hands the board over, except the keys the keyboard's cursor itself uses (ui/keyCursor.ts `modeAfterKey`). |
| 1703 | addEventListener( | |
| 1704 | 'keydown', | |
| 1705 | (e) => { | |
720cc9d3 2026-11-03 Pavel Safronov | 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 | |
92cc4ee3 2026-11-02 Pavel Safronov | 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 | ) | |
| 1717 | addEventListener('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. | |
| 1720 | addEventListener('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. | |
ebcd0a8f 2026-11-02 Pavel Safronov | 1726 | // LittleJS would also read the pad (its own d-pad-as-keys); this game does it itself. |
| 1727 | setGamepadsEnable(false) | |
| 1728 | ||
beb9f2b7 2026-10-21 Pavel Safronov | 1729 | if (!resumeSaved()) startRun(Number(params.get('seed')) || randomSeed(), testing ? urlOptions() : preferredOptions()) |
5551eb37 2026-10-20 Pavel Safronov | 1730 | |
351348d1 2026-11-04 Pavel Safronov | 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 | */ | |
| 1739 | const REPLAY_STEP_SECONDS = 0.55 | |
| 1740 | const REPLAY_HOLD_SECONDS = 3 | |
| 1741 | let replayScript: ReplayScript | null = null | |
| 1742 | interface 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 | } | |
| 1754 | let replayRun: ReplayRun | null = null | |
| 1755 | let replayLabel: HTMLElement | null = null | |
| 1756 | let rulesCompare: RuleComparison | null = null | |
| 1757 | let rulesRef = '' | |
| 1758 | if (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. */ | |
| 1787 | function 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 | ||
| 1795 | const 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. */ | |
| 1798 | const RULES_LEAD_TURNS = 6 | |
| 1799 | const RULES_TAIL_TURNS = 15 | |
| 1800 | interface RulesRun { | |
| 1801 | i: number | |
| 1802 | end: number | |
| 1803 | at: number | |
| 1804 | blue: GameState | |
| 1805 | blueAnim: ReturnType<typeof newAnimation> | |
| 1806 | held: boolean | |
| 1807 | } | |
| 1808 | let rulesRun: RulesRun | null = null | |
| 1809 | ||
| 1810 | /** The state of one side after turn `k` (-1: the start); a side that stopped keeps its last. */ | |
| 1811 | const sideState = (side: RuleComparison['red'], k: number): GameState => (k < 0 ? side.start : side.results[Math.min(k, side.results.length - 1)]?.state ?? side.start) | |
| 1812 | ||
| 1813 | function 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 | ||
| 1824 | function 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 | ||
| 1855 | function 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 | ||
52d68b59 2026-10-24 Pavel Safronov | 1891 | /** |
| 1892 | * The demo, before the game starts (user request, 2026-10-23: some players never played the | |
36c5d6a2 2026-10-24 Pavel Safronov | 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. | |
52d68b59 2026-10-24 Pavel Safronov | 1901 | */ |
36c5d6a2 2026-10-24 Pavel Safronov | 1902 | /** How long the finger takes to get somewhere, a tap lasts, and a button stays picked before its tile. */ |
| 1903 | const FINGER_MOVE_SECONDS = 0.45 | |
| 1904 | const TAP_SECONDS = 0.18 | |
| 1905 | const PICKED_SECONDS = 0.45 | |
| 1906 | /** After a turn: time for its animations before the finger moves on. */ | |
| 1907 | const DEMO_SETTLE_SECONDS = 0.55 | |
52d68b59 2026-10-24 Pavel Safronov | 1908 | /** How long a scene's last turn stays on screen, and how long before a scene's first move. */ |
| 1909 | const DEMO_HOLD_SECONDS = 1.8 | |
| 1910 | const DEMO_LEAD_SECONDS = 0.8 | |
36c5d6a2 2026-10-24 Pavel Safronov | 1911 | /** One beat of the demo's script: something done, then a pause before the next. */ |
| 1912 | interface Beat { | |
| 1913 | run: () => void | |
| 1914 | then: number | |
| 1915 | } | |
58f1e700 2026-10-24 Pavel Safronov | 1916 | /** `rest`: the finger is still to be put beside the player, once the camera shows the new scene. */ |
33a0256b 2026-10-24 Pavel Safronov | 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 | */ | |
| 1921 | let 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 | |
92cc4ee3 2026-11-02 Pavel Safronov | 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 | |
33a0256b 2026-10-24 Pavel Safronov | 1938 | } | null = null |
52d68b59 2026-10-24 Pavel Safronov | 1939 | // The scenes' seeds: from `?seed` for a repeatable demo, otherwise random. |
| 1940 | let demoSeed = Number(params.get('seed')) || randomSeed() | |
e3384c6b 2026-10-24 Pavel Safronov | 1941 | /** |
92cc4ee3 2026-11-02 Pavel Safronov | 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). | |
e3384c6b 2026-10-24 Pavel Safronov | 1945 | */ |
92cc4ee3 2026-11-02 Pavel Safronov | 1946 | type StillItem = 'topBar' | 'whatIsThat' | 'padCursor' | 'padL1' | 'padR1' | 'padStick' | 'padLook' | 'padStart' | KeyStill |
| 1947 | type 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. */ | |
| 1949 | const 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 | } | |
| 1953 | const isStill = (item: DemoItem): item is StillItem => item in STILL_ROOMS | |
| 1954 | const isKeyStill = (item: StillItem): item is KeyStill => (KEY_STILLS as readonly string[]).includes(item) | |
33a0256b 2026-10-24 Pavel Safronov | 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 | */ | |
| 1960 | const 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)), | |
8edb5cb9 2026-11-01 Pavel Safronov | 1966 | // Developer mode's edge-case checks (sim/testScenes.ts), with `?dev`. |
| 1967 | ...(params.has('dev') ? TEST_KINDS : []), | |
33a0256b 2026-10-24 Pavel Safronov | 1968 | ] |
92cc4ee3 2026-11-02 Pavel Safronov | 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`). */ |
| 1970 | const 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`). */ | |
| 1972 | const KEY_DEMO_ORDER: readonly DemoItem[] = keyDemoOrder(DEMO_ORDER) | |
| 1973 | /** Which order of scenes an input plays: touch and mouse share one. */ | |
| 1974 | type OrderKind = 'touch' | 'pad' | 'keys' | |
| 1975 | const orderKind = (pad: boolean, keys: boolean): OrderKind => (pad ? 'pad' : keys ? 'keys' : 'touch') | |
| 1976 | const 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. */ | |
| 1978 | const demoKind = (): OrderKind => orderKind(!!demo?.pad, !!demo?.keys) | |
| 1979 | /** The order the demo plays in now. */ | |
| 1980 | const 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. */ | |
| 1982 | const DEMO_KEYS: Record<OrderKind, string> = { touch: storageKey('demoAt'), pad: storageKey('demoAtPad'), keys: storageKey('demoAtKeys') } | |
| 1983 | ||
| 1984 | function savedDemoIndex(kind = demoKind()): number { | |
33a0256b 2026-10-24 Pavel Safronov | 1985 | try { |
92cc4ee3 2026-11-02 Pavel Safronov | 1986 | const n = Number(localStorage.getItem(DEMO_KEYS[kind])) |
| 1987 | return !testing && Number.isInteger(n) && n >= 0 && n < orderFor(kind).length ? n : 0 | |
33a0256b 2026-10-24 Pavel Safronov | 1988 | } catch { |
| 1989 | return 0 | |
| 1990 | } | |
| 1991 | } | |
| 1992 | ||
| 1993 | function saveDemoIndex(i: number): void { | |
| 1994 | if (testing) return | |
| 1995 | try { | |
92cc4ee3 2026-11-02 Pavel Safronov | 1996 | localStorage.setItem(DEMO_KEYS[demoKind()], String(i)) |
33a0256b 2026-10-24 Pavel Safronov | 1997 | } catch { |
| 1998 | // No storage: the demo starts from the beginning next time. | |
| 1999 | } | |
| 2000 | } | |
bb3823d2 2026-10-31 Pavel Safronov | 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 | |
92cc4ee3 2026-11-02 Pavel Safronov | 2005 | * indices into DEMO_ORDER (the pad's: PAD_DEMO_ORDER), in order or shuffled; ◀ ▶ and the automatic next scene follow it. |
bb3823d2 2026-10-31 Pavel Safronov | 2006 | */ |
| 2007 | const SHUFFLE_KEY = storageKey('demoShuffle') | |
| 2008 | let demoShuffle = ((): boolean => { | |
| 2009 | try { | |
| 2010 | return !testing && localStorage.getItem(SHUFFLE_KEY) === '1' | |
| 2011 | } catch { | |
| 2012 | return false | |
| 2013 | } | |
| 2014 | })() | |
| 2015 | let scenePlan: number[] = [] | |
| 2016 | ||
| 2017 | /** `scenePlan` made again: in order, or shuffled with `first` (the scene playing) kept at the front. */ | |
| 2018 | function makeScenePlan(first: number): void { | |
92cc4ee3 2026-11-02 Pavel Safronov | 2019 | scenePlan = demoOrder().map((_, i) => i) |
bb3823d2 2026-10-31 Pavel Safronov | 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. */ | |
| 2030 | function adjacentScene(index: number, delta: number): number { | |
| 2031 | const n = scenePlan.length | |
| 2032 | return scenePlan[(scenePlan.indexOf(index) + delta + n) % n]! | |
| 2033 | } | |
| 2034 | ||
| 2035 | function 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 | } | |
52d68b59 2026-10-24 Pavel Safronov | 2054 | const demoPanel = document.getElementById('demo')! |
e6bb7c42 2026-10-25 Pavel Safronov | 2055 | /** The bars round the view, so every screen sees the same 9 × 13 tiles (render/camera.ts). */ |
| 2056 | const viewMask = document.getElementById('view-mask')! | |
0bfdc8e6 2026-10-25 Pavel Safronov | 2057 | /** The demo's title, on top above the TV (user request, 2026-10-25: title, TV, caption). */ |
| 2058 | const demoTitle = document.getElementById('demo-title')! | |
33a0256b 2026-10-24 Pavel Safronov | 2059 | const demoCount = document.getElementById('demo-count')! |
b8849d36 2026-11-01 Pavel Safronov | 2060 | /** The bar of the scene's turns, with its moment marked (ui/timeline.ts). */ |
| 2061 | const demoTime = bindTimeline(document.getElementById('demo-time')!) | |
33a0256b 2026-10-24 Pavel Safronov | 2062 | // The demo's buttons: the scene before, the scene after, and back to the first. |
bb3823d2 2026-10-31 Pavel Safronov | 2063 | document.getElementById('demo-prev')!.addEventListener('click', () => demo && skipTo(adjacentScene(demo.index, -1))) |
| 2064 | document.getElementById('demo-next')!.addEventListener('click', () => demo && skipTo(adjacentScene(demo.index, 1))) | |
805e7a9f 2026-11-03 Pavel Safronov | 2065 | function demoRestart(): void { |
bb3823d2 2026-10-31 Pavel Safronov | 2066 | // Back to the first scene; shuffled, the order is made again from there. |
| 2067 | makeScenePlan(0) | |
| 2068 | skipTo(0) | |
805e7a9f 2026-11-03 Pavel Safronov | 2069 | } |
| 2070 | document.getElementById('demo-restart')!.addEventListener('click', demoRestart) | |
bb3823d2 2026-10-31 Pavel Safronov | 2071 | const shuffleButton = document.getElementById('demo-shuffle')! |
805e7a9f 2026-11-03 Pavel Safronov | 2072 | function demoShuffleToggle(): void { |
| 2073 | if (demo) setDemoShuffle(!demoShuffle) | |
| 2074 | } | |
| 2075 | shuffleButton.addEventListener('click', demoShuffleToggle) | |
52d68b59 2026-10-24 Pavel Safronov | 2076 | const demoCaption = document.getElementById('demo-caption')! |
36c5d6a2 2026-10-24 Pavel Safronov | 2077 | const actionBar = document.getElementById('actions')! |
| 2078 | const finger = document.getElementById('demo-finger')! | |
58f1e700 2026-10-24 Pavel Safronov | 2079 | const youTag = document.getElementById('demo-you')! |
e3384c6b 2026-10-24 Pavel Safronov | 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 | */ | |
| 2087 | const crtScreen = document.getElementById('demo-crt')! | |
| 2088 | /** The CRT's widest shape, width to height: an old TV's 4:3. */ | |
| 2089 | const 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): | |
33a0256b 2026-10-24 Pavel Safronov | 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. | |
e3384c6b 2026-10-24 Pavel Safronov | 2095 | const CRT_SHADER = postProcessEffects( |
| 2096 | ` // interference | |
| 2097 | float _torn = step(.75, fract(sin(floor(p.y / 4.) * 12.9898 + floor(iTime * 24.)) * 43758.5453)); | |
33a0256b 2026-10-24 Pavel Safronov | 2098 | uv.x += interference * (_torn * .015 + .003 * sin(uv.y * 90. + iTime * 70.)); |
e3384c6b 2026-10-24 Pavel Safronov | 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 | */ | |
| 2110 | const VACUUM_SECONDS = 0.5 | |
| 2111 | let vacuumAt = Infinity | |
| 2112 | ||
| 2113 | /** Called every frame of the demo: the interference's strength now, for the shader and the screen. */ | |
| 2114 | function 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 | } | |
92cc4ee3 2026-11-02 Pavel Safronov | 2127 | /** The finger, or for the mouse demo (Q61) the Cursor Pack's pointer (cursor_none) in its place (style.css `#demo-finger.mouse`). */ |
| 2128 | function 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 | } | |
| 2133 | drawPointer() | |
| 2134 | /** The input button (Q61): names what the demo is shown for, and one click picks the next (ui/demoInput.ts). */ | |
| 2135 | const inputButton = document.getElementById('demo-input')! | |
| 2136 | function syncInputButton(): void { | |
| 2137 | const label = demoInputLabel(demoInput()) | |
805e7a9f 2026-11-03 Pavel Safronov | 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 | } | |
92cc4ee3 2026-11-02 Pavel Safronov | 2143 | } |
805e7a9f 2026-11-03 Pavel Safronov | 2144 | function cycleDemoInput(): void { |
92cc4ee3 2026-11-02 Pavel Safronov | 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) | |
805e7a9f 2026-11-03 Pavel Safronov | 2150 | } |
| 2151 | inputButton.addEventListener('click', cycleDemoInput) | |
36c5d6a2 2026-10-24 Pavel Safronov | 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 | */ | |
| 2157 | const DEMO_GAP = 8 | |
| 2158 | const DEMO_PANEL_GAP = 24 | |
| 2159 | ||
92cc4ee3 2026-11-02 Pavel Safronov | 2160 | /** Places the action bar under the board (a zoomed scene: under the view, the board runs past it), for this frame's camera. */ |
| 2161 | function placeDemoBar(view: { x: number; y: number; width: number; height: number }): void { | |
36c5d6a2 2026-10-24 Pavel Safronov | 2162 | const r = mainCanvas.getBoundingClientRect() |
| 2163 | const topLeft = worldToScreen(vec2(0, state.height)) | |
| 2164 | const bottomRight = worldToScreen(vec2(state.width, 0)) | |
92cc4ee3 2026-11-02 Pavel Safronov | 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 | } | |
36c5d6a2 2026-10-24 Pavel Safronov | 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 | } | |
52d68b59 2026-10-24 Pavel Safronov | 2175 | /** |
fa3e81a7 2026-10-24 Pavel Safronov | 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. | |
52d68b59 2026-10-24 Pavel Safronov | 2178 | */ |
fa3e81a7 2026-10-24 Pavel Safronov | 2179 | const SCENE_ICONS: Record<SceneKind, string> = { |
8edb5cb9 2026-11-01 Pavel Safronov | 2180 | ...(Object.fromEntries(TEST_KINDS.map((k) => [k, TEST_SCENES[k].icon])) as Record<TestKind, string>), |
36c5d6a2 2026-10-24 Pavel Safronov | 2181 | tapMove: 'icon_move', holdBomb: 'bomb', bombButton: 'bomb', wait: 'icon_wait', autoMove: 'icon_move', |
fa3e81a7 2026-10-24 Pavel Safronov | 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, | |
fcf44f32 2026-10-25 Pavel Safronov | 2185 | run: POWER_UP_ICONS.run, kick: POWER_UP_ICONS.kick, throw: POWER_UP_ICONS.throw, teleport: CHARGE_ICONS.teleport, |
fa3e81a7 2026-10-24 Pavel Safronov | 2186 | diagonal: POWER_UP_ICONS.diagonal, detonate: POWER_UP_ICONS.detonator, wallPass: POWER_UP_ICONS.wallPass, |
fcf44f32 2026-10-25 Pavel Safronov | 2187 | bombPass: POWER_UP_ICONS.bombPass, flamePass: CHARGE_ICONS.flamePass, star: POWER_UP_ICONS.star, |
0bfdc8e6 2026-10-25 Pavel Safronov | 2188 | chargeSwap: CHARGE_ICONS.nuke, |
fa3e81a7 2026-10-24 Pavel Safronov | 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, | |
32dd53cd 2026-10-25 Pavel Safronov | 2191 | exit: 'door_open_top', doorBlast: 'door_closed_top', lastEnemy: 'door_open_top', crate: 'block_planks', pillar: 'block_blue', |
ac534e57 2026-10-25 Pavel Safronov | 2192 | rainbowGem: 'gem_green', immortal: 'star', |
d5e3ad4d 2026-10-24 Pavel Safronov | 2193 | dieIntoBlast: 'fireball', dieNotFar: 'fireball', dieBlockedIn: 'bomb', dieChain: 'bomb', dieEnemy: 'slime_normal_rest', |
| 2194 | dieDetonator: POWER_UP_ICONS.detonator, dieRun: POWER_UP_ICONS.run, | |
1f0038ac 2026-11-01 Pavel Safronov | 2195 | oilSlide: 'oil', |
7f242d36 2026-10-25 Pavel Safronov | 2196 | rpg: POWER_UP_ICONS.rpg, proximity: POWER_UP_ICONS.proximity, slide: POWER_UP_ICONS.slide, icbm: CHARGE_ICONS.icbm, |
b8849d36 2026-11-01 Pavel Safronov | 2197 | acid: CHARGE_ICONS.acid, rubble: 'rock', |
05e79f5f 2026-10-27 Pavel Safronov | 2198 | bush: 'grass_a', bushFire: 'grass_a', metal: 'block_metal', oil: 'oil', spikes: 'spikes', barrel: 'block_strong_danger', |
fa3e81a7 2026-10-24 Pavel Safronov | 2199 | } |
52d68b59 2026-10-24 Pavel Safronov | 2200 | |
33a0256b 2026-10-24 Pavel Safronov | 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 | */ | |
| 2206 | function prepareScene(index: number): void { | |
| 2207 | if (!demo) return | |
92cc4ee3 2026-11-02 Pavel Safronov | 2208 | const order = demoOrder() |
| 2209 | const i = (index + order.length) % order.length | |
33a0256b 2026-10-24 Pavel Safronov | 2210 | const upcoming: NonNullable<typeof demo.upcoming> = { index: i, scene: undefined } |
| 2211 | demo.upcoming = upcoming | |
92cc4ee3 2026-11-02 Pavel Safronov | 2212 | const item = order[i]! |
33a0256b 2026-10-24 Pavel Safronov | 2213 | // The still scenes play on the room of a sim scene, without its turns. |
92cc4ee3 2026-11-02 Pavel Safronov | 2214 | void makeScene(isStill(item) ? STILL_ROOMS[item] : item, demoSeed++).then((scene) => { |
33a0256b 2026-10-24 Pavel Safronov | 2215 | upcoming.scene = scene |
| 2216 | }) | |
36c5d6a2 2026-10-24 Pavel Safronov | 2217 | } |
| 2218 | ||
33a0256b 2026-10-24 Pavel Safronov | 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 | */ | |
52d68b59 2026-10-24 Pavel Safronov | 2223 | function nextScene(): void { |
| 2224 | if (!demo) return | |
92cc4ee3 2026-11-02 Pavel Safronov | 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) | |
bb3823d2 2026-10-31 Pavel Safronov | 2228 | if (!demo.upcoming) prepareScene(adjacentScene(demo.index, 1)) |
33a0256b 2026-10-24 Pavel Safronov | 2229 | const { index, scene } = demo.upcoming! |
| 2230 | if (scene === undefined) return | |
bb3823d2 2026-10-31 Pavel Safronov | 2231 | if (scene === null) return prepareScene(adjacentScene(index, 1)) |
92cc4ee3 2026-11-02 Pavel Safronov | 2232 | const item = demoOrder()[index]! |
33a0256b 2026-10-24 Pavel Safronov | 2233 | demo.index = index |
| 2234 | saveDemoIndex(index) | |
bb3823d2 2026-10-31 Pavel Safronov | 2235 | demoCount.textContent = `${scenePlan.indexOf(index) + 1} / ${scenePlan.length}` |
| 2236 | prepareScene(adjacentScene(index, 1)) | |
33a0256b 2026-10-24 Pavel Safronov | 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 | |
92cc4ee3 2026-11-02 Pavel Safronov | 2244 | demo.cursor = null |
| 2245 | demoPad.releaseAll() | |
| 2246 | demoKeys.releaseAll() | |
33a0256b 2026-10-24 Pavel Safronov | 2247 | state = scene.start |
92cc4ee3 2026-11-02 Pavel Safronov | 2248 | if (isStill(item)) { |
33a0256b 2026-10-24 Pavel Safronov | 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. | |
d3e4ed26 2026-10-25 Pavel Safronov | 2251 | if (item === 'topBar') state = { ...state, powers: { ...state.powers, kick: true }, charge: { kind: 'pierce', left: 3 } } |
92cc4ee3 2026-11-02 Pavel Safronov | 2252 | demo.beats = item === 'topBar' ? topBarTour() : item === 'whatIsThat' ? whatIsThat() : isKeyStill(item) ? keyBeats(item) : padBeats(item) |
b8849d36 2026-11-01 Pavel Safronov | 2253 | demoTime.set(0, 0) |
33a0256b 2026-10-24 Pavel Safronov | 2254 | } else { |
b8849d36 2026-11-01 Pavel Safronov | 2255 | demoTime.set(0, scene.actions.length, scene.moment) |
33a0256b 2026-10-24 Pavel Safronov | 2256 | const icon = SCENE_ICONS[item] |
92cc4ee3 2026-11-02 Pavel Safronov | 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) | |
52d68b59 2026-10-24 Pavel Safronov | 2261 | } |
33a0256b 2026-10-24 Pavel Safronov | 2262 | afterChange() |
| 2263 | // The finger comes back beside the player, where the eye should be (user request, 2026-10-24). | |
| 2264 | demo.rest = true | |
52d68b59 2026-10-24 Pavel Safronov | 2265 | } |
| 2266 | ||
e3384c6b 2026-10-24 Pavel Safronov | 2267 | /** The demo's caption: an icon (an image URL; `mono` for a one-colour ink icon) and the words. */ |
| 2268 | function setCaption(icon: string, mono: boolean, text: string): void { | |
92cc4ee3 2026-11-02 Pavel Safronov | 2269 | padCaptionNow = null |
e3384c6b 2026-10-24 Pavel Safronov | 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. */ | |
| 2274 | const 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 | ] | |
| 2283 | const TOUR_SECONDS = 2.6 | |
| 2284 | const 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 | */ | |
| 2292 | function 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. */ | |
| 2312 | function 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 | */ | |
| 2322 | function 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 | ||
92cc4ee3 2026-11-02 Pavel Safronov | 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). */ | |
| 2348 | function 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). */ | |
| 2353 | function 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. */ | |
| 2359 | function 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. */ | |
| 2364 | function 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. */ | |
| 2382 | let padCaptionNow: { text: string; icon: string; mono: boolean } | null = null | |
| 2383 | ||
| 2384 | function 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. */ | |
| 2390 | function 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. */ | |
| 2398 | const LOOK_ZOOM = 2 | |
| 2399 | const LOOK_SECONDS = PAD_LIT_SECONDS + 0.4 | |
| 2400 | ||
| 2401 | /** The scripts of the pad's own scenes, on a still room. */ | |
| 2402 | function 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. */ | |
| 2466 | function 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. */ | |
| 2471 | function 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 | */ | |
| 2480 | function 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. */ | |
| 2502 | function 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 | */ | |
| 2560 | function 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 | ||
33a0256b 2026-10-24 Pavel Safronov | 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 | */ | |
52d68b59 2026-10-24 Pavel Safronov | 2592 | function startDemo(): void { |
92cc4ee3 2026-11-02 Pavel Safronov | 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() | |
bb3823d2 2026-10-31 Pavel Safronov | 2599 | makeScenePlan(at) |
| 2600 | shuffleButton.setAttribute('aria-pressed', String(demoShuffle)) | |
33a0256b 2026-10-24 Pavel Safronov | 2601 | prepareScene(at) |
52d68b59 2026-10-24 Pavel Safronov | 2602 | document.body.classList.add('demo') |
| 2603 | demoPanel.hidden = false | |
0bfdc8e6 2026-10-25 Pavel Safronov | 2604 | demoTitle.hidden = false |
92cc4ee3 2026-11-02 Pavel Safronov | 2605 | demoPad.el.hidden = !pad |
| 2606 | demoKeys.el.hidden = !keys | |
| 2607 | syncDemoFamily() | |
| 2608 | syncInputButton() | |
36c5d6a2 2026-10-24 Pavel Safronov | 2609 | finger.hidden = false |
58f1e700 2026-10-24 Pavel Safronov | 2610 | finger.classList.add('away') |
| 2611 | youTag.hidden = false | |
e3384c6b 2026-10-24 Pavel Safronov | 2612 | crtScreen.hidden = false |
| 2613 | setDialogInDemo(true) | |
0b63d158 2026-10-25 Pavel Safronov | 2614 | if (postProcess) postProcess.enabled = true |
52d68b59 2026-10-24 Pavel Safronov | 2615 | nextScene() |
| 2616 | } | |
| 2617 | ||
36c5d6a2 2026-10-24 Pavel Safronov | 2618 | /** Called every frame: the script's next beat when it is due, the next turn's script, or the next scene. */ |
52d68b59 2026-10-24 Pavel Safronov | 2619 | function demoTick(): void { |
92cc4ee3 2026-11-02 Pavel Safronov | 2620 | // The button's name follows the input in use. |
| 2621 | if (demo) syncInputButton() | |
52d68b59 2026-10-24 Pavel Safronov | 2622 | if (!demo || time < demo.at) return |
36c5d6a2 2026-10-24 Pavel Safronov | 2623 | if (!demo.beats.length) { |
33a0256b 2026-10-24 Pavel Safronov | 2624 | if (!demo.scene || demo.next >= demo.scene.actions.length) return nextScene() |
36c5d6a2 2026-10-24 Pavel Safronov | 2625 | const i = demo.next++ |
92cc4ee3 2026-11-02 Pavel Safronov | 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) | |
b8849d36 2026-11-01 Pavel Safronov | 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)) | |
36c5d6a2 2026-10-24 Pavel Safronov | 2633 | } |
| 2634 | const beat = demo.beats.shift()! | |
| 2635 | beat.run() | |
| 2636 | demo.at = time + beat.then | |
| 2637 | } | |
| 2638 | ||
92cc4ee3 2026-11-02 Pavel Safronov | 2639 | /** The beat that plays a turn, after the finger (or the pad's cursor) has done its part. */ |
| 2640 | function playBeat(action: Action, last: boolean): Beat { | |
| 2641 | return { | |
36c5d6a2 2026-10-24 Pavel Safronov | 2642 | run: () => { |
| 2643 | fingerUp() | |
| 2644 | hover = null | |
| 2645 | armed = null | |
| 2646 | selected = null | |
92cc4ee3 2026-11-02 Pavel Safronov | 2647 | if (demo) demo.cursor = null |
36c5d6a2 2026-10-24 Pavel Safronov | 2648 | animateTurn(step(state, action), action) |
| 2649 | afterChange() | |
| 2650 | if (last) finger.classList.add('away') | |
| 2651 | }, | |
92cc4ee3 2026-11-02 Pavel Safronov | 2652 | then: last ? DEMO_HOLD_SECONDS : DEMO_SETTLE_SECONDS, |
36c5d6a2 2026-10-24 Pavel Safronov | 2653 | } |
92cc4ee3 2026-11-02 Pavel Safronov | 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 | */ | |
| 2660 | function beatsFor(g: Gesture, action: Action, last: boolean): Beat[] { | |
| 2661 | const play = playBeat(action, last) | |
36c5d6a2 2026-10-24 Pavel Safronov | 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 | } | |
8edb5cb9 2026-11-01 Pavel Safronov | 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. | |
b89399da 2026-11-01 Pavel Safronov | 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. | |
8edb5cb9 2026-11-01 Pavel Safronov | 2680 | return [ |
b89399da 2026-11-01 Pavel Safronov | 2681 | { run: () => {}, then: 1.2 }, |
| 2682 | { run: () => (state.offer ? openOffer(state, state.offer, () => {}) : openChargeOffer(state, () => {})), then: 2.4 }, | |
8edb5cb9 2026-11-01 Pavel Safronov | 2683 | { ...play, run: () => (closeDialog(), play.run()) }, |
| 2684 | ] | |
| 2685 | } | |
0bfdc8e6 2026-10-25 Pavel Safronov | 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 | } | |
36c5d6a2 2026-10-24 Pavel Safronov | 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. */ | |
| 2715 | function tilePoint(p: Vec) { | |
| 2716 | const r = mainCanvas.getBoundingClientRect() | |
| 2717 | return worldToScreen(toWorld(state, p)).add(vec2(r.left, r.top)) | |
| 2718 | } | |
| 2719 | ||
e6bb7c42 2026-10-25 Pavel Safronov | 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 | ||
36c5d6a2 2026-10-24 Pavel Safronov | 2726 | function centreOf(el: Element) { |
| 2727 | const r = el.getBoundingClientRect() | |
| 2728 | return vec2(r.left + r.width / 2, r.top + r.height / 2) | |
| 2729 | } | |
| 2730 | ||
58f1e700 2026-10-24 Pavel Safronov | 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 | */ | |
| 2736 | function placeDemoMarks(): void { | |
| 2737 | if (!demo) return | |
| 2738 | // Hugging the top of the player's head, always over them (user request, 2026-10-24). | |
33a0256b 2026-10-24 Pavel Safronov | 2739 | const tip = worldToScreen(playerPos(state, anim.playerMove).add(vec2(0, 0.4))) |
58f1e700 2026-10-24 Pavel Safronov | 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 | |
92cc4ee3 2026-11-02 Pavel Safronov | 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 | |
58f1e700 2026-10-24 Pavel Safronov | 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 | ||
36c5d6a2 2026-10-24 Pavel Safronov | 2750 | /** The finger's tip to `at`: gliding there, or (`glide` false) at once. */ |
| 2751 | function 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. */ | |
| 2757 | function 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. */ | |
| 2764 | function 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. */ | |
| 2770 | function 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. */ | |
| 2775 | function 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 | |
52d68b59 2026-10-24 Pavel Safronov | 2781 | } |
| 2782 | ||
33a0256b 2026-10-24 Pavel Safronov | 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 | */ | |
| 2787 | function skipTo(index: number): void { | |
| 2788 | if (!demo) return | |
92cc4ee3 2026-11-02 Pavel Safronov | 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 | |
33a0256b 2026-10-24 Pavel Safronov | 2791 | demo.beats = [] |
| 2792 | demo.scene = null | |
| 2793 | demo.at = 0 | |
b8849d36 2026-11-01 Pavel Safronov | 2794 | demoTime.set(0, 0) |
33a0256b 2026-10-24 Pavel Safronov | 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') | |
92cc4ee3 2026-11-02 Pavel Safronov | 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) | |
33a0256b 2026-10-24 Pavel Safronov | 2809 | } |
| 2810 | ||
52d68b59 2026-10-24 Pavel Safronov | 2811 | function endDemo(): void { |
| 2812 | if (!demo) return | |
| 2813 | state = demo.real | |
| 2814 | demo = null | |
92cc4ee3 2026-11-02 Pavel Safronov | 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 | |
52d68b59 2026-10-24 Pavel Safronov | 2823 | document.body.classList.remove('demo') |
| 2824 | demoPanel.hidden = true | |
0bfdc8e6 2026-10-25 Pavel Safronov | 2825 | demoTitle.hidden = true |
36c5d6a2 2026-10-24 Pavel Safronov | 2826 | finger.hidden = true |
58f1e700 2026-10-24 Pavel Safronov | 2827 | youTag.hidden = true |
e3384c6b 2026-10-24 Pavel Safronov | 2828 | if (dialogOpen()) closeDialog() |
| 2829 | setDialogInDemo(false) | |
| 2830 | crtScreen.hidden = true | |
| 2831 | crtScreen.classList.remove('interference') | |
| 2832 | vacuumAt = Infinity | |
0b63d158 2026-10-25 Pavel Safronov | 2833 | if (postProcess) postProcess.enabled = false |
e3384c6b 2026-10-24 Pavel Safronov | 2834 | spotlight(null) |
36c5d6a2 2026-10-24 Pavel Safronov | 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 | |
52d68b59 2026-10-24 Pavel Safronov | 2840 | resetView() |
| 2841 | afterChange() | |
d3e4ed26 2026-10-25 Pavel Safronov | 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() | |
5607bea2 2026-10-31 Pavel Safronov | 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 | } | |
52d68b59 2026-10-24 Pavel Safronov | 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). | |
| 2860 | let swallowClick = 0 | |
| 2861 | addEventListener( | |
| 2862 | 'pointerdown', | |
| 2863 | (e) => { | |
33a0256b 2026-10-24 Pavel Safronov | 2864 | // The demo's own buttons (◀ ▶ ↺) work, and keep it going. |
| 2865 | if (!demo || (e.target as Element).closest?.('.demo-nav')) return | |
52d68b59 2026-10-24 Pavel Safronov | 2866 | endDemo() |
| 2867 | swallowClick = performance.now() + 600 | |
| 2868 | e.stopPropagation() | |
| 2869 | e.preventDefault() | |
| 2870 | }, | |
| 2871 | true, | |
| 2872 | ) | |
| 2873 | for (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 | } | |
| 2884 | addEventListener( | |
| 2885 | 'keydown', | |
| 2886 | (e) => { | |
| 2887 | if (!demo) return | |
bb3823d2 2026-10-31 Pavel Safronov | 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. | |
33a0256b 2026-10-24 Pavel Safronov | 2891 | e.stopPropagation() |
bb3823d2 2026-10-31 Pavel Safronov | 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)) | |
33a0256b 2026-10-24 Pavel Safronov | 2898 | } |
52d68b59 2026-10-24 Pavel Safronov | 2899 | }, |
| 2900 | true, | |
| 2901 | ) | |
| 2902 | ||
5607bea2 2026-10-31 Pavel Safronov | 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. | |
| 2905 | let news = testing ? [] : newSinceLastVisit() | |
52d68b59 2026-10-24 Pavel Safronov | 2906 | if (!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). | |
| 2910 | setDebugWatermark(false) | |
5551eb37 2026-10-20 Pavel Safronov | 2911 | // Sharp on high-density screens, capped so phones don't pay for 3×. |
0b63d158 2026-10-25 Pavel Safronov | 2912 | setCanvasPixelRatio(Math.min(devicePixelRatio, Number(params.get('ratio')) || 2)) |
6e94f995 2026-10-24 Pavel Safronov | 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). | |
0b63d158 2026-10-25 Pavel Safronov | 2916 | setGLEnable(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. | |
| 2921 | glSetAntialias(params.has('aa')) | |
644cfd0b 2026-10-20 Pavel Safronov | 2922 | // The sprites are smooth vector-style art drawn smaller than their 128 px, not pixel art. |
| 2923 | setTilesPixelated(false) | |
5551eb37 2026-10-20 Pavel Safronov | 2924 | |
52d68b59 2026-10-24 Pavel Safronov | 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 | */ | |
| 2930 | function 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 | ||
5551eb37 2026-10-20 Pavel Safronov | 2943 | await engineInit( |
| 2944 | () => { | |
| 2945 | setShowSplashScreen(false) | |
0b63d158 2026-10-25 Pavel Safronov | 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 | |
5551eb37 2026-10-20 Pavel Safronov | 2952 | setCanvasClearColor(BACKGROUND) |
| 2953 | // Pressing shows the preview on the tile under the finger; releasing commits to it. | |
82adee18 2026-10-20 Pavel Safronov | 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) => { | |
92cc4ee3 2026-11-02 Pavel Safronov | 2957 | mouseHover = null |
82adee18 2026-10-20 Pavel Safronov | 2958 | hover = tileUnder(e) |
e6bb7c42 2026-10-25 Pavel Safronov | 2959 | drag = { x: e.clientX, y: e.clientY, panning: false } |
82adee18 2026-10-20 Pavel Safronov | 2960 | startPress(hover) |
| 2961 | }) | |
5551eb37 2026-10-20 Pavel Safronov | 2962 | mainCanvas.addEventListener('pointermove', (e) => { |
82adee18 2026-10-20 Pavel Safronov | 2963 | if (e.pointerType !== 'mouse' && !e.buttons) return |
e6bb7c42 2026-10-25 Pavel Safronov | 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 | } | |
82adee18 2026-10-20 Pavel Safronov | 2979 | const p = tileUnder(e) |
| 2980 | if (e.buttons && !(p && pressTile && eq(p, pressTile))) startPress(null) | |
| 2981 | hover = p | |
92cc4ee3 2026-11-02 Pavel Safronov | 2982 | mouseHover = e.pointerType === 'mouse' && !e.buttons ? p : null |
5551eb37 2026-10-20 Pavel Safronov | 2983 | }) |
| 2984 | mainCanvas.addEventListener('pointerup', (e) => { | |
e6bb7c42 2026-10-25 Pavel Safronov | 2985 | const panned = drag?.panning |
| 2986 | drag = null | |
| 2987 | endPan() | |
| 2988 | if (panned) return startPress(null) | |
5551eb37 2026-10-20 Pavel Safronov | 2989 | const p = tileUnder(e) |
| 2990 | if (e.pointerType !== 'mouse') hover = null | |
82adee18 2026-10-20 Pavel Safronov | 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 | |
92cc4ee3 2026-11-02 Pavel Safronov | 2998 | mouseHover = null |
e6bb7c42 2026-10-25 Pavel Safronov | 2999 | drag = null |
| 3000 | endPan() | |
82adee18 2026-10-20 Pavel Safronov | 3001 | startPress(null) |
5551eb37 2026-10-20 Pavel Safronov | 3002 | }) |
82adee18 2026-10-20 Pavel Safronov | 3003 | // A long press shouldn't open the browser's context menu. |
| 3004 | mainCanvas.addEventListener('contextmenu', (e) => e.preventDefault()) | |
5551eb37 2026-10-20 Pavel Safronov | 3005 | }, |
0b63d158 2026-10-25 Pavel Safronov | 3006 | timed(guarded('update', () => { |
351348d1 2026-11-04 Pavel Safronov | 3007 | if (!replayOn) pad.poll(performance.now()) |
52d68b59 2026-10-24 Pavel Safronov | 3008 | demoTick() |
720cc9d3 2026-11-03 Pavel Safronov | 3009 | reopenPendingChoice() |
36c5d6a2 2026-10-24 Pavel Safronov | 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 | |
92cc4ee3 2026-11-02 Pavel Safronov | 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 | |
e6bb7c42 2026-10-25 Pavel Safronov | 3016 | viewMask.style.cssText = `left:${view.x}px;top:${view.y}px;width:${view.width}px;height:${view.height}px` |
92cc4ee3 2026-11-02 Pavel Safronov | 3017 | if (demo) placeDemoBar(view) |
e3384c6b 2026-10-24 Pavel Safronov | 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`) | |
0bfdc8e6 2026-10-25 Pavel Safronov | 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) | |
e3384c6b 2026-10-24 Pavel Safronov | 3025 | document.body.style.setProperty('--tv-left', `${(innerWidth - tv) / 2}px`) |
58f1e700 2026-10-24 Pavel Safronov | 3026 | placeDemoMarks() |
e3384c6b 2026-10-24 Pavel Safronov | 3027 | if (demo) vacuum() |
aef53511 2026-11-02 Pavel Safronov | 3028 | // The controller's cursor is always on while the pad plays the board; its label sits over the action bar. |
| 3029 | ensureCursor() | |
92cc4ee3 2026-11-02 Pavel Safronov | 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) : [] | |
ebcd0a8f 2026-11-02 Pavel Safronov | 3034 | // Empty (so the box is hidden) when there are no lines to show. |
92cc4ee3 2026-11-02 Pavel Safronov | 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) : '' | |
ebcd0a8f 2026-11-02 Pavel Safronov | 3038 | // The action bar's buttons show what the cursor's tile offers: greyed, or marked when it costs a life. |
720cc9d3 2026-11-03 Pavel Safronov | 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('')}` : '' | |
ebcd0a8f 2026-11-02 Pavel Safronov | 3042 | if (barKey !== lastBarKey) { |
| 3043 | lastBarKey = barKey | |
720cc9d3 2026-11-03 Pavel Safronov | 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) |
ebcd0a8f 2026-11-02 Pavel Safronov | 3045 | } |
| 3046 | if (lastNote !== note) { | |
| 3047 | lastNote = note | |
92cc4ee3 2026-11-02 Pavel Safronov | 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) { | |
ebcd0a8f 2026-11-02 Pavel Safronov | 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 | |
92cc4ee3 2026-11-02 Pavel Safronov | 3056 | row.append(keysUi ? keyCap(KEY_CAPS[l.button]) : buttonPicture(spriteUrl(glyphFor(padKind, l.button === 'charged' ? 'charge' : l.button))), text) |
ebcd0a8f 2026-11-02 Pavel Safronov | 3057 | return row |
| 3058 | }), | |
| 3059 | ) | |
92cc4ee3 2026-11-02 Pavel Safronov | 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) | |
720cc9d3 2026-11-03 Pavel Safronov | 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`)) | |
ebcd0a8f 2026-11-02 Pavel Safronov | 3080 | } else padLabel.textContent = note |
| 3081 | padLabel.hidden = !note | |
| 3082 | } | |
| 3083 | if (note) padLabel.style.bottom = `${controls.insets().bottom + 8}px` | |
aef53511 2026-11-02 Pavel Safronov | 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) }) | |
92cc4ee3 2026-11-02 Pavel Safronov | 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). |
720cc9d3 2026-11-03 Pavel Safronov | 3087 | const escText = keysUi && inspecting ? 'Leave inspection mode' : keysUi && l1.text === IDLE_L1_TEXT && l1.dim ? 'Hide cursor' : l1.text |
92cc4ee3 2026-11-02 Pavel Safronov | 3088 | const hintKey = aimShown() ? `${keysUi ? 'keys' : padKind}|${escText}|${l1.dim && !keysUi}|${inspecting}` : '' |
aef53511 2026-11-02 Pavel Safronov | 3089 | if (hintKey !== lastHintKey) { |
| 3090 | lastHintKey = hintKey | |
| 3091 | padHint.hidden = !hintKey | |
| 3092 | if (hintKey) { | |
720cc9d3 2026-11-03 Pavel Safronov | 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 => { | |
aef53511 2026-11-02 Pavel Safronov | 3095 | const el = document.createElement('span') |
| 3096 | el.className = dim ? 'pad-hint-item dim' : 'pad-hint-item' | |
720cc9d3 2026-11-03 Pavel Safronov | 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) | |
92cc4ee3 2026-11-02 Pavel Safronov | 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) | |
aef53511 2026-11-02 Pavel Safronov | 3109 | return el |
| 3110 | } | |
720cc9d3 2026-11-03 Pavel Safronov | 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)) | |
aef53511 2026-11-02 Pavel Safronov | 3113 | } |
| 3114 | } | |
720cc9d3 2026-11-03 Pavel Safronov | 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` | |
5551eb37 2026-10-20 Pavel Safronov | 3117 | snapCamera = false |
33a0256b 2026-10-24 Pavel Safronov | 3118 | pruneAnimation(anim, time) |
92cc4ee3 2026-11-02 Pavel Safronov | 3119 | autopilotTick() |
ea380ee8 2026-10-22 Pavel Safronov | 3120 | autoStep() |
351348d1 2026-11-04 Pavel Safronov | 3121 | replayTick() |
0b63d158 2026-10-25 Pavel Safronov | 3122 | })), |
5551eb37 2026-10-20 Pavel Safronov | 3123 | () => {}, |
0b63d158 2026-10-25 Pavel Safronov | 3124 | timed(guarded('draw', () => { |
33a0256b 2026-10-24 Pavel Safronov | 3125 | // A dialog's clip first: it uses the canvas for a moment, then clears it (ui/clip.ts). |
32dd53cd 2026-10-25 Pavel Safronov | 3126 | drawClips() |
720cc9d3 2026-11-03 Pavel Safronov | 3127 | syncHudAim() |
92cc4ee3 2026-11-02 Pavel Safronov | 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 | |
82adee18 2026-10-20 Pavel Safronov | 3130 | drawBoard({ |
720cc9d3 2026-11-03 Pavel Safronov | 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, |
ebcd0a8f 2026-11-02 Pavel Safronov | 3132 | // The controller's cursor: every actionable tile blue, and the result of the first action there, faded. |
92cc4ee3 2026-11-02 Pavel Safronov | 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, | |
e3d8dc49 2026-10-27 Pavel Safronov | 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, | |
351348d1 2026-11-04 Pavel Safronov | 3138 | rival: replayRun || rulesRun ? ((r) => ({ state: r.blue, move: r.blueAnim.playerMove, pose: r.blueAnim.playerPose, facingLeft: r.blueAnim.facingLeft }))((replayRun ?? rulesRun)!) : undefined, |
33a0256b 2026-10-24 Pavel Safronov | 3139 | }) |
e6bb7c42 2026-10-25 Pavel Safronov | 3140 | // What is out of sight on a board bigger than the view: arrows at its edge. |
| 3141 | if (!demo) drawIndicators(state, viewRect()) | |
0b63d158 2026-10-25 Pavel Safronov | 3142 | countFrame() |
| 3143 | })), | |
5551eb37 2026-10-20 Pavel Safronov | 3144 | () => {}, |
644cfd0b 2026-10-20 Pavel Safronov | 3145 | TEXTURES, |
5551eb37 2026-10-20 Pavel Safronov | 3146 | ) |
52d68b59 2026-10-24 Pavel Safronov | 3147 | |
bb3823d2 2026-10-31 Pavel Safronov | 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. |
| 3149 | document.title = TITLE | |
| 3150 | loadNextInfo() | |
| 3151 | ||
52d68b59 2026-10-24 Pavel Safronov | 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. | |
| 3154 | if (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 | } |