explodorama / commit
Demo title on top, compact menu, a demo scene for the charge swap
CHANGELOG.md | 2 ++ PLAN.md | 11 +++++++- README.md | 28 +++++++++++-------- decisions.md | 19 +++++++++++++ docs/screenshots/auto-move.png | Bin 216615 -> 216588 bytes docs/screenshots/bomb-picked.png | Bin 215795 -> 215822 bytes docs/screenshots/countdown.png | Bin 219725 -> 219733 bytes docs/screenshots/death.png | Bin 233761 -> 233775 bytes docs/screenshots/demo.png | Bin 962995 -> 952229 bytes docs/screenshots/desktop.png | Bin 313060 -> 313044 bytes docs/screenshots/dev-offer.png | Bin 238816 -> 202961 bytes docs/screenshots/dev.png | Bin 219429 -> 219399 bytes docs/screenshots/enemies.png | Bin 215183 -> 215194 bytes docs/screenshots/gem-offer.png | Bin 196435 -> 217813 bytes docs/screenshots/gem.png | Bin 200068 -> 200159 bytes docs/screenshots/info-hud.png | Bin 187998 -> 184678 bytes docs/screenshots/info-mode.png | Bin 214124 -> 214204 bytes docs/screenshots/lingering.png | Bin 219234 -> 219173 bytes docs/screenshots/long-press.png | Bin 219409 -> 219476 bytes docs/screenshots/menu.png | Bin 146351 -> 150070 bytes docs/screenshots/move-picked.png | Bin 216061 -> 216107 bytes docs/screenshots/shop.png | Bin 205099 -> 202986 bytes index.html | 2 +- src/main.ts | 31 ++++++++++++++++----- src/sim/demo.ts | 20 +++++++++++--- src/style.css | 57 ++++++++++++++++++++++++++++++++------- src/ui/clip.ts | 3 ++- src/ui/controls.ts | 3 ++- test/demo.test.ts | 1 + 29 files changed, 144 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b8b949a..80bf8bd 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 +- **A demo scene for the charge swap:** carrying a Pierce, the player picks up a Nuke, and the finger takes it in the choice that opens. - **One charge at a time, used when you choose:** a charge button beside Bomb (or V) puts down a Pierce, Power or Nuke bomb, fires an ICBM, freezes the enemies or places a Decoy; Bomb always places a plain bomb; a Shield is raised with it too and stays up until something hits you, and a Decoy is put up to 3 tiles away in a straight line and lasts until the level ends or something reaches it. The charge button is always there (an empty slot with none). Picking up, choosing or buying a different charge asks: keep yours and sell the new one, or take it and sell yours. Ends the Nuke trap, where a Nuke forced on every bomb made a level impossible. Version 29: runs saved before don't resume. - **The balance lab** (for development): bots play thousands of games on the real rules to measure what each power-up does (`npm run balance`, reports in `docs/balance/`). - **Four new enemies:** the **Sprinter** (rests, then dashes 2 tiles), the **Ghost** (every other turn, chasing you through crates), the **Wisp** (wanders through crates) and the **Hunter** (always chases). A new type now comes every 3 levels, so a run meets all seven. Version 28: runs saved before don't resume. @@ -89,6 +90,7 @@ saved before the change is dropped and the next visit starts a new run. ### Changed +- **The demo's title is on top,** above the TV, and the **menu is compact:** Resume and New side by side, then Almanac, Scores, Demo and Bug in one row. - **Proximity alert:** a bomb now goes off only when an enemy is right next to it, not anywhere in its reach, so a bomb dropped between you and an enemy no longer blows up next to you. - 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. diff --git a/PLAN.md b/PLAN.md index 3a90150..10c25a5 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1024,7 +1024,16 @@ tools/ compose-icons.sh (composed sprites and the app icons), sc ### Backlog -- **UI changes, queued (user, 2026-10-25; to do eventually):** +- **Lose the carried charge on a death? (user idea, 2026-10-25; to decide.)** Today the charge + carried goes through a death (§3.3). Dropping it would make a death cost more than the life and + give a reason to use a charge rather than hoard it; but it also hits hardest the player who is + already struggling, and the retry would start without the tool that might have saved them. A + middle way: the charge drops on the tile where the player died, to be picked up again on the + retry (the level restarts the same, so it is close by) — or only charges *picked up during this + attempt* are lost, as the gem's choice already is. The balance lab can measure each. +- ~~**UI changes, queued (user, 2026-10-25)**~~ *Built 2026-10-25:* the demo's title is its own + bar above the TV (the top bar and the TV move down under it; `--demo-title`), and the menu has + Resume and New side by side over a row of four small buttons (`className: 'menu'`). 1. **The demo's title on top:** "EXPLODORAMA!" moves to the very top, above the board, so the demo reads title, then the TV, then the caption. Once the game starts, both the title and the caption go. diff --git a/README.md b/README.md index 5675439..27993a8 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,9 @@ resumed, and a seed gives the same dungeon only within one version. ## How to play **The demo:** when the game opens, it first plays a demo — short scenes made up on the spot from -the real rules, each with a caption and an icon. Each scene gets to its point quickly: what it is +the real rules, each with a caption and an icon. The screen reads top to bottom: the title +**EXPLODORAMA!**, the TV with the scene, then the caption; both the title and the caption go +once the game starts. Each scene gets to its point quickly: what it is about happens 3 to 6 turns in, starting from a calm board (no bomb or flame yet). **A finger shows the controls** as it plays every turn, on the real action bar (it sits right under the board, apart from the caption at the bottom: what you play on, then what explains it): a tap on a tile (with a ripple @@ -48,7 +50,9 @@ power-up you can see in action (Bomb +1, Fire +1, Fuse +1, Run, Kick — a tap o you —, Throw, Teleport, Diagonal, Slide, RPG, the Detonator — a tap on your bomb —, Proximity alert, Wall / Bomb / Flame Pass, Star) and every charge (Pierce, Power, Nuke, Shield, -Freeze, Decoy, ICBM), and the exit (walking out once it opens; blasting it lets enemies out). Heart and +Freeze, Decoy, ICBM — each used with the charge button), **the charge swap** (carrying a +Pierce, the player picks up a Nuke; the choice opens over the scene and the finger taps Take +Nuke), and the exit (walking out once it opens; blasting it lets enemies out). Heart and 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 @@ -58,7 +62,7 @@ time. The panel shows which one it is (**12 / 47**); **◀ ▶** (or 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. +else. The menu's **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"> @@ -135,12 +139,14 @@ a finger shows how. Enemies have only their picture. With reduced motion turned a still picture of its moment instead. **Menu:** the round button at the top right (or **Esc** with nothing picked). It shows the level -and seed, the **Sound** and **Vibration** switches (see *Sound and vibration* below), and has the **Almanac** — every power-up, charge and enemy type, with its -picture and what it does, in three tabs; an entry with a green ▶ opens its clip — **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. +and seed, the **Sound** and **Vibration** switches (see *Sound and vibration* below), and its +buttons, kept compact: **Resume** and **New** side by side (New starts a new run and asks first, +since the current run can't be got back), then one row of small ones — **Almanac** (every +power-up, charge and enemy type, with its picture and what it does, in three tabs; an entry with +a green ▶ opens its clip), **Scores** (the high scores), **Demo** (the demo again) and **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). **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 @@ -340,7 +346,7 @@ replayed. ### New run: modes -**Menu → New run** (or the button after the victory) starts a new run, with two optional +**Menu → New** (or the button after the victory) starts a new run, with two optional modes (there is one board size, and crates always crumble once the enemies are gone): | Mode | What it does | @@ -353,7 +359,7 @@ dungeon; type a seed (shown in the menu and at the end of a run) to play that du one with a friend or try it again. Any other text, a word say, is made into a seed too. **High scores:** every finished run — game over or the win — goes on a board kept in this -browser (Menu → High scores, or the button on the end-of-run banner, which also says when a run +browser (Menu → Scores, or the button on the end-of-run banner, which also says when a run makes it). Runs are ranked by how far they got (a win first, then the level), then coins, then fewer turns; the best 25 are kept. Each shows the enemies killed, turns, lives lost, how it ended, the power-ups held, the date and the seed. Developer runs count, with a red border; diff --git a/decisions.md b/decisions.md index 848bdbd..73ae391 100644 --- a/decisions.md +++ b/decisions.md @@ -1927,3 +1927,22 @@ bomb dropped between you and an enemy coming down a corridor now waits until the now stays up until a hit lands, however many levels later (`shieldUp`, carried with the run). A **Decoy** lasts until the level ends, or until it takes a single hit: an enemy reaching its tile, or flame. `SIM_VERSION` 31. + +### 2026-10-25 — The demo's title on top; a compact menu + +Built from the queued UI items (user request). **The demo** reads top to bottom: the title +EXPLODORAMA! in its own bar, the TV (top bar included, moved down under the title), then the +caption; title and caption go when the game starts. **The menu**: Resume and New (was "New run") +side by side, then Almanac, Scores, Demo and Bug as one row of small word buttons (thinner edges so +ALMANAC fits a quarter row on a phone). Words rather than icons: they say what they do at a glance, +and the Kenney icons for an almanac or a bug report would need explaining. + +### 2026-10-25 — A demo scene for the charge swap + +User question: "is there already a demo written for the charge swap?" There wasn't: the demo +skipped every turn that would ask, since its finger only played the board and the action bar. Now a +`chargeSwap` scene starts carrying a Pierce with a Nuke lying near; when the pickup asks, the real +dialog opens over the scene (not dimming the caption, as "What is that?" does) and the finger taps +**Take Nuke**. A new gesture, `choice`, plays it (`swapCharge` / `sellCharge`); in a dialog's clip, +which has no dialog, the choice just happens. Only this scene keeps turns that ask. No separate +charge-button scene: each charge's scene already shows the finger pressing it. diff --git a/docs/screenshots/auto-move.png b/docs/screenshots/auto-move.png index 244f173..79b2b8b 100644 Binary files a/docs/screenshots/auto-move.png and b/docs/screenshots/auto-move.png differ diff --git a/docs/screenshots/bomb-picked.png b/docs/screenshots/bomb-picked.png index 806960e..9cef059 100644 Binary files a/docs/screenshots/bomb-picked.png and b/docs/screenshots/bomb-picked.png differ diff --git a/docs/screenshots/countdown.png b/docs/screenshots/countdown.png index bba52cd..72b5273 100644 Binary files a/docs/screenshots/countdown.png and b/docs/screenshots/countdown.png differ diff --git a/docs/screenshots/death.png b/docs/screenshots/death.png index dc89596..7a16827 100644 Binary files a/docs/screenshots/death.png and b/docs/screenshots/death.png differ diff --git a/docs/screenshots/demo.png b/docs/screenshots/demo.png index c308df8..a8593ca 100644 Binary files a/docs/screenshots/demo.png and b/docs/screenshots/demo.png differ diff --git a/docs/screenshots/desktop.png b/docs/screenshots/desktop.png index 5bfc224..b2c2651 100644 Binary files a/docs/screenshots/desktop.png and b/docs/screenshots/desktop.png differ diff --git a/docs/screenshots/dev-offer.png b/docs/screenshots/dev-offer.png index ba5889a..cf1f785 100644 Binary files a/docs/screenshots/dev-offer.png and b/docs/screenshots/dev-offer.png differ diff --git a/docs/screenshots/dev.png b/docs/screenshots/dev.png index 981408f..b9f07b5 100644 Binary files a/docs/screenshots/dev.png and b/docs/screenshots/dev.png differ diff --git a/docs/screenshots/enemies.png b/docs/screenshots/enemies.png index dcbb78f..42aa232 100644 Binary files a/docs/screenshots/enemies.png and b/docs/screenshots/enemies.png differ diff --git a/docs/screenshots/gem-offer.png b/docs/screenshots/gem-offer.png index 1fc80ca..fbc147b 100644 Binary files a/docs/screenshots/gem-offer.png and b/docs/screenshots/gem-offer.png differ diff --git a/docs/screenshots/gem.png b/docs/screenshots/gem.png index 47f01c8..6ba8dfd 100644 Binary files a/docs/screenshots/gem.png and b/docs/screenshots/gem.png differ diff --git a/docs/screenshots/info-hud.png b/docs/screenshots/info-hud.png index 5ac94d9..ec0e177 100644 Binary files a/docs/screenshots/info-hud.png and b/docs/screenshots/info-hud.png differ diff --git a/docs/screenshots/info-mode.png b/docs/screenshots/info-mode.png index 31496e6..5fa541b 100644 Binary files a/docs/screenshots/info-mode.png and b/docs/screenshots/info-mode.png differ diff --git a/docs/screenshots/lingering.png b/docs/screenshots/lingering.png index a333d5e..4db39f7 100644 Binary files a/docs/screenshots/lingering.png and b/docs/screenshots/lingering.png differ diff --git a/docs/screenshots/long-press.png b/docs/screenshots/long-press.png index 5a4e2d2..219852e 100644 Binary files a/docs/screenshots/long-press.png and b/docs/screenshots/long-press.png differ diff --git a/docs/screenshots/menu.png b/docs/screenshots/menu.png index 3c96343..48ab68d 100644 Binary files a/docs/screenshots/menu.png and b/docs/screenshots/menu.png differ diff --git a/docs/screenshots/move-picked.png b/docs/screenshots/move-picked.png index 1dc95e3..133f250 100644 Binary files a/docs/screenshots/move-picked.png and b/docs/screenshots/move-picked.png differ diff --git a/docs/screenshots/shop.png b/docs/screenshots/shop.png index 02c6d97..900a84a 100644 Binary files a/docs/screenshots/shop.png and b/docs/screenshots/shop.png differ diff --git a/index.html b/index.html index b76c485..163a43b 100644 --- a/index.html +++ b/index.html @@ -55,8 +55,8 @@ <div id="dialog-buttons"></div> </div> </dialog> +<h1 id="demo-title" hidden>EXPLODORAMA!</h1> <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> diff --git a/src/main.ts b/src/main.ts index 7732e89..d31335c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -436,7 +436,8 @@ function commitLine(): string { * 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. + * top bar say what they do. Laid out compact (user request, 2026-10-25: "still very packed"): + * Resume and New side by side, then one row of single words — Almanac, Scores, Demo, Bug. */ function showMenu(): void { const where = state.status === 'playing' ? `Level ${state.level}, turn ${state.turn}. ` : '' @@ -454,12 +455,13 @@ function showMenu(): void { `<a href="${REPO_URL}" target="_blank" rel="noopener">Source code</a></p>`, buttons: [ { label: 'Resume', kind: 'primary', icon: playIcon }, + { label: 'New', kind: 'danger', icon: newRunIcon, onClick: newRunDialog }, { 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 }, + { label: 'Scores', kind: 'plain', onClick: openScores }, + { label: 'Demo', kind: 'plain', onClick: startDemo }, + { label: 'Bug', kind: 'plain', onClick: reportBug }, ], + className: 'menu', }) // The switches work at once, without a button (user request, 2026-10-24: sounds and vibration). for (const input of document.querySelectorAll<HTMLInputElement>('#dialog-body .feedback input')) { @@ -758,6 +760,8 @@ function saveDemoIndex(i: number): void { } } const demoPanel = document.getElementById('demo')! +/** The demo's title, on top above the TV (user request, 2026-10-25: title, TV, caption). */ +const demoTitle = document.getElementById('demo-title')! 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)) @@ -844,6 +848,7 @@ const SCENE_ICONS: Record<SceneKind, string> = { run: POWER_UP_ICONS.run, kick: POWER_UP_ICONS.kick, throw: POWER_UP_ICONS.throw, teleport: POWER_UP_ICONS.teleport, diagonal: POWER_UP_ICONS.diagonal, detonate: POWER_UP_ICONS.detonator, wallPass: POWER_UP_ICONS.wallPass, bombPass: POWER_UP_ICONS.bombPass, flamePass: POWER_UP_ICONS.flamePass, star: POWER_UP_ICONS.star, + chargeSwap: CHARGE_ICONS.nuke, pierce: CHARGE_ICONS.pierce, power: CHARGE_ICONS.power, nuke: CHARGE_ICONS.nuke, shield: CHARGE_ICONS.shield, freeze: CHARGE_ICONS.freeze, decoy: CHARGE_ICONS.decoy, exit: 'door_open_top', doorBlast: 'door_closed_top', lastEnemy: 'door_open_top', crate: 'block_planks', pillar: 'block_blue', @@ -988,6 +993,7 @@ function startDemo(): void { prepareScene(at) document.body.classList.add('demo') demoPanel.hidden = false + demoTitle.hidden = false finger.hidden = false finger.classList.add('away') youTag.hidden = false @@ -1042,6 +1048,16 @@ function beatsFor(g: Gesture, action: Action, last: boolean): Beat[] { // A far tile: auto-move's way shows, and its first move is made. return [...onTile(g.tile, () => ((destination = g.tile), afterChange())), play] } + if (g.kind === 'choice') { + // The charge picked up asks, as in play: the dialog opens over the scene (not dimming the + // caption), and the finger taps Take (swap) or Keep. + return [ + { run: () => openChargeOffer(state, () => {}), then: INFO_SECONDS }, + { run: () => placeFinger(centreOf(document.querySelector(`#dialog-buttons button:${g.swap ? 'last' : 'first'}-child`)!)), then: FINGER_MOVE_SECONDS }, + { run: fingerDown, then: TAP_SECONDS }, + { ...play, run: () => (closeDialog(), play.run()) }, + ] + } const button = g.kind === 'go' ? 'move' : g.button const el = actionBar.querySelector<HTMLElement>(`[data-action=${button}]`)! const pressButton: Beat[] = [ @@ -1144,6 +1160,7 @@ function endDemo(): void { demo = null document.body.classList.remove('demo') demoPanel.hidden = true + demoTitle.hidden = true finger.hidden = true youTag.hidden = true if (dialogOpen()) closeDialog() @@ -1297,7 +1314,9 @@ await engineInit( // and the TV's sides: at most 4:3 wide, black bars beside it on a wide screen (user request, // 2026-10-24: it looked very wide on a desktop). document.body.style.setProperty('--demo-panel', `${panel}px`) - const tv = Math.min(innerWidth, (innerHeight - panel) * TV_ASPECT) + const title = demo ? demoTitle.getBoundingClientRect().height : 0 + document.body.style.setProperty('--demo-title', `${title}px`) + const tv = Math.min(innerWidth, (innerHeight - panel - title) * TV_ASPECT) document.body.style.setProperty('--tv-left', `${(innerWidth - tv) / 2}px`) placeDemoMarks() if (demo) vacuum() diff --git a/src/sim/demo.ts b/src/sim/demo.ts index b5349f8..3018129 100644 --- a/src/sim/demo.ts +++ b/src/sim/demo.ts @@ -39,7 +39,7 @@ export type SceneKind = | 'bombPlus' | 'firePlus' | 'fusePlus' | 'run' | 'kick' | 'throw' | 'teleport' | 'diagonal' | 'detonate' | 'rpg' | 'proximity' | 'slide' | 'icbm' | 'wallPass' | 'bombPass' | 'flamePass' | 'star' - | 'pierce' | 'power' | 'nuke' | 'shield' | 'freeze' | 'decoy' + | 'pierce' | 'power' | 'nuke' | 'shield' | 'freeze' | 'decoy' | 'chargeSwap' | 'exit' | 'doorBlast' | 'lastEnemy' | 'crate' | 'pillar' | 'rainbowGem' | 'immortal' | 'dieIntoBlast' | 'dieNotFar' | 'dieBlockedIn' | 'dieChain' | 'dieEnemy' | 'dieDetonator' | 'dieRun' @@ -59,6 +59,8 @@ export type Gesture = | { kind: 'walk'; tile: Vec } | { kind: 'button'; button: ButtonId; tile?: Vec } | { kind: 'go' } + /** A charge picked up while carrying another: a tap on the dialog's Take (swap) or Keep button. */ + | { kind: 'choice'; swap: boolean } /** The action bar's buttons (ui/controls.ts). */ export type ButtonId = 'move' | 'bomb' | 'charged' | 'wait' @@ -116,6 +118,8 @@ interface SceneDef { steer?: (s: GameState, rng: Rng, walking: Walking) => Play | null /** Bombs placed with the Bomb button and a tap, not a long press. */ bombButton?: boolean + /** A scene about the sell-or-swap choice: turns that ask it are kept (elsewhere they are skipped). */ + choice?: boolean } const WIDTH = 9 @@ -388,6 +392,16 @@ export const SCENES: Record<SceneKind, SceneDef> = { prepare: dropping('decoy'), shows: (t, seen) => seen.picked.has('decoy') && kills(t).length > 0 && !!t.prev.decoy, }, + // One charge at a time (user question, 2026-10-25: "is there already a demo for the charge + // swap?"): carrying a Pierce, the player picks up a Nuke, and the finger takes it. + chargeSwap: { + caption: 'You carry one charge. Picking up another asks: keep yours, or take the new one. The other is sold for coins.', + enemies: ['blob'], + prepare: (s, rng) => ((s.charge = { kind: 'pierce', left: 3 }), dropNear(s, rng, 'nuke', 2)), + choice: true, + steer: (s) => (s.chargeOffer ? { action: { id: 'swapCharge' }, gesture: { kind: 'choice', swap: true } } : null), + shows: (t) => t.action.id === 'swapCharge', + }, // The exit (user request, 2026-10-23: "demos end naturally when the player walks into an open door"). exit: { caption: 'A crate hides the exit. Once every enemy is gone it opens: walk in to finish the level.', @@ -708,8 +722,8 @@ function rollout(kind: SceneKind, start: GameState, rng: Rng): GameTurn[] | null const play = def.steer?.(s, rng, walking) ?? playOf(def, s, randomAction(s, rng)) if (!play) continue const r = step(s, play.action) - // A second charge picked up would ask to sell or swap, which no gesture plays: not in a scene. - if (r.state.chargeOffer) continue + // A second charge picked up asks to sell or swap: only in the scene about that. + if (r.state.chargeOffer && !def.choice) continue 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 || reasonable(kind, t))) || (def.dies && def.shows(t, seen))) turn = t diff --git a/src/style.css b/src/style.css index e4e73c9..73ee73a 100644 --- a/src/style.css +++ b/src/style.css @@ -885,10 +885,33 @@ body.demo #banner { /* The top bar is on the TV too, as wide as it (main.ts). */ body.demo #hud { + top: var(--demo-title, 0px); left: var(--tv-left, 0px); right: var(--tv-left, 0px); } +/* The demo's title, on top, above the TV (user request, 2026-10-25): title, TV, caption. In the + rounded system font, extra bold: Kenney Future's blocky letters (an X like an H) were hard to + read this big (user request, 2026-10-23). */ +#demo-title { + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 10; + margin: 0; + padding: calc(env(safe-area-inset-top) + 8px) 16px 6px; + background: var(--ink); + color: #fff; + text-align: center; + font: 800 30px/1.15 ui-rounded, 'SF Pro Rounded', system-ui, -apple-system, sans-serif; + letter-spacing: 0.01em; +} + +#demo-title[hidden] { + display: none; +} + /* The top bar's tour in the demo (main.ts): the item it is about, ringed. */ .demo-spot { border-radius: 10px; @@ -913,14 +936,6 @@ body.demo #hud { display: none; } -/* The title in the rounded system font, extra bold: Kenney Future's blocky letters (an X like an - H) were hard to read this big (user request, 2026-10-23). */ -#demo h1 { - margin: 0 0 6px; - font: 800 30px/1.15 ui-rounded, 'SF Pro Rounded', system-ui, -apple-system, sans-serif; - letter-spacing: 0.01em; -} - /* The words, with a scene's icon beside them (not in the line, which would space it unevenly). */ #demo-caption { display: flex; @@ -1246,7 +1261,7 @@ body.demo #actions { and the bezel round it; a slight flicker. */ #demo-crt { position: fixed; - inset: 0 var(--tv-left, 0px) var(--demo-panel, 0px); + inset: var(--demo-title, 0px) var(--tv-left, 0px) var(--demo-panel, 0px); z-index: 8; overflow: hidden; /* Black bars either side of a TV narrower than the screen (main.ts: at most 4:3). */ @@ -1463,6 +1478,30 @@ canvas.clip[hidden] { cursor: default; } +/* The menu (user request, 2026-10-25: it was "very packed"): Resume and New side by side, then + Almanac, Scores, Demo and Bug in one row of small buttons. */ +#dialog .panel.menu #dialog-buttons { + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +#dialog .panel.menu .ui-button { + grid-column: span 2; + box-sizing: border-box; + min-width: 0; + min-height: 46px; + white-space: nowrap; +} + +#dialog .panel.menu .ui-button.plain { + grid-column: span 1; + min-height: 40px; + padding: 0; + /* Thinner edges than a full-size button, so a word as long as ALMANAC fits a quarter row. */ + border-width: 8px 6px 12px; + border-image-width: 8px 6px 12px; + font-size: 10px; +} + /* The shop's buttons: Restock and Next level side by side, one short row. The selected item's own Buy button (smaller; only on the selected card), and a smaller clip, so the items have room. */ #dialog .panel.shop #dialog-buttons { diff --git a/src/ui/clip.ts b/src/ui/clip.ts index e95d771..b271e83 100644 --- a/src/ui/clip.ts +++ b/src/ui/clip.ts @@ -184,7 +184,8 @@ function play(c: Clip, action: Action): void { */ function gestureBeats(c: Clip, g: Gesture, action: Action, last: boolean): Clip['beats'] { const s = c.state! - const tile = g.kind === 'go' ? null : g.kind === 'button' ? (g.tile ?? s.player) : g.tile + // A choice in a dialog (the charge swap) has no tile on the board: the clip just plays it. + const tile = g.kind === 'go' || g.kind === 'choice' ? null : g.kind === 'button' ? (g.tile ?? s.player) : g.tile if (!tile) return [{ run: () => play(c, action), then: last ? HOLD_SECONDS : STEP_SECONDS }] const finger = c.finger! const hold = g.kind === 'hold' diff --git a/src/ui/controls.ts b/src/ui/controls.ts index c197a8d..a1e1862 100644 --- a/src/ui/controls.ts +++ b/src/ui/controls.ts @@ -280,7 +280,8 @@ export function createControls(handlers: Handlers) { }, insets() { return { - top: document.getElementById('hud')!.getBoundingClientRect().height, + // The bar's bottom edge: it sits lower in the demo, under the title. + top: document.getElementById('hud')!.getBoundingClientRect().bottom, bottom: bar.getBoundingClientRect().height, } }, diff --git a/test/demo.test.ts b/test/demo.test.ts index 90cad64..90262df 100644 --- a/test/demo.test.ts +++ b/test/demo.test.ts @@ -22,6 +22,7 @@ function controlsTake(s: GameState, g: Gesture, destination: Vec | null): Action if (g.kind === 'hold') return isValidTarget(s, 'bomb', g.tile) ? { id: 'bomb', target: g.tile } : null if (g.kind === 'walk') return tapMeaning(s, g.tile) === 'walk' ? autoMoveAction(s, g.tile, safe) : null if (g.kind === 'go') return destination && autoMoveAction(s, destination, safe) + if (g.kind === 'choice') return s.chargeOffer ? { id: g.swap ? 'swapCharge' : 'sellCharge' } : null if (g.button === 'wait') return { id: 'wait' } // The charge button alone: a Freeze or a Shield, used at once (main.ts `pick`). if (g.button === 'charged' && !g.tile) return { id: s.charge?.kind === 'shield' ? 'shield' : 'freeze' }