explodorama / commit
Dialog clips (steps 1-2), the demo in order with ◀ ▶ ↺, Report a bug
CHANGELOG.md | 4 + PLAN.md | 83 +++++++++++- README.md | 22 ++-- decisions.md | 123 +++++++++++++++++- docs/screenshots/demo.png | Bin 1035346 -> 971948 bytes docs/screenshots/menu.png | Bin 136917 -> 142515 bytes index.html | 6 + src/devlog.ts | 12 +- src/main.ts | 317 +++++++++++++++++++++++++++++++--------------- src/render/animate.ts | 70 ++++++++++ src/render/board.ts | 14 +- src/sim/demo.ts | 34 ++++- src/sim/scene-worker.ts | 11 ++ src/style.css | 54 ++++++++ src/ui/clip.ts | 117 +++++++++++++++++ src/ui/dialog.ts | 6 + src/ui/scenes.ts | 36 ++++++ test/demo.test.ts | 10 +- 18 files changed, 793 insertions(+), 126 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index afdeb28..0ce749d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ saved before the change is dropped and the next visit starts a new run. ### Added +- **Report a bug** in the menu: an email to the project's GitLab Service Desk (no account needed), filled in with the game's version, the device and the run, so it can be replayed. - **A version number**, at the bottom of the menu and near the top of the README. It is the rules version (`SIM_VERSION`), so it changes exactly when old saved runs stop resuming. The menu also shows the commit the game was built from and links to the source code. @@ -78,6 +79,8 @@ saved before the change is dropped and the next visit starts a new run. ### Changed +- The demo plays its scenes in one order, numbered, with ◀ ▶ (and ← →) to go through them and ↺ back to the first; it carries on where it was, after a reload too. +- The menu can play the demo again (**Watch the demo**), and names the run's modes in one short line. - The game is now called **EXPLODORAMA!** - One board, 9 × 11 with 20 levels (it was a choice with the original's 31 × 13 and 50 levels), and crates always crumble once the enemies are gone (it was an option). The New run menu has @@ -121,6 +124,7 @@ saved before the change is dropped and the next visit starts a new run. ### Fixed +- Long dialogs (the almanac, the rainbow gem's offer) scroll with a finger again. - The player is drawn a little higher, so their feet never touch the line between tiles. - A top-bar number bumping at the edge of its row (Turn) is no longer cut off. - Moving off the auto-move path now clears the flag. diff --git a/PLAN.md b/PLAN.md index 82420da..e815b85 100644 --- a/PLAN.md +++ b/PLAN.md @@ -676,7 +676,8 @@ scene whose moment is the death: into a blast (from a safe tile onto a burning o enough (moved but stayed in reach; Fire 3), blocked in (no step out of danger), chain (caught only by a bomb that wasn't due), an enemy, and two funny ones: the Detonator next to you, a Run into an enemy. The random play keeps refusing deadly moves, except the one that is the scene's -death. Living and dying scenes take turns, each queue shuffled. 36 kinds, all found on every +death. Living and dying scenes take turns, each queue shuffled (*until 2026-10-24: now one fixed +order, see below*). 36 kinds, all found on every seed tried, the slowest under 0.5 s; each caption has the thing's icon. The "Ouch!" and level-cleared banners are hidden in the demo. The UI (`main.ts`: `startDemo`, `demoTick`) plays a turn every 0.6 s through the same animation code as play, hides the top bar and buttons, and @@ -710,10 +711,71 @@ lines, a rolling dark bar and sweeping bright lines, vignette, rounded glass and too), at most 4:3 with black bars; the finger is in front of the glass. **Five control scenes** come first (step, long-press bomb, Bomb button, Wait, auto-move with Go), and again each time the living scenes have all been shown. Captions of Run, Kick, Throw, Teleport, Diagonal and the Detonator say how to do them. -41 kinds; on 40 seeds each, all but one Nuke scene found (a missing scene is skipped), the -slowest under 0.45 s. *Since 2026-10-24 (user request)* every room has its exit: under a crate, -or in one room in six already uncovered (locked while enemies live). Ideas: a gem's choice, and -showing the demo again after the player is idle on the menu. +41 kinds; on 40 seeds each, all found, the slowest 0.5 s (made in a worker). *Since 2026-10-24 +(user request)* the random play is **sensible** outside the scenes about dying: no flame on an +uncovered exit or gem (except in the scene about that), and a bomb only where its blast reaches a +crate, an enemy, a tile an enemy is going to, or another bomb. *Since 2026-10-24 (user request)* every room has its exit: under a crate, +or in one room in six already uncovered (locked while enemies live). *Since 2026-10-24 (user +request)* the menu's **Watch the demo** plays it again; a tap returns to the run as it was. *Since 2026-10-24 (user request)* the scenes play **in one fixed +order**, looped (`DEMO_ORDER` in main.ts: the controls' seven, then the table's living scenes with +a way to die after every four); the panel numbers them (n / 43), ◀ ▶ and ← → step through them, +↺ goes back to the first, and the place is kept in `localStorage` so the demo carries on there +next time, after a reload too (not on a test URL, which starts at the first). Scenes are made in +a Web Worker (`ui/scenes.ts`), the next while one plays. Ideas: a gem's choice, and showing the +demo again after the player is idle on the menu. + +## 13c. Animated clips in the info dialogs (user request, 2026-10-24; planned) + +Every info dialog gets a small looping clip of what it describes, made on the spot like the demo's +scenes (`sim/demo.ts`): a small room, random play by the real rules until the moment shows, the +last few turns replayed. Each loop holds its moment for **1 s**, then starts over **with a new +setup** (a new seed), for variety. **No CRT** (that is the demo's joke). **The finger** only where +it helps: the action buttons and the power-ups you work yourself (Run, Kick, Throw, Teleport, +Diagonal, the Detonator). **Reduced motion:** a still of the moment wherever one frame says it +(a bomb at 1 with its blast previewed, a Nuke's shape, the exit with its three enemies coming); +for what is about moving (the enemies' rules), a still with the arrows. + +**What gets a clip** (an existing demo scene where there is one; *new* otherwise): + +- *Action buttons* ("What is that?" on them): Move (`tapMove`, or with Run / Teleport / Kick / + Diagonal held, theirs), Bomb (`holdBomb`, `bombButton`; with Throw or the Detonator, theirs), + Wait (`wait`). +- *Top-bar items:* lives (*new*: hit, a heart empties, the level starts over the same), level + (`exit`), enemies left (*new*: the last one dies, the count reaches 0, the exit opens), coins + (`coins`), turn and par (*new*: the count ticking), bombs (`bombPlus`), fire (`firePlus`), fuse + (`fusePlus` / `kill`), Star (`star`), each pass held (its scene), each charge held (its scene, + the charge already held), the modes (Developer: the rainbow gem's offer; Immortal: unharmed by + a blast). +- *Tiles* ("What is that?" on the board): a bomb (its countdown and blast; a Pierce / Power / Nuke bomb its shape), + the decoy (`decoy`), still burning (`lingering`), a crate (*new*: burst, uncovering a drop, the + gem or the exit), a pillar or the outer wall (*new*: a blast stopped by it), the gem and the + rainbow gem (`gem`), the exit locked (`doorBlast`) and open (`exit`), coins or a charge on the + floor (picked up; a charge also doing its thing). *The enemies get no clip, only their picture* + (user's call, 2026-10-24: the first try, the `enemies` scene, showed a busy room with all three + kinds; the player learns how they move soon enough) — on the board and in the almanac alike. +- *Other dialogs:* the first-pickup dialog of each charge (its top-bar clip), the gem's offer (a + clip per card, perhaps only the card in focus), the almanac's entries (perhaps only one opened), + and the "Ouch!" banner (the matching death scene, or a replay of the player's own last turns). +Clips should keep their rooms simple: few crates and as few enemies as the moment needs. + +**How they are drawn** (decided 2026-10-24): one LittleJS canvas, drawn twice a frame while a +dialog with a clip is open — first the clip's room, drawn at an offset in world space where the +camera already looks (LittleJS builds the camera's transform only at the start of a frame), copied into a small +`<canvas>` in the dialog, then cleared and the game as usual — so clips use exactly the board's +drawing and animation code. Fallback, if a copy in the middle of a frame doesn't work: LittleJS's +2D drawing straight into the dialog's canvas. It needs the board's view state (effects, flames, +the player's move and pose, enemy steps, …), now module variables in `main.ts`, to become an +object, one for the game and one per clip; and scene generation (up to ~0.45 s) to move to a +**Web Worker** (the sim is pure), so the next loop's setup is ready in time — the demo can use it +too. + +**Order:** 1. a spike proving the two-pass drawing and copy (*done 2026-10-24*: `ui/clip.ts`; +"Still burning" plays the `lingering` scene as a first clip); 2. *done 2026-10-24*: the board's +animation state is one object (`render/animate.ts`: `Animation`, `animateTurn`), one for the game +and one per clip, and scenes are made in a Web Worker (`sim/scene-worker.ts`, `ui/scenes.ts`; on +the page where a worker can't be made), the next loop's while one plays — the demo's too; 3. the +clips that reuse demo scenes; 4. the new scenes (no longer the enemies'); 5. the offer's cards, +the almanac and the "Ouch!" replay, which need layout decisions. ## 13b. Run options (user request, 2026-10-21; built) @@ -901,3 +963,14 @@ tools/ compose-icons.sh (composed sprites and the app icons), sc 9. **M8 – Permanence:** Workshop, save export/import, victory certificate + QR, replay file and verify page. 10. **Later:** Reapers + turn limit, daily seed, final art. + +### Backlog + +- ~~**Bug reports from players**~~ *Built 2026-10-24* (user request): GitLab Service Desk is on + (the user turned it on; an email to its address becomes a confidential issue, no GitLab account + needed, replies go back by email), and the menu's **Report a bug** opens an email to it, filled + in with the version and build commit, the device, the seed, level, turn, lives and modes, the + last 5 errors the game caught (`recentErrors` in devlog.ts), and the run record. A record too + long for an email link (over ~1,900 characters) stays out of the email and goes on the + clipboard instead, and the email asks to paste it. + diff --git a/README.md b/README.md index 195ba2c..09ca4f9 100644 --- a/README.md +++ b/README.md @@ -51,11 +51,14 @@ Freeze, Decoy), and the exit (walking out once it opens; blasting it lets enemie Coin bag only change numbers, so they have no scene; nor does the shop. Every other scene shows **a way to die**: walking into a blast, not getting far enough, blocking yourself in with your own bomb, one bomb setting off another, touching an enemy, and a couple of funny ones (the -Detonator next to you, a Run into an enemy). Living and dying scenes take turns, each in a -shuffled order, and every time they're different; once all the living ones have been shown, -the controls come round again. It is -view-only; **tap anywhere (or press any key) to play**, and that tap does nothing else. A test -URL (`?seed` and the like) skips it; `?demo` shows it anyway. +Detonator next to you, a Run into an enemy). The scenes play **in a fixed order** and loop — +the controls first, then the rest, with a way to die after every four — each made new every +time. The panel shows which one it is (**12 / 43**); **◀ ▶** (or the **← →** keys) go to the +one before or after, and **↺** back to the first. The demo carries on where it was the next time +it plays, after a reload too. It is +view-only; **tap anywhere else (or press any other key) to play**, and that tap does nothing +else. The menu's **Watch the demo** plays it again; a tap brings you back to your run as it was. +A test URL (`?seed` and the like) skips it; `?demo` shows it anyway, from the first scene. <img src="docs/screenshots/demo.png" alt="The demo: the finger holding on a tile next to the player, a ring closed round it and the bomb's blast previewed; the action bar right under the board, the caption apart at the bottom" width="260"> @@ -125,8 +128,11 @@ themselves with a plain tap. **Menu:** the round button at the top right (or **Esc** with nothing picked). It shows the level and seed, and has the **Almanac** — every power-up, charge and enemy type, with its -picture and what it does, in three tabs — and **New run**, which asks first, since the current -run can't be got back. +picture and what it does, in three tabs — **High scores**, **Watch the demo**, **Report a bug** +(an email to the game's GitLab Service Desk, which needs no GitLab account, filled in with the +game's version, your device and the run, so it can be replayed; a long run's record goes on +your clipboard to paste in), and **New run**, which asks first, since the current run can't be +got back. **Press and hold** a tile next to you (about half a second, with a short buzz on phones that support it) to arm a bomb there: its blast shows, and letting go places it. Sliding off the @@ -303,7 +309,7 @@ Immortal runs don't. A global board and a daily dungeon are planned (PLAN.md §1 and **New dungeon** (a random seed, same modes). The choice is remembered for next time, is fixed for the run, and shows as small yellow tags at the start of -the top bar's last row (tap them for details) and in the menu. Starting a new run while one is +the top bar's last row (tap them for details) and, by name only, in the menu. Starting a new run while one is going warns that it will be lost. ### The run diff --git a/decisions.md b/decisions.md index c9010a2..2bc0b23 100644 --- a/decisions.md +++ b/decisions.md @@ -1270,7 +1270,9 @@ its width with black bars beside it. And the app is now called **EXPLODORAMA!**. tears — its lines jump sideways, some far, in a custom post-process piece before the colour fringes, driven by a shader value, `interference`, set each frame and fluttering like a motor — the static gets ten times louder (`staticNoise`), and an SVG-noise snow jumps over the whole - screen, top bar and buttons included; then it stops at once. None with reduced motion. The finger is in + screen, top bar and buttons included; then it stops at once. None with reduced motion. Then + the sideways jumps were halved (user request, same day: the left-to-right distortion was too + much); the static, snow and darkening stayed as they were. The finger is in front of the glass (above it), the ripples on the screen (below it); the caption panel is not on the TV. - **No glow, no bulge.** LittleJS's TV effect glows from black up, and even a glow only past 0.8 @@ -1294,3 +1296,122 @@ exit is part of the rules (flame on an uncovered one lets enemies out; once open clears the level), the random games play with it; a probe over 40 seeds of every kind found all but one Nuke scene (a missing scene is skipped, and the tests' seeds all pass), the slowest 0.44 s. At a scene's start, 79% of the doors are still covered. + +### 2026-10-24 — Clips in the info dialogs: plan, and the drawing spike + +User request: now that the demo works, give the info dialogs animations made on the spot the same +way. The plan (every dialog's clip, which demo scene each reuses, the new ones, the order) is in +PLAN.md §13c. The user's answers: the finger where it helps, no CRT, a still where one frame says +it best under reduced motion, a 1 s hold and a new setup every loop, and every item on the list. +How to draw them was left to me: + +- **One canvas, drawn twice a frame while a clip shows**: the clip's room first, copied into a + `<canvas>` in the dialog, then the canvas cleared and the game drawn. The clips use exactly the + board's drawing and animation code, with nothing duplicated. Rejected: drawing the clip behind + a hole in the dialog (the modal's backdrop covers it too), a second renderer (a second copy of + the board's drawing), frames rendered ahead (heavy, and no new setup each loop). +- **The spike** (`ui/clip.ts`): LittleJS builds the camera's transform only at the start of a + frame (`glPreRender`, not exported), so the clip doesn't move the camera; the board gets an + origin instead (`setBoardOrigin` in board.ts, used by `toWorld` and `toTile`), and the clip's room + is drawn centred where the camera already looks, at the game's scale. After `glFlush`, that patch + of the WebGL canvas is copied with `drawImage` (the drawing buffer is still there within the + frame), then `glClearCanvas`, the origin back at 0, and the game drawn as before. Its own view + (effects, flames, moves) for now repeats main.ts's `animateTurn`; step 2 makes that view an + object both use. A clip stops when its dialog closes. +- **First clip, to prove it:** "What is that?" on an enemy played the demo's `enemies` scene in + the dialog, a turn every 0.6 s, its moment held 1 s, then a new setup. Checked in a browser: the + clip plays crisply above the words, loops with new rooms, the dimmed game behind is undisturbed, + and the board is normal once the dialog closes; no errors. +- **Enemies get no clip after all** (user's call, on seeing it): the scene was a busy room with all + three kinds, and the player learns soon enough how a Blob moves; the others aren't different + enough to need one either. Their dialogs keep only their picture, on the board and in the + almanac. The first clip is now "Still burning" (flame from last turn), with the `lingering` + scene. Clips should keep their rooms simple, with as few enemies as the moment needs. + +### 2026-10-24 — The demo from the menu; a shorter menu; bug reports on the backlog + +- **Watch the demo** (user request: "just seems fun"): a menu button that starts the demo again, + from its first scene (the controls'), as when the game opens; the run waits behind it, and the + tap that ends it brings the run back as it was (an auto-move destination is dropped). +- **The menu names the run's modes and nothing more** ("Modes: Developer, Immortal."), instead of + a paragraph on each (user request: the menu looked busy). The tags in the top bar still open + the full explanation. +- **Bug reports** through GitLab Service Desk (user request) are on PLAN.md's new Backlog: the + user turns it on in the project's settings, then a menu button opens an email to its address, + filled in with the version, device, seed and run record. + +### 2026-10-24 — Clips, step 2: one animation object, scenes made in a worker + +- **The board's animation state is one object** (`Animation` in `render/animate.ts`: effects, + flames, the player's move, pose and facing, enemy steps), with one `animateTurn` that starts a + turn's animations. main.ts keeps one for the game and the demo; each clip has its own. Nothing + about play changed; main.ts lost six module variables and its copy of the turn's animation. +- **Scenes are made in a Web Worker** (`sim/scene-worker.ts`; `makeScene` in `ui/scenes.ts`): one + can take ~0.45 s, which on the page stalls every animation. The demo makes its next scene, and a + clip its next loop, while the current one plays. Where a worker can't be made (or fails), they + are made on the page as before. Vite builds the worker as its own file (32 kB). +- Checked in a browser: three minutes of the demo with no errors, the "Still burning" clip, and the + menu's Watch the demo and back. + +### 2026-10-24 — The demo in order, numbered, with ◀ ▶ and ↺, carrying on where it was + +User request: "loop through all the possible demos in order, indicate which number demo this is, +and allow left/right controls to switch demo scenes. When a page is reloaded, the demos continue +from the same spot. Add a round arrow or something to restart from the beginning, but make it a +choice." + +- **One fixed order**, looped (`DEMO_ORDER`): the seven control scenes (the five on the controls, + the top-bar tour, "What is that?"), then the living scenes in the table's order with a way to + die after every four — the dying ones used to take turns with the living, shuffled; I kept them + spread out rather than all at the end. 43 in all. Each is still made new every time it plays. +- **The panel shows "n / 43"**, between **◀** and **▶** (Game Icons' arrow, the left one mirrored), + with **↺** (the UI Pack's repeat icon, as on New run) set apart after them: back to the first, only + when pressed. **← and →** do the same as ◀ ▶. These buttons and keys don't end the demo; any + other tap or key still does. Skipping stops whatever was under way (the finger's script, an open + dialog, the tour's ring) and plays the chosen scene as soon as it is made. +- **It carries on where it was**: the scene playing is kept in `localStorage` + (`explodorama.demoAt`), so the demo starts there next time — on a reload, and from the menu's + Watch the demo. Not on a test URL, which starts at the first scene and saves nothing (the + screenshots stay the same). +- Checked in a browser: ▶ and → step forward, ← and ◀ back (wrapping from 1 to 43), a reload + resumes at the same scene, ↺ goes to 1, and a tap elsewhere ends the demo; no errors. + +### 2026-10-24 — Report a bug, through GitLab Service Desk + +The user turned Service Desk on and gave its address; it lives in the source (`BUG_EMAIL` in +main.ts), not a CI variable: it is public by design — the game's email link carries it either way — +and if it draws spam, GitLab can give the project a new alias. The menu's **Report a bug** opens +an email to it (`mailto:`), subject "Bug report: version 23, level N", body "What happened:" with +room to write, then the details: version and build commit, the device and its user agent, seed, +level, turn, lives, status and modes, the last 5 errors the game caught and carried on from (kept +by `reportError`, their first lines), and the run record, which replays the run exactly. Some mail +apps cut off long links, so over ~1,900 characters the record stays out of the email; the full +details go on the clipboard (only then, so the clipboard isn't overwritten for nothing), and the +email asks to paste them. Checked in a browser: a short run's email carries the record (919 +characters); after 90 turns it doesn't, and the clipboard holds the full details. + +### 2026-10-24 — Sensible play in the demo + +User report: an early scene placed its bomb on the exit; demos should show reasonable actions, +"unless the entire point is to paint ourselves into a corner". The random play now only takes a +turn a sensible player might (`reasonable` in sim/demo.ts), except in the scenes about dying: + +- nothing that lets enemies out of an uncovered exit or gem — neither a turn whose flame does, + nor a bomb whose blast would reach one (the scene about blasting the exit is exempt); +- a bomb, placed or thrown, only where its blast reaches a crate, an enemy, a tile an enemy is + going to, or another bomb (to set it off) — never one that does nothing. + +A random game gets 6 goes at each turn instead of 4. On 40 seeds of every kind, every scene was +found (the one Nuke miss is gone too), the slowest in 0.5 s, in the worker. Tests: in the living +scenes no enemies are let out (but in the exit-blast scene) and no bomb goes on the exit. + +### 2026-10-24 — Long dialogs scroll by touch again + +User report: the almanac wouldn't scroll. By mouse wheel it did (fixed 2026-10-22); by finger it +didn't: LittleJS listens for touches on the whole document and cancels every one that isn't on a +control (no page scrolling in a game), which also cancelled a finger dragging the almanac's list, +and the gem's long offer. Touches inside the dialog now stop there (as the wheel already did), so +LittleJS never sees them; the board is behind the dialog anyway. The dialog's body also allows +vertical panning (`touch-action: pan-y`) against the body's `none`. Checked with simulated touch +drags in a mobile browser: the almanac's list scrolls (it didn't on the last three commits), and +taps on its tabs and buttons still work. diff --git a/docs/screenshots/demo.png b/docs/screenshots/demo.png index 01cfb6e..6db8f0a 100644 Binary files a/docs/screenshots/demo.png and b/docs/screenshots/demo.png differ diff --git a/docs/screenshots/menu.png b/docs/screenshots/menu.png index b525d58..a224f39 100644 Binary files a/docs/screenshots/menu.png and b/docs/screenshots/menu.png differ diff --git a/index.html b/index.html index e9cab75..9e9efe6 100644 --- a/index.html +++ b/index.html @@ -57,6 +57,12 @@ <div id="demo" hidden aria-live="polite"> <h1>EXPLODORAMA!</h1> <p id="demo-caption"></p> + <div class="demo-nav"> + <button id="demo-prev" type="button" aria-label="Previous scene"></button> + <span id="demo-count"></span> + <button id="demo-next" type="button" aria-label="Next scene"></button> + <button id="demo-restart" type="button" aria-label="Back to the first scene" title="Back to the first scene"></button> + </div> <p class="demo-hint">Tap anywhere to play</p> </div> <div id="demo-finger" hidden aria-hidden="true"></div> diff --git a/src/devlog.ts b/src/devlog.ts index e7c4490..59f453f 100644 --- a/src/devlog.ts +++ b/src/devlog.ts @@ -6,13 +6,23 @@ */ /** Which device it came from, in a few words: "Android 390×844", "iPhone 390×844", "desktop 1280×720". */ -function device(): string { +export function device(): string { const ua = navigator.userAgent const kind = /iPad/.test(ua) ? 'iPad' : /iPhone/.test(ua) ? 'iPhone' : /Android/.test(ua) ? 'Android' : 'desktop' return `${kind} ${innerWidth}×${innerHeight}` } +/** The last few errors caught, oldest first, for a bug report (main.ts `reportBug`). */ +const recent: string[] = [] +const RECENT_KEPT = 5 + export function reportError(where: string, error: unknown): void { const detail = error instanceof Error ? (error.stack ?? `${error.name}: ${error.message}`) : String(error) console.error(`[${device()}] ${where}: ${detail}`) + recent.push(`${where}: ${detail.split('\n')[0]}`) + if (recent.length > RECENT_KEPT) recent.shift() +} + +export function recentErrors(): readonly string[] { + return recent } diff --git a/src/main.ts b/src/main.ts index 3798d3a..c6574c1 100644 --- a/src/main.ts +++ b/src/main.ts @@ -17,8 +17,8 @@ import { vec2, worldToScreen, } from 'littlejsengine' -import { BACKGROUND, drawBoard, FLAME_SECONDS, playerPos, toTile, toWorld, type Flame } from './render/board' -import { clearEffects, newEffects, playerTransition, pruneEffects, startEffects, type PlayerMove, type PlayerPose } from './render/effects' +import { BACKGROUND, drawBoard, playerPos, toTile, toWorld } from './render/board' +import { animateTurn as animateBoard, clearAnimation, newAnimation, pruneAnimation } from './render/animate' import { updateCamera } from './render/camera' import { CHARGE_ICONS, MONO_ICONS, POWER_UP_ICONS, TEXTURES } from './render/sprites' import { isAvailable, isValidTarget, ACTIONS } from './sim/actions' @@ -34,13 +34,15 @@ import { DEFAULT_OPTIONS, DIRS, type Action, type ActionId, type ChargeId, type import { openAlmanac } from './ui/almanac' import { createControls } from './ui/controls' import { closeDialog, dialogOpen, openDialog, setDialogInDemo, type DialogButton } from './ui/dialog' -import { actionInfo, chargeInfo, hudInfo, modeLines, tileInfo, type Info } from './ui/info' +import { actionInfo, chargeInfo, hudInfo, tileInfo, type Info } from './ui/info' import { openOffer } from './ui/offer' +import { startClip, drawClip } from './ui/clip' +import { makeScene } from './ui/scenes' import { openScores, recordScore } from './ui/scores' import { countsForScores, scoreEntry } from './sim/scores' -import { reportError } from './devlog' +import { device, recentErrors, reportError } from './devlog' import { hashSeed } from './sim/rng' -import { CONTROL_KINDS, DYING_KINDS, generateScene, LIVING_KINDS, SCENE_KINDS, SCENES, type Gesture, type Scene, type SceneKind } from './sim/demo' +import { CONTROL_KINDS, DYING_KINDS, LIVING_KINDS, SCENES, type Gesture, type Scene, type SceneKind } from './sim/demo' import { spriteUrl } from './render/sprites' import playIcon from '../assets/kenney-ui/Extra/icon_play_light.png' import newRunIcon from '../assets/kenney-ui/Extra/icon_repeat_light.png' @@ -61,12 +63,8 @@ let selected: ActionId | null = null /** "What is that?" is on: the next tap on the board explains the tile instead of acting. */ let inspecting = false let hover: Vec | null = null -let flames: Flame[] = [] -let facingLeft = false -let playerMove: PlayerMove | null = null -let playerPose: PlayerPose = { kind: 'none', at: 0 } -const enemySteps = new Map<number, { from: Vec; at: number }>() -const fx = newEffects() +/** What animates the board between turns (render/animate.ts). */ +const anim = newAnimation() let snapCamera = true /** Auto-move: a far tile the player tapped. Each press of Move takes one step along `path` toward it. */ let destination: Vec | null = null @@ -94,14 +92,10 @@ function firstCharge(e: GameEvent): ChargeId | null { /** A fresh view of the board: no animations under way, nothing picked, the camera snapped. */ function resetView(): void { - playerMove = null - playerPose = { kind: 'none', at: 0 } - enemySteps.clear() - clearEffects(fx) + clearAnimation(anim) selected = null inspecting = false destination = null - flames = [] snapCamera = true } @@ -224,31 +218,10 @@ function act(action: Action, auto = false): void { /** A turn played: the new state, and everything that animates it (also for the demo). */ function animateTurn(result: ReturnType<typeof step>, action: Action): void { - const prev = state - startEffects(fx, prev, result.state, result.events, time) + const newLevel = animateBoard(anim, state, result, action, time) state = result.state - // The player's move comes from their change of tile, the pose from what happened; the pose - // waits for the move, so nothing can skip it (effects.ts `playerTransition`). - const player = playerTransition(prev, state, result.events, action.id === 'teleport', time) - if (player.move) playerMove = player.move - if (player.move || player.pose.kind !== 'none') playerPose = player.pose - if (player.move && player.move.from.x !== state.player.x) facingLeft = state.player.x < player.move.from.x - const newLevel = result.events.some((e) => e.type === 'levelStarted' || e.type === 'levelRestarted') - if (newLevel) { - snapCamera = true - playerMove = null - playerPose = player.pose - enemySteps.clear() - } + if (newLevel) snapCamera = true if (newLevel || result.events.some((e) => e.type === 'levelCleared')) destination = null - for (const e of result.events) if (e.type === 'exploded') flames.push({ tiles: e.tiles, born: time }) - // Every enemy that changed tile steps from where it was: from the state, like the player. - if (!newLevel) { - for (const enemy of state.enemies) { - const was = prev.enemies.find((o) => o.id === enemy.id) - if (was && (was.pos.x !== enemy.pos.x || was.pos.y !== enemy.pos.y)) enemySteps.set(enemy.id, { from: was.pos, at: time }) - } - } } /** How long after taking a gem (or a new charge) its dialog opens: the walk there and the rise away first. */ @@ -338,19 +311,64 @@ function showTileInfo(p: Vec): void { const info = tileInfo(state, p) if (!info) return const reachable = cellAt(state, p) === 'floor' && !isBurning(state, p) && !enemyAt(state, p) && playerPath(state, p) - showInfo(info, reachable ? [{ label: 'Walk there', kind: 'primary', onClick: () => setDestination(p) }] : []) + // Clips so far (PLAN.md §13c, step 1): flame still burning. Enemies get none, only their picture. + const clip = !enemyAt(state, p) && isBurning(state, p) ? 'lingering' : undefined + showInfo(info, reachable ? [{ label: 'Walk there', kind: 'primary', onClick: () => setDestination(p) }] : [], clip) } -function showInfo(info: Info, extra: DialogButton[] = []): void { +/** An info dialog; with `clip`, a looping clip of that kind of scene above the words (ui/clip.ts). */ +function showInfo(info: Info, extra: DialogButton[] = [], clip?: SceneKind): void { openDialog({ icon: spriteUrl(info.icon), title: info.title, - body: info.body.map((t) => `<p>${t}</p>`).join(''), + body: (clip ? '<canvas class="clip" aria-hidden="true"></canvas>' : '') + info.body.map((t) => `<p>${t}</p>`).join(''), buttons: [...extra, { label: 'Got it', kind: 'plain' }], }) + if (clip) startClip(document.querySelector<HTMLCanvasElement>('#dialog-body canvas.clip')!, clip) } const REPO_URL = 'https://gitlab.com/PavelSafronov/explodorama' +/** + * The project's GitLab Service Desk address (user, 2026-10-24): an email to it becomes a + * confidential issue, so players need no GitLab account. Public by design: it is in the game's + * email link either way. If it draws spam, GitLab can give it a new alias. + */ +const BUG_EMAIL = 'contact-project+pavelsafronov-fuzzyworld-82950314-issue-@incoming.gitlab.com' +/** Longer email links than this may be cut off by some mail apps: the run record then stays out. */ +const MAILTO_MAX = 1900 + +/** What a bug report says about the game and this run, to reproduce it (the run record replays it). */ +function bugDetails(withRecord: boolean): string { + const commit = import.meta.env.VITE_COMMIT ? `, commit ${import.meta.env.VITE_COMMIT}${import.meta.env.VITE_COMMIT_DIRTY ? ' plus local changes' : ''}` : '' + const modes = [state.options.dev && 'Developer', state.options.immortal && 'Immortal'].filter(Boolean).join(', ') || 'none' + const errors = recentErrors() + return [ + `Version ${SIM_VERSION}${commit}`, + `Device: ${device()}; ${navigator.userAgent}`, + `Seed ${state.seed}, level ${state.level}, turn ${state.turn}, ${state.lives} lives, ${state.status}; modes: ${modes}`, + `Errors caught: ${errors.length ? '\n' + errors.map((e) => `- ${e}`).join('\n') : 'none'}`, + ...(withRecord ? [`Run record: ${JSON.stringify(record)}`] : []), + ].join('\n') +} + +/** + * Report a bug (user request, 2026-10-24): an email to the Service Desk, filled in with the + * details and the run record. A long run's record makes the link too long for some mail apps: + * then it stays out, the full details go on the clipboard, and the email asks to paste them. + */ +function reportBug(): void { + const full = bugDetails(true) + const mailto = (details: string) => + `mailto:${BUG_EMAIL}?subject=${encodeURIComponent(`Bug report: version ${SIM_VERSION}, level ${state.level}`)}` + + `&body=${encodeURIComponent(`What happened:\n\n\n\n---\n${details}`)}` + const withRecord = mailto(full) + if (withRecord.length <= MAILTO_MAX) { + location.href = withRecord + return + } + void navigator.clipboard?.writeText(full).catch(() => {}) + location.href = mailto(bugDetails(false) + '\nRun record: too long for an email link. The full details are on your clipboard: please paste them here.') +} /** ", commit 0580e85" linked to it on GitLab, with a note if built with uncommitted changes. */ function commitLine(): string { @@ -360,14 +378,20 @@ function commitLine(): string { return `, commit ${link}${import.meta.env.VITE_COMMIT_DIRTY ? ' plus local changes' : ''}` } -/** The menu: resume, or abandon this run for a new one (confirmed first, since it can't be undone). */ +/** + * The menu: resume, the almanac, high scores, the demo again (user request, 2026-10-24: "just + * seems fun"), a bug report, or abandon this run for a new one (confirmed first, since it can't be undone). The + * run's modes are only named here, to keep it short (user request, same day); their tags in the + * top bar say what they do. + */ function showMenu(): void { const where = state.status === 'playing' ? `Level ${state.level}, turn ${state.turn}. ` : '' + const modes = [state.options.dev && 'Developer', state.options.immortal && 'Immortal'].filter(Boolean) openDialog({ title: 'Menu', body: `<p>${where}Seed ${state.seed}. The game saves after every move, so you can close it and come back.</p>` + - modeLines(state).map((l) => `<p class="mode">${l}</p>`).join('') + + (modes.length ? `<p class="mode">${modes.length > 1 ? 'Modes' : 'Mode'}: ${modes.join(', ')}.</p>` : '') + `<p class="credit">Art by <a href="https://www.kenney.nl" target="_blank" rel="noopener">Kenney</a>: ` + `New Platformer Pack, UI Pack, UI Pack: RPG Expansion, Game Icons and Board Game Icons (CC0). Thank you!</p>` + // The version is the rules version: it changes exactly when old saves stop resuming. @@ -377,6 +401,8 @@ function showMenu(): void { { label: 'Resume', kind: 'primary', icon: playIcon }, { label: 'Almanac', kind: 'plain', onClick: () => openAlmanac() }, { label: 'High scores', kind: 'plain', onClick: openScores }, + { label: 'Watch the demo', kind: 'plain', onClick: startDemo }, + { label: 'Report a bug', kind: 'plain', onClick: reportBug }, { label: 'New run', kind: 'danger', icon: newRunIcon, onClick: newRunDialog }, ], }) @@ -595,22 +621,65 @@ interface Beat { then: number } /** `rest`: the finger is still to be put beside the player, once the camera shows the new scene. */ -let demo: { scene: Scene; next: number; at: number; real: GameState; beats: Beat[]; rest: boolean } | null = null +/** + * `index`: the scene playing, in DEMO_ORDER. `upcoming`: the one to play next, being made in the + * background (`scene` undefined until it is). + */ +let demo: { + scene: Scene | null + index: number + upcoming: { index: number; scene: Scene | null | undefined } | null + next: number + at: number + real: GameState + beats: Beat[] + rest: boolean +} | null = null // The scenes' seeds: from `?seed` for a repeatable demo, otherwise random. let demoSeed = Number(params.get('seed')) || randomSeed() -// The scenes about the controls first, in their order; then two shuffled queues, taken in turn: -// a scene where the player lives, then one where they die (user request, 2026-10-23: alternate -// them). In their usual order with `?seed`, for a test URL. Once the living ones have all been -// shown, the controls come again. /** * Besides the scenes made in the sim, two acted out on a still room (user request, 2026-10-24): * a tour of the top bar, and "What is that?" on an enemy. They come after the controls' scenes. */ type DemoItem = SceneKind | 'topBar' | 'whatIsThat' -const CONTROL_ITEMS: readonly DemoItem[] = [...CONTROL_KINDS, 'topBar', 'whatIsThat'] -const demoQueues: { controls: DemoItem[]; living: SceneKind[]; dying: SceneKind[] } = { controls: [...CONTROL_ITEMS], living: [], dying: [] } -let demoDying = false +/** + * Every scene, in one fixed order, looped (user request, 2026-10-24; they were shuffled): the + * controls first, then the rest in the table's order (sim/demo.ts), a way to die after every + * four (they took turns until then: user request, 2026-10-23). + */ +const DEMO_ORDER: readonly DemoItem[] = [ + ...CONTROL_KINDS, + 'topBar', + 'whatIsThat', + ...LIVING_KINDS.flatMap((k, i) => (i % 4 === 3 && DYING_KINDS[(i - 3) / 4] ? [k, DYING_KINDS[(i - 3) / 4]!] : [k])), + ...DYING_KINDS.slice(Math.floor(LIVING_KINDS.length / 4)), +] +/** Where the demo is, kept so it carries on from there next time (not for a test URL: from 1). */ +const DEMO_KEY = 'explodorama.demoAt' + +function savedDemoIndex(): number { + try { + const n = Number(localStorage.getItem(DEMO_KEY)) + return !testing && Number.isInteger(n) && n >= 0 && n < DEMO_ORDER.length ? n : 0 + } catch { + return 0 + } +} + +function saveDemoIndex(i: number): void { + if (testing) return + try { + localStorage.setItem(DEMO_KEY, String(i)) + } catch { + // No storage: the demo starts from the beginning next time. + } +} const demoPanel = document.getElementById('demo')! +const demoCount = document.getElementById('demo-count')! +// The demo's buttons: the scene before, the scene after, and back to the first. +document.getElementById('demo-prev')!.addEventListener('click', () => demo && skipTo(demo.index - 1)) +document.getElementById('demo-next')!.addEventListener('click', () => demo && skipTo(demo.index + 1)) +document.getElementById('demo-restart')!.addEventListener('click', () => skipTo(0)) const demoCaption = document.getElementById('demo-caption')! const actionBar = document.getElementById('actions')! const finger = document.getElementById('demo-finger')! @@ -627,12 +696,13 @@ const crtScreen = document.getElementById('demo-crt')! const TV_ASPECT = 4 / 3 // Colour fringes and faint static. No glow: the board is pale and bright all over, and any glow // (postProcessTV's, or one only past 0.8) washed it out. First, interference (`vacuum` below): -// with `interference` above 0 the lines of the picture jump sideways, some far, and it darkens a -// little; the static is `staticNoise`, louder meanwhile. +// with `interference` above 0 the lines of the picture jump sideways, some far (half as far as at +// first: user request, 2026-10-24, too disruptive), and it darkens a little; the static is +// `staticNoise`, louder meanwhile. const CRT_SHADER = postProcessEffects( ` // interference float _torn = step(.75, fract(sin(floor(p.y / 4.) * 12.9898 + floor(iTime * 24.)) * 43758.5453)); - uv.x += interference * (_torn * .03 + .006 * sin(uv.y * 90. + iTime * 70.)); + uv.x += interference * (_torn * .015 + .003 * sin(uv.y * 90. + iTime * 70.)); c = texture(iChannel0, uv); c.rgb *= 1. - .12 * interference;`, postProcessChromatic(0.0025), @@ -698,48 +768,58 @@ const SCENE_ICONS: Record<SceneKind, string> = { dieDetonator: POWER_UP_ICONS.detonator, dieRun: POWER_UP_ICONS.run, } -/** The next kind of scene: the controls' queue first, then living and dying ones in turn. */ -function nextKind(): DemoItem { - if (demoQueues.controls.length) return demoQueues.controls.shift()! - const which = demoDying ? 'dying' : 'living' - demoDying = !demoDying - const all = which === 'dying' ? DYING_KINDS : LIVING_KINDS - if (!demoQueues[which].length) demoQueues[which] = params.has('seed') ? [...all] : [...all].sort(() => Math.random() - 0.5) - const kind = demoQueues[which].shift()! - if (which === 'living' && !demoQueues.living.length) demoQueues.controls = [...CONTROL_ITEMS] - return kind + +/** + * Starts making scene `index` of DEMO_ORDER in the background (ui/scenes.ts), to play once the + * current one ends: the next one, made while this one plays, or one picked with ◀ ▶. + */ +function prepareScene(index: number): void { + if (!demo) return + const i = (index + DEMO_ORDER.length) % DEMO_ORDER.length + const upcoming: NonNullable<typeof demo.upcoming> = { index: i, scene: undefined } + demo.upcoming = upcoming + const item = DEMO_ORDER[i]! + // The still scenes play on the room of a sim scene, without its turns. + void makeScene(item === 'topBar' ? 'tapMove' : item === 'whatIsThat' ? 'enemies' : item, demoSeed++).then((scene) => { + upcoming.scene = scene + }) } -/** The next scene: each cycling through its kinds in a shuffled order. */ +/** + * The upcoming scene, once made (called each frame until it is). One that couldn't be made (none + * found, rare) is passed over for the one after it. + */ function nextScene(): void { if (!demo) return - for (let tries = 0; tries < SCENE_KINDS.length; tries++) { - const item = nextKind() - // The still scenes play on the room of a sim scene, without its turns. - const scene = generateScene(item === 'topBar' ? 'tapMove' : item === 'whatIsThat' ? 'enemies' : item, demoSeed++) - if (!scene) continue - resetView() - hover = null - armed = null - demo.scene = scene - demo.next = 0 - demo.beats = [] - demo.at = time + DEMO_LEAD_SECONDS - state = scene.start - if (item === 'topBar' || item === 'whatIsThat') { - demo.scene = { ...scene, actions: [], gestures: [] } - // The tour's room holds a power-up and a charge, so the top bar's last row has something to show. - if (item === 'topBar') state = { ...state, powers: { ...state.powers, kick: true }, boosts: { ...state.boosts, pierce: 3 } } - demo.beats = item === 'topBar' ? topBarTour() : whatIsThat() - } else { - const icon = SCENE_ICONS[item] - setCaption(spriteUrl(icon), MONO_ICONS.has(icon), SCENES[item].caption) - } - afterChange() - // The finger comes back beside the player, where the eye should be (user request, 2026-10-24). - demo.rest = true - return + if (!demo.upcoming) prepareScene(demo.index + 1) + const { index, scene } = demo.upcoming! + if (scene === undefined) return + if (scene === null) return prepareScene(index + 1) + const item = DEMO_ORDER[index]! + demo.index = index + saveDemoIndex(index) + demoCount.textContent = `${index + 1} / ${DEMO_ORDER.length}` + prepareScene(index + 1) + resetView() + hover = null + armed = null + demo.scene = scene + demo.next = 0 + demo.beats = [] + demo.at = time + DEMO_LEAD_SECONDS + state = scene.start + if (item === 'topBar' || item === 'whatIsThat') { + demo.scene = { ...scene, actions: [], gestures: [] } + // The tour's room holds a power-up and a charge, so the top bar's last row has something to show. + if (item === 'topBar') state = { ...state, powers: { ...state.powers, kick: true }, boosts: { ...state.boosts, pierce: 3 } } + demo.beats = item === 'topBar' ? topBarTour() : whatIsThat() + } else { + const icon = SCENE_ICONS[item] + setCaption(spriteUrl(icon), MONO_ICONS.has(icon), SCENES[item].caption) } + afterChange() + // The finger comes back beside the player, where the eye should be (user request, 2026-10-24). + demo.rest = true } /** The demo's caption: an icon (an image URL; `mono` for a one-colour ink icon) and the words. */ @@ -813,8 +893,14 @@ function whatIsThat(): Beat[] { ] } +/** + * The demo: when the game opens, or from the menu; from where it was last time (user request, + * 2026-10-24), its ↺ button back to the start. + */ function startDemo(): void { - demo = { scene: null!, next: 0, at: 0, real: state, beats: [], rest: false } + const at = savedDemoIndex() + demo = { scene: null, index: at, upcoming: null, next: 0, at: 0, real: state, beats: [], rest: false } + prepareScene(at) document.body.classList.add('demo') demoPanel.hidden = false finger.hidden = false @@ -824,14 +910,13 @@ function startDemo(): void { setDialogInDemo(true) postProcess?.setShaderCode(CRT_SHADER) nextScene() - if (!demo.scene) endDemo() } /** Called every frame: the script's next beat when it is due, the next turn's script, or the next scene. */ function demoTick(): void { if (!demo || time < demo.at) return if (!demo.beats.length) { - if (demo.next >= demo.scene.actions.length) return nextScene() + if (!demo.scene || demo.next >= demo.scene.actions.length) return nextScene() const i = demo.next++ demo.beats = beatsFor(demo.scene.gestures[i]!, demo.scene.actions[i]!, demo.next === demo.scene.actions.length) } @@ -909,7 +994,7 @@ function centreOf(el: Element) { function placeDemoMarks(): void { if (!demo) return // Hugging the top of the player's head, always over them (user request, 2026-10-24). - const tip = worldToScreen(playerPos(state, playerMove).add(vec2(0, 0.4))) + const tip = worldToScreen(playerPos(state, anim.playerMove).add(vec2(0, 0.4))) const r = mainCanvas.getBoundingClientRect() youTag.style.transform = `translate(${r.left + tip.x}px, ${r.top + tip.y}px)` if (!demo.rest) return @@ -951,6 +1036,23 @@ function placeMark(className: string): HTMLElement { return mark } +/** + * ◀ ▶ (or ← →), and ↺ (0): leaves the scene playing for scene `index`, as soon as it is made. + * Anything under way stops: the finger's script, a dialog, the tour's ring. + */ +function skipTo(index: number): void { + if (!demo) return + demo.beats = [] + demo.scene = null + demo.at = 0 + if (dialogOpen()) closeDialog() + spotlight(null) + fingerUp() + for (const b of actionBar.querySelectorAll('.pressed')) b.classList.remove('pressed') + finger.classList.add('away') + prepareScene(index) +} + function endDemo(): void { if (!demo) return state = demo.real @@ -981,7 +1083,8 @@ let swallowClick = 0 addEventListener( 'pointerdown', (e) => { - if (!demo) return + // The demo's own buttons (◀ ▶ ↺) work, and keep it going. + if (!demo || (e.target as Element).closest?.('.demo-nav')) return endDemo() swallowClick = performance.now() + 600 e.stopPropagation() @@ -1004,6 +1107,13 @@ addEventListener( 'keydown', (e) => { if (!demo) return + e.stopPropagation() + e.preventDefault() + // ← and → go through the scenes; any other key ends the demo. + if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') { + if (!e.repeat) skipTo(demo.index + (e.key === 'ArrowLeft' ? -1 : 1)) + return + } endDemo() e.stopPropagation() e.preventDefault() @@ -1093,17 +1203,18 @@ await engineInit( placeDemoMarks() if (demo) vacuum() snapCamera = false - flames = flames.filter((f) => time - f.born < FLAME_SECONDS) - pruneEffects(fx, time) + pruneAnimation(anim, time) autoStep() }), () => {}, - guarded('draw', () => + guarded('draw', () => { + // A dialog's clip first: it uses the canvas for a moment, then clears it (ui/clip.ts). + drawClip() drawBoard({ - state, danger, selected, hover, flames, facingLeft, destination, path, armed, playerMove, playerPose, enemySteps, fx, + state, danger, selected, hover, destination, path, armed, ...anim, pathBlocked: !!path?.[0] && !safeStep(path[0]), - }), - ), + }) + }), () => {}, TEXTURES, ) diff --git a/src/render/animate.ts b/src/render/animate.ts new file mode 100644 index 0000000..874368f --- /dev/null +++ b/src/render/animate.ts @@ -0,0 +1,70 @@ +import type { step } from '../sim/turn' +import type { Action, GameState, Vec } from '../sim/types' +import { FLAME_SECONDS, type Flame } from './board' +import { clearEffects, newEffects, playerTransition, pruneEffects, startEffects, type Effects, type PlayerMove, type PlayerPose } from './effects' + +/** + * Everything that animates a board between turns: what the last turns changed (effects.ts), the + * flame of a blast, the player's move and pose and which way they face, and each enemy's latest + * step. One for the game (and the demo, which plays on its board), one for each dialog's clip + * (ui/clip.ts), so a clip can play beside the game (PLAN.md §13c). + */ +export interface Animation { + fx: Effects + flames: Flame[] + playerMove: PlayerMove | null + playerPose: PlayerPose + /** The way the player last stepped. The character sprite faces right; this mirrors it. */ + facingLeft: boolean + /** Each enemy's latest step, by id: where from, and when (LittleJS `time`). */ + enemySteps: Map<number, { from: Vec; at: number }> +} + +export function newAnimation(): Animation { + return { fx: newEffects(), flames: [], playerMove: null, playerPose: { kind: 'none', at: 0 }, facingLeft: false, enemySteps: new Map() } +} + +/** A fresh board: nothing under way. */ +export function clearAnimation(a: Animation): void { + clearEffects(a.fx) + a.flames = [] + a.playerMove = null + a.playerPose = { kind: 'none', at: 0 } + a.enemySteps.clear() +} + +/** Each frame: what has finished playing, dropped. */ +export function pruneAnimation(a: Animation, now: number): void { + a.flames = a.flames.filter((f) => now - f.born < FLAME_SECONDS) + pruneEffects(a.fx, now) +} + +/** + * A turn played, from `prev` to `result.state`: everything that animates it starts at `now`. + * True if a level started or restarted (the board is new: nothing walks over from the old one). + */ +export function animateTurn(a: Animation, prev: GameState, result: ReturnType<typeof step>, action: Action, now: number): boolean { + const next = result.state + startEffects(a.fx, prev, next, result.events, now) + // The player's move comes from their change of tile, the pose from what happened; the pose + // waits for the move, so nothing can skip it (effects.ts `playerTransition`). + const player = playerTransition(prev, next, result.events, action.id === 'teleport', now) + if (player.move) a.playerMove = player.move + if (player.move || player.pose.kind !== 'none') a.playerPose = player.pose + if (player.move && player.move.from.x !== next.player.x) a.facingLeft = next.player.x < player.move.from.x + const newLevel = result.events.some((e) => e.type === 'levelStarted' || e.type === 'levelRestarted') + if (newLevel) { + a.playerMove = null + a.playerPose = player.pose + a.enemySteps.clear() + } + for (const e of result.events) if (e.type === 'exploded') a.flames.push({ tiles: e.tiles, born: now }) + // Every enemy that changed tile steps from where it was: from the state, like the player. + if (!newLevel) { + for (const enemy of next.enemies) { + const was = prev.enemies.find((o) => o.id === enemy.id) + if (was && (was.pos.x !== enemy.pos.x || was.pos.y !== enemy.pos.y)) a.enemySteps.set(enemy.id, { from: was.pos, at: now }) + } + } + return newLevel +} diff --git a/src/render/board.ts b/src/render/board.ts index 966d80b..22a74e9 100644 --- a/src/render/board.ts +++ b/src/render/board.ts @@ -12,16 +12,26 @@ import { } from './effects' import { CHARGE_ICONS, digitSprite, ENEMY_SPRITES, GEM_SPRITES, sprite, wallSprite, type BoardSprite } from './sprites' +/** + * Where the board's bottom-left corner is in world space: the origin, except while a dialog's + * clip is drawn (ui/clip.ts), which draws its own room somewhere else for a moment. + */ +let origin = vec2(0) + +export function setBoardOrigin(o: Vector2): void { + origin = o +} + /** * The one place where sim tiles meet LittleJS world space. The sim's y grows downward * (row 0 at the top); LittleJS's y grows upward. A tile's centre is at (x + 0.5, y + 0.5). */ export function toWorld(s: GameState, p: Vec): Vector2 { - return vec2(p.x + 0.5, s.height - 1 - p.y + 0.5) + return vec2(origin.x + p.x + 0.5, origin.y + s.height - 1 - p.y + 0.5) } export function toTile(s: GameState, w: Vector2): Vec { - return { x: Math.floor(w.x), y: s.height - 1 - Math.floor(w.y) } + return { x: Math.floor(w.x - origin.x), y: s.height - 1 - Math.floor(w.y - origin.y) } } /** Shown for a moment after a bomb goes off. Flame has no lasting effect on the board. */ diff --git a/src/sim/demo.ts b/src/sim/demo.ts index 0b8917a..e0f8b7b 100644 --- a/src/sim/demo.ts +++ b/src/sim/demo.ts @@ -1,5 +1,6 @@ import { ACTIONS, isAvailable, isValidTarget } from './actions' import { predictDanger } from './danger' +import { blastTiles } from './explosions' import { planIntents } from './enemies' import { Rng } from './rng' import { autoMoveAction, playerPath, safeStep } from './path' @@ -624,19 +625,19 @@ function rollout(kind: SceneKind, start: GameState, rng: Rng): GameTurn[] | null const walking: Walking = { to: null } const def = SCENES[kind] for (let n = 0; n < MAX_TURNS && s.status === 'playing' && !s.offer; n++) { - // A random turn (or the scene's nudge) that doesn't get the player killed, a few goes at it — - // unless dying is what the scene is about, and this is the way it shows. Only turns a gesture - // can play. + // A random turn (or the scene's nudge) that doesn't get the player killed and that a sensible + // player might take, a few goes at it — unless dying is what the scene is about, and this is + // the way it shows. Only turns a gesture can play. let turn: GameTurn | null = null const was = walking.to - for (let go = 0; go < 4 && !turn; go++) { + for (let go = 0; go < 6 && !turn; go++) { walking.to = was const play = def.steer?.(s, rng, walking) ?? playOf(def, s, randomAction(s, rng)) if (!play) continue const r = step(s, play.action) const t = { prev: s, ...play, events: r.events, next: r.state, walking: was !== null } const alive = r.state.status === 'playing' || r.state.status === 'cleared' || r.state.status === 'won' - if (alive || (def.dies && def.shows(t, seen))) turn = t + if ((alive && (def.dies || reasonable(kind, t))) || (def.dies && def.shows(t, seen))) turn = t } if (!turn) return null turns.push(turn) @@ -648,6 +649,29 @@ function rollout(kind: SceneKind, start: GameState, rng: Rng): GameTurn[] | null return null } +/** + * Whether a turn is one a sensible player might take (user request, 2026-10-24: a scene put a + * bomb on the exit). Not asked in the scenes about dying, whose point is a mistake. + * - No flame on an uncovered exit or gem, which lets enemies out — unless that is the scene. + * - A bomb (placed or thrown) only where its blast reaches a crate, an enemy, a tile an enemy is + * going to, or another bomb (to set it off): never one that does nothing. + */ +function reasonable(kind: SceneKind, t: Turn): boolean { + const punishing = kind !== 'doorBlast' + if (punishing && t.events.some((e) => e.type === 'enemiesSpawned')) return false + if ((t.action.id !== 'bomb' && t.action.id !== 'throw') || !('target' in t.action) || !t.action.target) return true + const target = t.action.target + const bomb = t.next.bombs.find((b) => at(b.pos, target)) + if (!bomb) return true + const tiles = blastTiles(t.next, bomb) + const s = t.prev + const uncovered = (p: Vec) => s.cells[idx(s, p)] === 'floor' && (at(p, s.exit) || s.gems.some((g) => at(g.pos, p))) + if (punishing && tiles.some(uncovered)) return false + return tiles.some( + (p) => s.cells[idx(s, p)] === 'block' || s.bombs.some((b) => at(b.pos, p)) || s.enemies.some((e) => at(e.pos, p) || e.plan.some((q) => at(q, p))), + ) +} + /** * The gesture that plays `action` in a scene, or null if none does (a step onto a bomb with Bomb * Pass: a tap there kicks it, or means nothing). A step, a diagonal step, a kick or a detonation is diff --git a/src/sim/scene-worker.ts b/src/sim/scene-worker.ts new file mode 100644 index 0000000..236267a --- /dev/null +++ b/src/sim/scene-worker.ts @@ -0,0 +1,11 @@ +import { generateScene, type SceneKind } from './demo' + +/** + * Makes the demo's and the dialogs' scenes off the main thread (ui/scenes.ts): one can take up to + * ~0.45 s, which played on the page would stall the animation. A request is an id, a kind and a + * seed; the answer is the id and the scene, or null if none was found. + */ +self.onmessage = (e: MessageEvent<{ id: number; kind: SceneKind; seed: number }>) => { + const { id, kind, seed } = e.data + self.postMessage({ id, scene: generateScene(kind, seed) }) +} diff --git a/src/style.css b/src/style.css index d3c872f..fb086a4 100644 --- a/src/style.css +++ b/src/style.css @@ -553,6 +553,8 @@ img { overflow-y: auto; /* Scrolls by touch, wheel or trackpad, without a scroll bar across the parchment. */ scrollbar-width: none; + /* The body has touch-action: none (a game surface); a finger may still scroll this. */ + touch-action: pan-y; font-size: 15px; line-height: 1.4; } @@ -841,6 +843,46 @@ body.demo #hud { filter: brightness(0) invert(1); } +/* Which scene this is, and the buttons to go through them (main.ts): ◀ n / N ▶, and ↺ back to + the first, set apart. They don't end the demo. */ +#demo .demo-nav { + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + margin-top: 8px; +} + +#demo-count { + min-width: 4.5em; + font-variant-numeric: tabular-nums; + font-weight: 700; +} + +#demo .demo-nav button { + width: 36px; + height: 36px; + border: 0; + border-radius: 50%; + background: rgb(255 255 255 / 0.14) center / 18px no-repeat; + cursor: pointer; +} + +#demo #demo-prev, +#demo #demo-next { + background-image: url('../assets/kenney-icons/White/arrowRight.png'); +} + +#demo-prev { + transform: scaleX(-1); +} + +#demo #demo-restart { + margin-left: 14px; + background-image: url('../assets/kenney-ui/Extra/icon_repeat_light.png'); + background-size: 22px; +} + #demo .demo-hint { margin: 10px 0 0; color: #ffc83d; @@ -1212,3 +1254,15 @@ body.demo #actions { display: none; } } + +/* A dialog's clip (ui/clip.ts): the scene, drawn by the game, above the words. */ +#dialog-body canvas.clip { + display: block; + width: 100%; + max-width: 300px; + height: auto; + /* A room's shape (9 × 7), held while its first scene is being made. */ + aspect-ratio: 9 / 7; + margin: 0 auto; + border-radius: 8px; +} diff --git a/src/ui/clip.ts b/src/ui/clip.ts new file mode 100644 index 0000000..3966846 --- /dev/null +++ b/src/ui/clip.ts @@ -0,0 +1,117 @@ +import { cameraPos, getCanvasPixelRatio, glCanvas, glClearCanvas, glFlush, time, vec2, worldToScreen } from 'littlejsengine' +import { animateTurn, newAnimation, pruneAnimation, type Animation } from '../render/animate' +import { drawBoard, setBoardOrigin } from '../render/board' +import { predictDanger } from '../sim/danger' +import type { Scene, SceneKind } from '../sim/demo' +import { step } from '../sim/turn' +import type { GameState } from '../sim/types' +import { makeScene } from './scenes' + +/** + * A dialog's clip (PLAN.md §13c, user request, 2026-10-24): a small looping scene of what the + * dialog describes, made on the spot like the demo's (sim/demo.ts), each loop a new setup. + * + * Drawn with the board's own code: there is one LittleJS canvas, so while a clip shows, each + * frame first draws the clip's room (at an offset in world space, where the camera looks), copies + * that patch of the canvas into the dialog's <canvas>, clears, and then the game is drawn as + * usual (`drawClip`, called before the game's drawing). Scenes are made in the background + * (ui/scenes.ts), the next loop's while this one plays. + */ +interface Clip { + canvas: HTMLCanvasElement + kind: SceneKind + seed: number + /** The loop playing (null until the first is made), and the next one, once made. */ + scene: Scene | null + upcoming: Scene | null + state: GameState | null + /** The next turn to play, and when (LittleJS `time`). */ + next: number + at: number + anim: Animation +} + +/** A turn every this many seconds; the moment held this long; a new setup's first turn after this. */ +const STEP_SECONDS = 0.6 +const HOLD_SECONDS = 1 +const LEAD_SECONDS = 0.5 + +let clip: Clip | null = null + +/** Plays a clip of `kind` in `canvas` (in an open dialog) until the dialog closes or another starts. */ +export function startClip(canvas: HTMLCanvasElement, kind: SceneKind): void { + const c: Clip = { + canvas, kind, seed: Math.floor(Math.random() * 2 ** 31), scene: null, upcoming: null, state: null, next: 0, at: 0, anim: newAnimation(), + } + clip = c + prepare(c) +} + +/** Makes the next loop's scene in the background (a new setup each loop). */ +function prepare(c: Clip): void { + void makeScene(c.kind, c.seed++).then((scene) => { + // None found (rare): try the next seed. + if (!scene) return clip === c && prepare(c) + c.upcoming = scene + }) +} + +/** Each frame: the clip's next turn when due, or the next loop once made; its animations moving on. */ +function tick(c: Clip): void { + pruneAnimation(c.anim, time) + const done = !c.scene || (c.next >= c.scene.actions.length && time >= c.at) + if (done && c.upcoming) { + c.scene = c.upcoming + c.upcoming = null + c.state = c.scene.start + c.next = 0 + c.at = time + LEAD_SECONDS + c.anim = newAnimation() + prepare(c) + return + } + if (!c.scene || !c.state || time < c.at || c.next >= c.scene.actions.length) return + const action = c.scene.actions[c.next++]! + const result = step(c.state, action) + animateTurn(c.anim, c.state, result, action, time) + c.state = result.state + c.at = time + (c.next < c.scene.actions.length ? STEP_SECONDS : HOLD_SECONDS) +} + +/** + * Called in the draw callback before the game's own drawing: the clip's room, drawn where the + * camera looks, copied into its canvas; then the canvas cleared for the game. Nothing, and the + * clip dropped, once its dialog has closed (or its canvas has gone). + */ +export function drawClip(): void { + if (!clip) return + if (!clip.canvas.isConnected || !clip.canvas.closest('dialog')?.open) { + clip = null + return + } + const c = clip + tick(c) + const s = c.state + if (!s) return + // The room centred where the camera looks, so it lies inside the canvas. + const corner = cameraPos.subtract(vec2(s.width / 2, s.height / 2)) + setBoardOrigin(corner) + try { + drawBoard({ + state: s, danger: predictDanger(s), selected: null, hover: null, destination: null, path: null, pathBlocked: false, armed: null, + ...c.anim, + }) + glFlush() + // The room's patch of the canvas, in its backing pixels, top-left first. + const dpr = getCanvasPixelRatio() + const topLeft = worldToScreen(corner.add(vec2(0, s.height))) + const bottomRight = worldToScreen(corner.add(vec2(s.width, 0))) + const w = Math.round((bottomRight.x - topLeft.x) * dpr) + const h = Math.round((bottomRight.y - topLeft.y) * dpr) + if (c.canvas.width !== w || c.canvas.height !== h) Object.assign(c.canvas, { width: w, height: h }) + c.canvas.getContext('2d')!.drawImage(glCanvas, topLeft.x * dpr, topLeft.y * dpr, w, h, 0, 0, w, h) + } finally { + setBoardOrigin(vec2(0)) + glClearCanvas() + } +} diff --git a/src/ui/dialog.ts b/src/ui/dialog.ts index 724c2ba..ce3b004 100644 --- a/src/ui/dialog.ts +++ b/src/ui/dialog.ts @@ -78,6 +78,12 @@ dialog.addEventListener('click', (e) => { // LittleJS cancels every wheel event on the page (so the game never scrolls it), which also // stopped a long dialog, like the almanac, scrolling with a mouse wheel or trackpad. Kept here. dialog.addEventListener('wheel', (e) => e.stopPropagation(), { passive: true }) +// The same for touch: LittleJS cancels every touch on the page that isn't on a control, so a +// finger couldn't scroll a long dialog, like the almanac, on a phone (user report, 2026-10-24). +// Touches in the dialog don't reach it; the board is behind the dialog anyway. +for (const type of ['touchstart', 'touchmove', 'touchend', 'touchcancel'] as const) { + dialog.addEventListener(type, (e) => e.stopPropagation(), { passive: true }) +} // Esc closes it too, animated like the rest. dialog.addEventListener('cancel', (e) => { e.preventDefault() diff --git a/src/ui/scenes.ts b/src/ui/scenes.ts new file mode 100644 index 0000000..f6d8ec5 --- /dev/null +++ b/src/ui/scenes.ts @@ -0,0 +1,36 @@ +import { generateScene, type Scene, type SceneKind } from '../sim/demo' + +/** + * Scenes for the demo and the dialogs' clips, made in a Web Worker (sim/scene-worker.ts) so the + * page never waits on one (PLAN.md §13c). Where a worker can't be made, on the page instead. + */ +let worker: Worker | null = null +try { + worker = new Worker(new URL('../sim/scene-worker.ts', import.meta.url), { type: 'module' }) +} catch { + worker = null +} + +let nextId = 0 +const waiting = new Map<number, { kind: SceneKind; seed: number; done: (scene: Scene | null) => void }>() +worker?.addEventListener('message', (e: MessageEvent<{ id: number; scene: Scene | null }>) => { + waiting.get(e.data.id)?.done(e.data.scene) + waiting.delete(e.data.id) +}) +// A worker that fails (a script that wouldn't load) answers nothing: what waits on it, and all +// after, is made here. +worker?.addEventListener('error', () => { + worker = null + for (const { kind, seed, done } of waiting.values()) done(generateScene(kind, seed)) + waiting.clear() +}) + +/** A scene of `kind` from `seed`, when it is ready; null if none was found. */ +export function makeScene(kind: SceneKind, seed: number): Promise<Scene | null> { + if (!worker) return Promise.resolve(generateScene(kind, seed)) + const id = nextId++ + return new Promise((resolve) => { + waiting.set(id, { kind, seed, done: resolve }) + worker!.postMessage({ id, kind, seed }) + }) +} diff --git a/test/demo.test.ts b/test/demo.test.ts index 8cb309e..c11301e 100644 --- a/test/demo.test.ts +++ b/test/demo.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import { isValidTarget } from '../src/sim/actions' import { predictDanger } from '../src/sim/danger' -import { generateScene, MAX_STEPS, MIN_STEPS, SCENE_KINDS, SCENES, showsAtEnd, type Gesture } from '../src/sim/demo' +import { generateScene, MAX_STEPS, MIN_STEPS, replayScene, SCENE_KINDS, SCENES, showsAtEnd, type Gesture } from '../src/sim/demo' import { autoMoveAction, safeStep } from '../src/sim/path' import { resolvePick, tapMeaning } from '../src/sim/picks' import { step } from '../src/sim/turn' @@ -55,6 +55,14 @@ describe('demo scenes', () => { expect(last && SCENES[kind].dies ? ['dead', 'gameOver'] : ['playing', 'cleared']).toContain(s.status) } expect(showsAtEnd(scene)).toBe(true) + // Sensible play, unless the scene is about a mistake: nothing lets enemies out of the exit + // or the gem (but in the scene about that), and no bomb goes on the exit. + if (!SCENES[kind].dies) { + for (const t of replayScene(scene).turns) { + if (kind !== 'doorBlast') expect(t.events.some((e) => e.type === 'enemiesSpawned')).toBe(false) + if (kind !== 'doorBlast' && t.action.id === 'bomb' && 'target' in t.action) expect(t.action.target).not.toEqual(t.prev.exit) + } + } } }) }