explodorama / commit
Release 0.2.0: controller and keyboard play, demos for every input, autopilot, panel screens, Changes button
CHANGELOG.md | 3 + README.md | 9 +- decisions.md | 3 + index.html | 14 ++- package-lock.json | 4 +- package.json | 2 +- src/changelog.ts | 318 ++++++++++++++++++++----------------------------- src/main.ts | 81 +++++++++++-- src/style.css | 117 +++++++++++------- test/changelog.test.ts | 5 + 10 files changed, 298 insertions(+), 258 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8874d51..93614ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,8 +13,11 @@ saved before the change is dropped and the next visit starts a new run. ## [Unreleased] +## [0.2.0] - 2026-11-02 + ### Added +- **End-of-level screens are panels; demo controls on a controller** (2026-11-02): the level-cleared, "Ouch!" and game-over/victory banner (`#banner`) is now a parchment `.panel` with the dialog's `.ui-button` buttons and backdrop (HTML wrapper in `index.html`, CSS in `style.css`; the ids and the pad glue in `padDialog.ts` are unchanged). In the demo the d-pad up = shuffle, down = first scene, R1 = next input, with pictures on the buttons (`syncDemoGlyphs` in `main.ts`). Menu: a Changes button. - **Faded blue for auto-move's reach; the way previewed; faster d-pad repeat** (Q69, 2026-11-02): with the pad's or keyboard's cursor, every tile auto-move can walk to (`farTiles` in `ui/padTiles.ts`: `tapMeaning` says 'walk', `autoMoveAction` finds a safe, not deadly first move; minus the bright tiles and your own) is tinted a fainter blue (`C.targetFar`, `View.faded`) and is a cursor candidate (`cursorCandidates`, `FarTile`). Move/Go there (M or Enter on the keyboard) does what a tap does (`padDo` taps the tile with nothing picked), label "Walk here · N steps" (`farActions`; "Keep walking" when it is the set destination, which is then the only faded tile). Computed once per state in `currentFar` (`main.ts`); `afterChange` now works out danger and the destination before `refreshCursor`. **Preview:** with the cursor on a faded tile, or the mouse (not touch) hovering over one with nothing picked, the board draws auto-move's way as the real one is drawn (landing tiles, arrows, first one red when unsafe) without setting it or stepping (`routePreview` in `ui/routePreview.ts`, `View.preview`, `previewNow` in `main.ts` caches per tile, state and danger; `farTileAt` tests one tile). D-pad repeat: `REPEAT_DELAY_MS` 300 to 250, `REPEAT_MS` 110 to 75 (`ui/padMap.ts`; menus and dialogs share it). Tapping and clicking are unchanged. Tests: `test/farTiles.test.ts`, `test/routePreview.test.ts`; the repeat tests in `test/padMap.test.ts` now use the constants. - **The demo for the keyboard** (Q65 part B, user request, 2026-11-02): **Input: keyboard** is the input button's third entry (touch, mouse, keyboard, then the controllers; `ui/demoInput.ts`, `isKeysInput`). The scenes are the controller demo's shape (the touch scenes without the touch-only ones and the charge swap; own saved place `demoAtKeys`), acted out with keys on a new **key overlay** (`ui/keyOverlay.ts`: Kenney Keyboard & Mouse glyphs, 15 files copied into `assets/kenney-input-prompts/`; arrows, Alt, Shift, M B V, Space, ? Home Enter Esc; white at rest, lit for 0.7 s) and the dashed cursor when Shift is used. `ui/keyDemo.ts` (pure, tested) turns each turn into the fewest key presses (a plain arrow, an action key then an arrow, the key twice for Freeze, or Shift + arrows then the key) and verifies each by replaying it through the game's rules. New scenes: the Shift cursor, `?` + arrow + Enter, Alt + arrows with Home, Esc for the menu; keyboard captions (`SceneDef.keyCaption`, `keyCaptionHtml`); hint "Press Enter or Esc to play". Clips are unchanged. - **A keyboard cursor, and the keyboard fully playable** (Q65 part A, user request, 2026-11-02): Shift + arrows (or Shift + W A S D) wake the controller's tile-first cursor for the keyboard (`ui/keyCursor.ts`, pure, tested; `inputMode` 'pad' | 'keys' | null in `main.ts` replaces `padActive` as what shows the cursor and aim); M B V R K T P F I L X Space act on its tile, Enter does the first action or confirms a picked one, Esc backs out stepwise; its label and hint show key caps. Plain arrows keep "nearest valid tile that way". `?` now aims over the explainable tiles (arrows, Enter or `?` explains), and no longer lets an arrow move you; Alt + arrows pan, Home recentres. Fixed: Enter on the banner pressed the first button whatever had the focus, and Space waited when a button had the focus; now Enter and Space press the focused button instead of acting in the game (`targetTakesKey`; a mouse or touch click does not leave the focus on a board button); a dialog opened on its panel now answers Enter with its green (or only) button, and closing one blurs the button the browser hands the focus back to. README *Keyboard* section is new. diff --git a/README.md b/README.md index 466954d..f9bd946 100644 --- a/README.md +++ b/README.md @@ -12,10 +12,10 @@ seven charges (one carried at a time, used when you choose), a hidden gem on eve mid-run work. Curses and the Reaper enemy are planned — see [PLAN.md](PLAN.md). -Version: **0.1.0** (semver; the base release). The menu's bottom line shows it with the rules +Version: **0.2.0** (semver; 0.1.0 was the base release). The menu's bottom line shows it with the rules version and the commit the game was built from (a link to it on GitLab, plus "plus local changes" in a build with uncommitted changes) and a link to the -[source code](https://code.fuzzyworld.net/explodorama/). Tapping the version opens the list of +[source code](https://code.fuzzyworld.net/explodorama/). Tapping the version, or the menu's Changes button, opens the list of changes in every release, each with its date and, for most, a demo scene. A player who comes back on a newer version is shown what came after the one they last played (kept in this browser's localStorage) when the opening demo ends; a first visit, and a test URL, show nothing. Releases are git tags, made when Pavel decides a batch is ready; between @@ -110,6 +110,7 @@ last line reads "Hold (the button's picture, A on a Nintendo pad) to play" inste **Input: controller: ps5**, **xbox**, **switch** and **generic** (the pad's scenes and virtual controller with that kind's button pictures, and "Hold (the button) to play"). It overrides what was detected, for this demo only (nothing is kept): it stays until the demo ends or a controller is plugged in or unplugged (no click, tap or button press drops it; only holding the confirm button, which ends the demo, leaves it). The scene starts again for the new input. The dialogs' clips follow the real input, not the pick. +**The demo's own controls on a controller** (shown as the button's picture on each control while the demo is drawn for a controller): d-pad left / right = the scene before / after, up = shuffle on / off, down = back to the first scene, R1 (RB, R) or View = the next input in the list above. Hold the confirm button to play. **With the keyboard** (pick **Input: keyboard**; the entry cycles after mouse) the demo shows a **key overlay** under the caption instead of the finger: Kenney's key pictures for the arrows, Alt, Shift, **M B V**, **Space**, **?**, Home, Enter and Esc, white at rest and lit for 0.7 s when pressed. Each turn is played with the fewest keys the game accepts: a plain arrow for a step, **B** (or M, V) then an arrow @@ -241,10 +242,10 @@ 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), **Fullscreen** (on or off now; where the browser has fullscreen) (the game never goes fullscreen by itself; F11 or Esc leaves it; iPhone Safari has no fullscreen for pages) and **Autopilot**, 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 +since the current run can't be got back), then two rows of small ones — **Almanac** (every power-up, charge and enemy type, and everything on the board (crates, pillars, bushes, metal crates, oil, spikes, oil barrels, rubble, with the level each first turns up on), with its picture -and what it does, in four tabs, and in a Developer run a fifth, **Tests**: fixed edge-case situations such as a slide over two gems, each with what to expect; the same scenes join the landing demo with `?dev`; an entry with a green ▶ opens its clip; **Demo controller** is a sample of the demo's virtual controller, each tap pressing its next part on an Xbox, PlayStation and Switch pad in turn), **Scores** (the high scores), **Demo** (the demo again) and **Bug** (an +and what it does, in four tabs, and in a Developer run a fifth, **Tests**: fixed edge-case situations such as a slide over two gems, each with what to expect; the same scenes join the landing demo with `?dev`; an entry with a green ▶ opens its clip; **Demo controller** is a sample of the demo's virtual controller, each tap pressing its next part on an Xbox, PlayStation and Switch pad in turn), **Scores** (the high scores), **Changes** (the list of changes in every release), **Demo** (the demo again) and **Bug** (an email to the game's bug-report address, which needs no 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). diff --git a/decisions.md b/decisions.md index e1b5c4c..4c927e8 100644 --- a/decisions.md +++ b/decisions.md @@ -2952,3 +2952,6 @@ the mouse merely moves over a pad-driven board; a click on the board is the way - **Q88 detail 2**: the single press covers everything under an inspection action: dialogs opened from the info dialogs inherit `inspectDialog` until no dialog is left open. - **Q89**: autopilot step 0.25 s -> 0.4 s (bug report: too fast). Touch got a Cancel button (a picked action or auto-move destination had no way out but Esc/L1): round, over the bar's top right, absolute so the bar's height (the board's inset) is unchanged; hidden for the pad, the demos and while inspecting. The "demo became really tall" screenshot (touch, scene 5/57, the board drawn as one thin column, sprites squeezed, no bar) could not be reproduced yet (open question to Pavel). - **Q90**: bug report (seed 395025326, level 5): 'Couldn't move'. Replay: `offer` (5 gems) and `chargeOffer` (Acid) both pending after a slide; `isAvailable` refuses all actions while either is set, and no dialog was showing. Cause of the missing dialog unknown (offer dialog is `required`); suspects: a dialog opened over it (menu / bug report) replacing it, or a timing race between the pickup timers. Fix is a safety net: `reopenPendingChoice` reopens after 1.5 s without a dialog. +- **Menu Changes button (2026-11-02, Pavel: the version link was easy to miss)**: a fifth small button, Changes, opens the same list as the version link (which stays). The small buttons now sit on a six-column grid: Almanac, Scores, Changes (two columns each) in one row, Demo and Bug (three each) under them, because five in one row do not fit a phone. +- **Demo controls on a controller (2026-11-02, Pavel: shuffle/restart had no button)**: in the demo the d-pad up = shuffle, down = back to the first scene (left/right were already scene before/after), R1 or View = cycle the Input button; the same 400 ms throttle as left/right so a held d-pad does not flip shuffle repeatedly. Each demo control shows its pad picture on its corner while the demo is drawn for a controller (`syncDemoGlyphs`, Q67 rule). Tried only with a mocked pad in a browser: no unit test (the logic is in main.ts). +- **The banner is a panel (2026-11-02, Pavel: the level-cleared screen looked unfinished, 'should it be a dialog?')**: restyled, not migrated. `#banner` keeps its own elements and the pad code in `padDialog.ts` (a `<dialog>` would mean moving the status-driven refresh, the replay clip and the hold ring); it now has a `.panel` child with the dialog's parchment 9-slice, the dialog's backdrop and `.ui-button` buttons (primary green, the others plain). Side effect: the old `button.secondary` / `button.link` styles are gone. Checked in a browser at phone and desktop width with a mocked pad (the glyphs sit on the three buttons). diff --git a/index.html b/index.html index c51263e..7b45881 100644 --- a/index.html +++ b/index.html @@ -40,12 +40,14 @@ </header> <nav id="actions" aria-label="Actions"></nav> <div id="banner" hidden> - <img id="banner-icon" alt="" /> - <canvas id="banner-clip" class="clip" aria-hidden="true" hidden></canvas> - <p id="banner-text"></p> - <button id="banner-button" type="button"></button> - <button id="banner-button-2" type="button" class="secondary" hidden></button> - <button id="banner-scores" type="button" class="link" hidden>High scores</button> + <div class="panel"> + <img id="banner-icon" alt="" /> + <canvas id="banner-clip" class="clip" aria-hidden="true" hidden></canvas> + <p id="banner-text"></p> + <button id="banner-button" type="button" class="ui-button"></button> + <button id="banner-button-2" type="button" class="ui-button plain" hidden></button> + <button id="banner-scores" type="button" class="ui-button plain" hidden>High scores</button> + </div> </div> <dialog id="dialog" aria-labelledby="dialog-title"> <div class="panel" tabindex="-1"> diff --git a/package-lock.json b/package-lock.json index d9323c7..28f7a49 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "explodorama", - "version": "0.1.0", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "explodorama", - "version": "0.1.0", + "version": "0.2.0", "license": "MIT", "dependencies": { "littlejsengine": "1.28.0" diff --git a/package.json b/package.json index f1234a5..9902b37 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "explodorama", "private": true, - "version": "0.1.0", + "version": "0.2.0", "type": "module", "description": "A turn-based, mobile-first, rogue-like take on the 1985 NES bomb-laying classic.", "scripts": { diff --git a/src/changelog.ts b/src/changelog.ts index c0b5129..3387fa7 100644 --- a/src/changelog.ts +++ b/src/changelog.ts @@ -38,6 +38,135 @@ export interface Release { /** Newest first. */ export const RELEASES: Release[] = [ + { + version: '0.2.0', + date: '2026-11-02', + summary: 'Play with a controller or just the keyboard, with demos for every kind of input; autopilot, oil, and many fixes.', + changes: [ + { + kind: 'added', + icon: 'star', + title: 'Play with a controller', + text: [ + 'A gamepad now plays a whole run: the board, the menu, power-up choices, the shop, the almanac and the screens after a death or a level. A square rests on your tile; the d-pad hops it between the tiles you can act on, and the face buttons act there. Hold a button until its ring fills to confirm. Every button on screen shows its controller picture, and "What is that?" works with R1.', + 'The demo has controller scenes with a virtual controller instead of a finger, and its own buttons work with the d-pad and R1.', + ], + date: '2026-11-02', + }, + { + kind: 'added', + icon: 'star', + title: 'Play with just the keyboard', + text: [ + 'Hold Shift and press an arrow (or W A S D) to show a cursor on the board, hop it between the blue tiles, and act with M, B, V or Enter. Press ? to ask what something is. Alt and arrows move the view, and Home brings it back to you.', + 'With a controller or the keyboard, a faded blue marks every far tile auto-move can walk to, with the way shown before you confirm; pointing at one with a mouse shows it too.', + ], + date: '2026-11-02', + }, + { + kind: 'added', + icon: 'star', + title: 'Demos for every kind of input', + text: ['A button under the demo switches what it is shown for: touch, mouse, keyboard, or a PlayStation, Xbox, Switch or generic controller. A shuffle button plays the scenes in a random order, every scene has a bar showing how much is left, and only a click, tap, Enter or Esc (or holding the confirm button on a controller) ends the demo.'], + date: '2026-11-02', + }, + { + kind: 'added', + icon: 'coin_gold', + title: 'Autopilot', + text: [ + 'When the last enemy is gone, the character walks on its own to every coin, charge and gem still lying about, then to the exit, along a safe way. Gems and charges still ask what you want, and every move is a real turn, so gathering can lose the par bonus.', + 'Stop it with Esc, L1 on a controller, or the Stop button on the note at the top. To do it all yourself, turn off "Autopilot" under Settings in the menu.', + ], + date: '2026-11-02', + }, + { + kind: 'added', + icon: 'star', + title: 'A fullscreen button', + text: ['The game never goes fullscreen by itself. Use the new button next to the menu button, or F11; Esc or F11 leaves it.'], + date: '2026-11-02', + }, + { + kind: 'changed', + icon: 'star', + title: 'Smoother play', + text: [ + 'Turns now count from 1. A round Cancel button puts away a picked action or a walk you set. The view stays where you put it when you drag the board (reported by Pavel). A gem or charge choice whose window got lost opens again. The screens between levels and after a death are panels like the menu.', + ], + date: '2026-11-02', + }, + { + kind: 'added', + icon: 'bomb_active', + title: 'Try the next version early', + text: ['The menu opens with a box saying whether you are playing the official or the early adopter version, with a button to switch. The early copy is built from the newest work, with its own saves and scores; runs finished there are marked "Early adopter".'], + date: '2026-11-01', + }, + { + kind: 'changed', + icon: 'flag_green_a', + title: 'Wall Pass is now Crate Pass', + text: ['The power-up lets you walk through crates, not walls, so it has a name that says so. It works exactly as before.'], + clip: 'wallPass', + date: '2026-11-01', + }, + { + kind: 'added', + icon: 'bomb', + title: 'A note when you pick up a charge', + text: ['The first charge of each kind in a run still explains itself. After that, picking one up shows a small note with its name, how many you got and how many you hold.'], + date: '2026-11-01', + }, + { + kind: 'changed', + icon: 'gem_green', + title: 'Two gems in one move', + text: ['A slide, a run or an oil slide that takes two gems now asks for both choices, one after the other. The second used to be lost.'], + date: '2026-10-31', + }, + { + kind: 'changed', + icon: 'oil', + title: 'Oil is slippery', + text: [ + 'Step onto oil and you slide straight on over the spill, stopping on its last tile in that line (or short of a bomb or an enemy). You pick up what lies on the way, and flame where you end up burns you.', + 'Blobs, Seekers, Drifters, Sprinters and Hunters slip too, and their arrows show where the slide ends. Ghosts and Wisps fly over it.', + ], + clip: 'oilSlide', + date: '2026-10-31', + }, + { + kind: 'changed', + icon: 'bomb', + title: 'Kick or walk: you choose', + text: ['With both Kick and Bomb Pass, a tap (or arrow key) on a bomb next to you asks whether to kick it or walk onto it. With the Detonator as well, setting it off is a third choice.'], + date: '2026-10-31', + }, + { + kind: 'changed', + icon: 'block_metal', + title: 'A tap on a crate walks to it', + text: ['Tapping a crate (even from two tiles away, with Run) now walks you to the tile beside it.'], + date: '2026-10-31', + }, + { + kind: 'changed', + icon: 'charge_freeze', + title: 'Use a Freeze before swapping charges', + text: ['When you pick up a charge while carrying a Freeze, you can now use the Freeze first and then take the new charge, instead of losing one of them.'], + date: '2026-10-31', + }, + { + kind: 'added', + icon: 'rock', + title: 'A demo of rubble forming', + text: ['The Rubble entry in the Almanac now shows a cracked pillar being broken and the ceiling coming down.'], + clip: 'rubble', + date: '2026-10-31', + }, + ], + }, { version: '0.1.0', date: '2026-10-31', @@ -47,194 +176,7 @@ export const RELEASES: Release[] = [ ] /** Finished since the latest release, not yet part of one. Moved into RELEASES when one is tagged. */ -export const UNRELEASED: Change[] = [ - { - kind: 'changed', - icon: 'star', - title: 'Turns count from 1, and "What is that?" on a controller', - text: [ - 'The turn counter now starts at 1. With a controller or the keyboard, "What is that?" reaches pillars, walls and the top bar and can be aimed at yourself, stays on after each explanation, and takes a single press. Pressing Cmd, Shift or another modifier key no longer hands the board from the controller to the keyboard.', - ], - date: '2026-11-02', - }, - { - kind: 'changed', - icon: 'star', - title: 'A lost choice comes back', - text: [ - 'If a gem’s or charge’s choice was waiting but its window was gone (so nothing would move), the window now opens again by itself after a moment.', - ], - date: '2026-11-02', - }, - { - kind: 'changed', - icon: 'star', - title: 'Slower autopilot, and a Cancel button', - text: [ - 'The autopilot now walks two and a half steps a second instead of four. And while you have an action picked, or a walk set with a tap on a far tile, a round Cancel button appears just above the action buttons to put it away.', - ], - date: '2026-11-02', - }, - { - kind: 'changed', - icon: 'star', - title: 'Controller: the selected menu item shows what to hold', - text: [ - 'In the menu, the almanac and New run, the item the controller is on now shows the confirm button’s picture, and its ring fills while you hold it, so switches like Sound and Autopilot can be changed. Let go too early and a note reminds you to hold. The autopilot also waits for a bomb of yours to go off before it fetches what lies behind it.', - ], - date: '2026-11-02', - }, - { - kind: 'added', - icon: 'star', - title: 'Fast move by controller and keyboard', - text: [ - 'With a controller or the keyboard cursor, a faded blue now marks every far tile auto-move can walk you to. Hop onto one and press Move (M on the keyboard): the label says "Walk here" and how many steps, and you set off. The way is shown before you confirm, and with a mouse, pointing at a far tile shows it too. Holding the d-pad now hops faster.', - ], - date: '2026-11-02', - }, - { - kind: 'added', - icon: 'star', - title: 'Play with just the keyboard', - text: [ - 'Hold Shift and press an arrow (or W A S D) to show a cursor on the board. Shift and arrows hop it between the blue tiles; M, B and V, or Enter, act on the tile it is on, and Esc backs out. Plain arrows still take the nearest tile that way.', - 'Press ? to ask what something is, then use the arrows to aim and Enter to find out. Alt and arrows move the view, and Home brings it back to you. Enter and Space now press the button that has the focus instead of acting in the game, and Enter works in dialogs.', - ], - date: '2026-11-02', - }, - { - kind: 'added', - icon: 'star', - title: 'A fullscreen button', - text: [ - 'The game never goes fullscreen by itself. Use the new fullscreen button next to the menu button, or F11; Esc or F11 leaves it.', - 'The menu has a Fullscreen switch too; a controller press cannot flip it in a browser, so it reminds you of F11.', - ], - date: '2026-11-02', - }, - { - kind: 'added', - icon: 'star', - title: 'The demo for any input', - text: [ - 'A button under the demo’s scene buttons switches what the demo is shown for: touch, mouse, keyboard, or a PlayStation, Xbox, Switch or generic controller. The scene starts again with the matching finger, pointer, key overlay or controller. The keyboard demo shows the keys lighting up as each turn is played, and has its own scenes: the Shift cursor, ? for "What is that?", Alt + arrows to look around, and Esc for the menu. The choice lasts only for that demo.', - ], - date: '2026-11-02', - }, - { - kind: 'added', - icon: 'coin', - title: 'Autopilot', - text: [ - 'When the last enemy is gone, the character walks round on its own, two and a half moves a second, to every coin, charge and gem still lying about, then to the exit, along a safe way and the route with the fewest turns. A note at the top says what it is doing. Gems and charges still ask what you want, and the walk waits for your answer. Every move is a real turn, so gathering costs turns and can lose the par bonus.', - 'Stop it with Esc, L1 on a controller, or the Stop button on the note. The board takes no input while it walks. If you would rather do it all yourself, turn off "Autopilot" under Settings in the menu.', - ], - date: '2026-11-02', - }, - { - kind: 'added', - icon: 'star', - title: 'Play with a controller', - text: [ - 'A gamepad now plays a whole run: the board, the menu, power-up choices, the shop, the almanac and the screens after a death or a level. The buttons on screen show which controller button presses them.', - 'On the board, a square always rests on your own tile at the start of a turn (or on your destination while auto-move is walking you there, so you can keep going), the d-pad moves it between the tiles you can act on (they light up blue), a line says what each button would do there, and the four face buttons (Cross, Square, Triangle and Circle on PlayStation; A, X, Y and B on Xbox) do it: hold one until the ring round it fills, and it acts at once. Buttons that cannot be used are greyed, and ones that would cost a life are marked in red. LB takes the square back to you. On a Nintendo pad the buttons keep their letters’ jobs: A, on the right, is Move and confirms, and B, at the bottom, is Wait and goes back.', - 'In dialogs, Cross, Triangle and Square press the buttons (held until the ring fills, as on the board, and they act the moment it does), Circle closes (B on a Nintendo pad), the d-pad moves a highlight over the choices, LB and RB switch the almanac’s tabs, and the right stick scrolls.', - 'The demo and the clips in dialogs show the controller’s buttons lighting up, and a square moving over the board, instead of a finger. To start playing from the demo, hold the confirm button (Cross, A; on a Nintendo pad, A on the right) until its ring fills; play starts then.', - ], - date: '2026-11-01', - }, - { - kind: 'changed', - icon: 'star', - title: 'Wall Pass is now Crate Pass', - text: ['The power-up lets you walk through crates, not walls, so it has a name that says so. It works exactly as before.'], - clip: 'wallPass', - date: '2026-11-01', - }, - { - kind: 'changed', - icon: 'star', - title: 'The view stays where you put it', - text: ['If you drag the board to look around and then play a turn, the view no longer jumps back to your character. It stays where you left it, and goes back to following you only when you would walk out of it, or on a new level. Reported by Pavel.'], - date: '2026-11-01', - }, - { - kind: 'added', - icon: 'star', - title: 'Which version is this?', - text: ['The menu now opens with a box saying whether you are playing the official or the early adopter version, with a button to switch to the other.'], - date: '2026-11-01', - }, - { - kind: 'added', - icon: 'bomb', - title: 'A note when you pick up a charge', - text: ['The first charge of each kind in a run still explains itself. After that, picking one up shows a small note with its picture, name, how many you got and how many you hold, since the icons in the top bar are small.'], - date: '2026-11-01', - }, - { - kind: 'changed', - icon: 'gem_green', - title: 'Two gems in one move', - text: ['A slide, a run or an oil slide that takes two gems now asks for both choices, one after the other. The second used to be lost.'], - date: '2026-10-31', - }, - { - kind: 'changed', - icon: 'oil', - title: 'Oil is slippery', - text: [ - 'Step onto oil and you slide straight on over the spill, stopping on its last tile in that line (or short of a bomb or an enemy). You pick up what lies on the way, and flame where you end up burns you.', - 'Blobs, Seekers, Drifters, Sprinters and Hunters slip too, and their arrows show where the slide ends. Ghosts and Wisps fly over it.', - ], - clip: 'oilSlide', - date: '2026-10-31', - }, - { - kind: 'added', - icon: 'rock', - title: 'A demo of rubble forming; a time bar on every demo', - text: ['The Rubble entry in the Almanac now shows a cracked pillar being broken and the ceiling coming down, with a couple of turns after it. Every demo scene and clip has a bar under it showing how much is left, with a mark where the important moment comes.'], - clip: 'rubble', - date: '2026-10-31', - }, - { - kind: 'added', - icon: 'bomb_active', - title: 'Try the next version early', - text: ['The menu now has a "Try it early" link to a copy of the game built from the newest work, with its own saves and scores. Runs finished there are marked "Early adopter" on its board, and the released game counts them in the menu.'], - date: '2026-10-31', - }, - { - kind: 'changed', - icon: 'bomb', - title: 'Kick or walk: you choose', - text: ['With both Kick and Bomb Pass, a tap (or arrow key) on a bomb next to you asks whether to kick it or walk onto it. With the Detonator as well, setting it off is a third choice.'], - date: '2026-10-31', - }, - { - kind: 'changed', - icon: 'block_metal', - title: 'A tap on a crate walks to it', - text: ['Tapping a crate (even from two tiles away, with Run) now walks you to the tile beside it.'], - date: '2026-10-31', - }, - { - kind: 'changed', - icon: 'charge_freeze', - title: 'Use a Freeze before swapping charges', - text: ['When you pick up a charge while carrying a Freeze, you can now use the Freeze first and then take the new charge, instead of losing one of them.'], - date: '2026-10-31', - }, - { - kind: 'changed', - icon: 'bomb', - title: 'Only a click ends the demo', - text: ['Pressing a key (Ctrl, Shift...) no longer starts the game from the opening demo; click or tap to play. A new shuffle button plays the demo scenes in a random order, and remembers your choice.'], - date: '2026-10-31', - }, -] +export const UNRELEASED: Change[] = [] /** The version being played. */ export const VERSION = RELEASES[0]!.version diff --git a/src/main.ts b/src/main.ts index 66fa2ab..1f70885 100644 --- a/src/main.ts +++ b/src/main.ts @@ -45,7 +45,7 @@ import { CHANNEL, storageKey, TITLE } from './channel' import { createControls } from './ui/controls' import { nextFocus, type Step } from './ui/spatial' import { createPadInput } from './ui/gamepad' -import { glyphFor, padCaptionHtml, shoulderGlyph } from './ui/padGlyph' +import { dpadGlyph, glyphFor, padCaptionHtml, shoulderGlyph } from './ui/padGlyph' import { cancelFace, confirmFace, type FaceIndex, type PadFamily } from './ui/padMap' import { PAD_STILL_CAPTIONS, padDemoOrder, padHops, padPlan, pressPart, type PadPress } from './ui/padDemo' import { createPadOverlay, PAD_AFTER_SECONDS, PAD_LIT_SECONDS } from './ui/padOverlay' @@ -622,6 +622,7 @@ function showMenu(): void { { label: 'New', kind: 'danger', icon: newRunIcon, onClick: newRunDialog }, { label: 'Almanac', kind: 'plain', onClick: () => openAlmanac(undefined, state.options.dev) }, { label: 'Scores', kind: 'plain', onClick: openScores }, + { label: 'Changes', kind: 'plain', onClick: () => openChanges() }, { label: 'Demo', kind: 'plain', onClick: startDemo }, { label: 'Bug', kind: 'plain', onClick: reportBug }, ], @@ -1180,10 +1181,25 @@ let padWoke = false /** The demo changes scene at most this often from the pad: a held stick repeats faster than a scene can be made. */ const PAD_SCENE_MS = 400 let lastPadScene = -Infinity -function padScene(delta: number): void { +function padDemoControl(act: () => void): void { if (!demo || performance.now() - lastPadScene < PAD_SCENE_MS) return lastPadScene = performance.now() - skipTo(adjacentScene(demo.index, delta)) + act() +} +/** + * Up and down (shuffle, back to the first scene) act on the press only, not on the d-pad's repeats (a held direction calls again after + * 250 ms, then every 75 ms): a call soon after the last one is a repeat. + */ +const PAD_ONCE_GAP_MS = 300 +let lastPadOnce = -Infinity +function padDemoOnce(act: () => void): void { + const now = performance.now() + const repeat = now - lastPadOnce < PAD_ONCE_GAP_MS + lastPadOnce = now + if (!repeat) padDemoControl(act) +} +function padScene(delta: number): void { + padDemoControl(() => skipTo(adjacentScene(demo!.index, delta))) } /** "What is that?": the aim on a tile to explain (`aim`), with what pressing again does. */ @@ -1447,6 +1463,34 @@ const demoHint = document.querySelector('#demo-title .demo-hint')! function syncDemoHint(): void { // The button to press is the real pad's, even when the demo's controller shows another family. demoHint.innerHTML = padCaptionHtml(demoHintText(demoInput()), padActive ? padKind : demoFamily(), spriteUrl) + syncDemoGlyphs() +} +/** + * The pad's buttons for the demo's own controls, as pictures on them (Q67: every action shows its button), only while the demo is + * drawn for a controller: d-pad left / right = scene before / after, up = shuffle, down = back to the first, R1 = the next input. + */ +function syncDemoGlyphs(): void { + const family = padActive ? padKind : demoFamily() + const glyphs: Record<string, string> = { + 'demo-prev': dpadGlyph(family, 'left'), + 'demo-next': dpadGlyph(family, 'right'), + 'demo-shuffle': dpadGlyph(family, 'up'), + 'demo-restart': dpadGlyph(family, 'down'), + 'demo-input': shoulderGlyph(family, 'r'), + } + for (const [id, glyph] of Object.entries(glyphs)) { + const button = document.getElementById(id)! + let img = button.querySelector<HTMLImageElement>('.pad-glyph') + if (!img) { + img = document.createElement('img') + img.className = 'pad-glyph' + img.alt = '' + img.draggable = false + button.append(img) + } + img.src = spriteUrl(glyph) + } + document.getElementById('demo')!.classList.toggle('pad', isPadInput(demoInput())) } /** The demo's controller, caption and last line drawn for the family now in use (the pad's kind became known, or a pick changed it). */ function syncDemoFamily(): void { @@ -1489,7 +1533,12 @@ const pad = createPadInput({ gone: dropDemoPick, toast: (title, text) => showToast(handIcon, title, text), dir(dx, dy) { - if (demo) return dy === 0 && dx !== 0 ? padScene(dx) : undefined + if (demo) { + if (dy === 0 && dx !== 0) return padScene(dx) + // Up shuffles, down goes back to the first scene (the same as the buttons; their pictures say so). + if (dx === 0 && dy !== 0) return padDemoOnce(dy < 0 ? demoShuffleToggle : demoRestart) + return + } if (dialogOpen()) return padDialog.move(dx, dy) if (state.status !== 'playing' || boardLocked()) return if (inspecting) { @@ -1533,7 +1582,8 @@ const pad = createPadInput({ // The confirm face button (Cross / A; a Nintendo pad's A on the right), held until its ring is full, starts play (and, still held, does nothing in the game: gamepad.ts); the other face buttons do nothing here. Any other button that acts ends it, as a tap or Esc does. if (face !== undefined) { if (face === confirmFace(padKind)) endDemo() - } else if (button !== 'info' && button !== 'reset') endDemo() + } else if (button === 'info') cycleDemoInput() + else if (button !== 'reset') endDemo() return } if (button === 'menu') { @@ -1840,13 +1890,17 @@ const demoTime = bindTimeline(document.getElementById('demo-time')!) // The demo's buttons: the scene before, the scene after, and back to the first. document.getElementById('demo-prev')!.addEventListener('click', () => demo && skipTo(adjacentScene(demo.index, -1))) document.getElementById('demo-next')!.addEventListener('click', () => demo && skipTo(adjacentScene(demo.index, 1))) -document.getElementById('demo-restart')!.addEventListener('click', () => { +function demoRestart(): void { // Back to the first scene; shuffled, the order is made again from there. makeScenePlan(0) skipTo(0) -}) +} +document.getElementById('demo-restart')!.addEventListener('click', demoRestart) const shuffleButton = document.getElementById('demo-shuffle')! -shuffleButton.addEventListener('click', () => demo && setDemoShuffle(!demoShuffle)) +function demoShuffleToggle(): void { + if (demo) setDemoShuffle(!demoShuffle) +} +shuffleButton.addEventListener('click', demoShuffleToggle) const demoCaption = document.getElementById('demo-caption')! const actionBar = document.getElementById('actions')! const finger = document.getElementById('demo-finger')! @@ -1909,15 +1963,20 @@ drawPointer() const inputButton = document.getElementById('demo-input')! function syncInputButton(): void { const label = demoInputLabel(demoInput()) - if (inputButton.textContent !== label) inputButton.textContent = label + if (inputButton.textContent !== label) { + inputButton.textContent = label + // Setting the text removed the pad's picture on the button: put it back. + syncDemoGlyphs() + } } -inputButton.addEventListener('click', () => { +function cycleDemoInput(): void { if (!demo) return demoForced = nextDemoInput(demoInput()) syncInputButton() // Starts the scene again, drawn for the new input (the pad's scenes from their first, as when a pad takes the demo). skipTo(demo.index) -}) +} +inputButton.addEventListener('click', cycleDemoInput) /** * In the demo the action bar sits right under the board, as wide as it, and the caption panel * apart at the bottom: what you play on together, what explains it separately (user request, diff --git a/src/style.css b/src/style.css index f3ecd5d..36dc078 100644 --- a/src/style.css +++ b/src/style.css @@ -554,11 +554,9 @@ body.pad #actions kbd { display: grid; place-content: center; justify-items: center; - gap: 16px; padding: 16px; - text-align: center; - background: rgb(53 53 65 / 0.75); - color: #fff; + /* The same backdrop as a dialog's. */ + background: rgb(53 53 65 / 0.6); } #banner[hidden] { @@ -589,7 +587,7 @@ body.pad #actions kbd { animation: fade-in 0.3s ease-out 0.35s backwards; } -#banner:not([hidden]) > * { +#banner:not([hidden]) > .panel { animation: pop-in 0.3s ease-out 0.35s backwards; } @@ -636,7 +634,7 @@ body.pad #actions kbd { @media (prefers-reduced-motion: reduce) { #banner:not([hidden]), - #banner:not([hidden]) > *, + #banner:not([hidden]) > .panel, #dialog[open] .panel, #dialog[open]::backdrop, #hud .hud-item.bump { @@ -644,51 +642,47 @@ body.pad #actions kbd { } } +/* The end-of-level and end-of-run screen is a panel like a dialog's (user report, 2026-11-02: bare + text on a scrim looked unfinished); its three buttons are the dialog's .ui-button ones. */ +#banner .panel { + display: flex; + flex-direction: column; + align-items: center; + box-sizing: border-box; + width: min(360px, calc(100vw - 32px)); + max-height: calc(100vh - 32px); + max-height: calc(100dvh - 32px); + /* On a short screen the panel scrolls, so the buttons stay reachable. */ + overflow-y: auto; + scrollbar-width: none; + gap: 10px; + padding: 14px 18px 18px; + border: 12px solid transparent; + border-image: url('../assets/kenney-ui-rpg/panel_beigeLight.png') 12 fill / 12px stretch; + color: var(--ink); + text-align: center; +} + #banner img { - width: 96px; - height: 96px; + width: 72px; + height: 72px; } #banner p { margin: 0; - font-size: 24px; - font-weight: 800; + font-size: 18px; + font-weight: 700; + line-height: 1.35; } -#banner button { - min-height: 56px; - padding: 0 32px; - border: 3px solid var(--ink); - border-radius: 12px; - background: var(--accent); - box-shadow: 0 4px 0 var(--accent-dark); - color: #fff; - font: inherit; - font-weight: 800; +#banner .ui-button { + align-self: stretch; } -#banner button[hidden] { +#banner .ui-button[hidden] { display: none; } -/* "High scores" under the end-of-run choices: a plain link-like button. */ -#banner button.link { - min-height: 40px; - border: 0; - background: none; - box-shadow: none; - color: #fff; - text-decoration: underline; -} - -/* The second choice (a new dungeon, after game over): white, under the green one. */ -#banner button.secondary { - margin-top: -6px; - background: var(--panel); - box-shadow: 0 4px 0 #1e1e27; - color: var(--ink); -} - /* ---- Kenney UI pack: the font, the dialog, its buttons, and the menu button ---- */ @font-face { @@ -1355,6 +1349,32 @@ body.demo #hud { opacity: 1; } +/* The pad's button for each demo control (main.ts `syncDemoGlyphs`), on its corner, only while the demo is drawn for a controller. */ +#demo .demo-nav button { + position: relative; +} + +#demo .demo-nav .pad-glyph { + display: none; + position: absolute; + right: -8px; + bottom: -8px; + width: 20px; + height: 20px; + pointer-events: none; +} + +#demo.pad .demo-nav .pad-glyph { + display: block; +} + +/* The previous-scene button is drawn mirrored; its picture is not, and it still sits on the lower right. */ +#demo #demo-prev .pad-glyph { + right: auto; + left: -8px; + transform: scaleX(-1); +} + /* The input button (main.ts, Q61): a pill under the scene buttons, naming what the demo is shown for. */ #demo .demo-input-row { margin-top: 6px; @@ -2061,7 +2081,7 @@ canvas.clip[hidden] { /* The "Ouch!" banner's replay of the player's last turns (main.ts `rememberTurn`). */ #banner .clip-frame { - width: min(300px, 80vw); + width: min(300px, 100%); } /* The shop (ui/shop.ts): the coins on top, then the clip and the items as in the gem's offer, each @@ -2109,13 +2129,14 @@ canvas.clip[hidden] { } /* 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. */ + Almanac, Scores and Changes in a row of small buttons, then Demo and Bug (six columns: the big + buttons take three each, the first three small ones two each, the last two small ones three each). */ #dialog .panel.menu #dialog-buttons { - grid-template-columns: repeat(4, minmax(0, 1fr)); + grid-template-columns: repeat(6, minmax(0, 1fr)); } #dialog .panel.menu .ui-button { - grid-column: span 2; + grid-column: span 3; box-sizing: border-box; min-width: 0; min-height: 46px; @@ -2123,15 +2144,19 @@ canvas.clip[hidden] { } #dialog .panel.menu .ui-button.plain { - grid-column: span 1; + grid-column: span 2; min-height: 40px; padding: 0; - /* Thinner edges than a full-size button, so a word as long as ALMANAC fits a quarter row. */ + /* Thinner edges than a full-size button, so a word as long as ALMANAC fits a third of the row on a phone. */ border-width: 8px 6px 12px; border-image-width: 8px 6px 12px; font-size: 10px; } +#dialog .panel.menu .ui-button.plain:nth-last-child(-n + 2) { + grid-column: span 3; +} + /* In the shop the clip sticks just under the coins, which stick at the very top. */ #dialog .panel.shop .offer-clip { top: 36px; @@ -2286,7 +2311,7 @@ body.pad #banner .pad-glyph { margin-left: 8px; } -/* A quarter-row button of the menu (Almanac, Scores, Demo, Bug) has no room for a picture beside its +/* A small button of the menu (Almanac, Scores, Changes, Demo, Bug) has no room for a picture beside its word: it hangs on the corner instead of pushing the word aside (user report, 2026-11-02). */ #dialog .panel.menu .ui-button.plain { position: relative; diff --git a/test/changelog.test.ts b/test/changelog.test.ts index 4fa82d1..2667250 100644 --- a/test/changelog.test.ts +++ b/test/changelog.test.ts @@ -1,5 +1,6 @@ import { readFileSync } from 'node:fs' import { describe, expect, it } from 'vitest' +import { spriteUrl } from '../src/render/sprites' import { compareVersions, newsFor, parseVersion, RELEASES, releasesSince, UNRELEASED, VERSION, type Release } from '../src/changelog' const release = (version: string): Release => ({ version, date: '2026-01-01', summary: '', changes: [] }) @@ -24,6 +25,10 @@ describe('changelog', () => { } }) + it('every change icon is a sprite the game loads', () => { + for (const c of [...UNRELEASED, ...RELEASES.flatMap((r) => r.changes)]) expect(() => spriteUrl(c.icon), c.title).not.toThrow() + }) + it('compares numerically, not as text', () => { expect(compareVersions('0.10.0', '0.9.0')).toBeGreaterThan(0) expect(compareVersions('1.0.0', '0.99.99')).toBeGreaterThan(0)