explodorama / commit
Controller and keyboard: demos, hold to confirm, faded far tiles with route preview, no auto-fullscreen; Autopilot replaces the hoover (0.2.0 work)
CHANGELOG.md | 26 +- PLAN.md | 17 +- README.md | 153 ++- assets/controller-art/LICENSE.txt | 13 + assets/controller-art/padart-nintendo.png | Bin 0 -> 52693 bytes assets/controller-art/padart-playstation.png | Bin 0 -> 74501 bytes assets/controller-art/padart-xbox.png | Bin 0 -> 60701 bytes assets/kenney-cursor/License.txt | 22 + assets/kenney-cursor/cursor_none.png | Bin 0 -> 262 bytes assets/kenney-icons/larger.png | Bin 0 -> 1529 bytes assets/kenney-icons/smaller.png | Bin 0 -> 1452 bytes assets/kenney-input-prompts/keyboard_alt.png | Bin 0 -> 298 bytes .../kenney-input-prompts/keyboard_arrow_down.png | Bin 0 -> 286 bytes .../kenney-input-prompts/keyboard_arrow_left.png | Bin 0 -> 295 bytes .../kenney-input-prompts/keyboard_arrow_right.png | Bin 0 -> 291 bytes assets/kenney-input-prompts/keyboard_arrow_up.png | Bin 0 -> 285 bytes assets/kenney-input-prompts/keyboard_arrows.png | Bin 0 -> 203 bytes assets/kenney-input-prompts/keyboard_b.png | Bin 0 -> 325 bytes assets/kenney-input-prompts/keyboard_enter.png | Bin 0 -> 387 bytes assets/kenney-input-prompts/keyboard_escape.png | Bin 0 -> 367 bytes assets/kenney-input-prompts/keyboard_home.png | Bin 0 -> 363 bytes assets/kenney-input-prompts/keyboard_m.png | Bin 0 -> 356 bytes assets/kenney-input-prompts/keyboard_question.png | Bin 0 -> 355 bytes assets/kenney-input-prompts/keyboard_shift.png | Bin 0 -> 341 bytes assets/kenney-input-prompts/keyboard_space.png | Bin 0 -> 389 bytes assets/kenney-input-prompts/keyboard_v.png | Bin 0 -> 377 bytes .../playstation5_button_options.png | Bin 0 -> 337 bytes assets/kenney-input-prompts/playstation_dpad.png | Bin 0 -> 373 bytes .../kenney-input-prompts/playstation_dpad_down.png | Bin 0 -> 390 bytes .../kenney-input-prompts/playstation_dpad_left.png | Bin 0 -> 406 bytes .../playstation_dpad_right.png | Bin 0 -> 410 bytes .../kenney-input-prompts/playstation_dpad_up.png | Bin 0 -> 394 bytes .../kenney-input-prompts/playstation_stick_r.png | Bin 0 -> 649 bytes .../playstation_stick_r_down.png | Bin 0 -> 687 bytes .../playstation_stick_r_left.png | Bin 0 -> 655 bytes .../playstation_stick_r_press.png | Bin 0 -> 697 bytes .../playstation_stick_r_right.png | Bin 0 -> 654 bytes .../playstation_stick_r_up.png | Bin 0 -> 679 bytes assets/kenney-input-prompts/switch_button_plus.png | Bin 0 -> 410 bytes assets/kenney-input-prompts/switch_dpad.png | Bin 0 -> 398 bytes assets/kenney-input-prompts/switch_dpad_down.png | Bin 0 -> 467 bytes assets/kenney-input-prompts/switch_dpad_left.png | Bin 0 -> 471 bytes assets/kenney-input-prompts/switch_dpad_right.png | Bin 0 -> 475 bytes assets/kenney-input-prompts/switch_dpad_up.png | Bin 0 -> 464 bytes assets/kenney-input-prompts/switch_stick_r.png | Bin 0 -> 649 bytes .../kenney-input-prompts/switch_stick_r_down.png | Bin 0 -> 687 bytes .../kenney-input-prompts/switch_stick_r_left.png | Bin 0 -> 655 bytes .../kenney-input-prompts/switch_stick_r_press.png | Bin 0 -> 697 bytes .../kenney-input-prompts/switch_stick_r_right.png | Bin 0 -> 654 bytes assets/kenney-input-prompts/switch_stick_r_up.png | Bin 0 -> 679 bytes assets/kenney-input-prompts/xbox_button_menu.png | Bin 0 -> 408 bytes assets/kenney-input-prompts/xbox_dpad.png | Bin 0 -> 217 bytes assets/kenney-input-prompts/xbox_dpad_down.png | Bin 0 -> 248 bytes assets/kenney-input-prompts/xbox_dpad_left.png | Bin 0 -> 253 bytes assets/kenney-input-prompts/xbox_dpad_right.png | Bin 0 -> 253 bytes assets/kenney-input-prompts/xbox_dpad_up.png | Bin 0 -> 252 bytes assets/kenney-input-prompts/xbox_stick_r.png | Bin 0 -> 649 bytes assets/kenney-input-prompts/xbox_stick_r_down.png | Bin 0 -> 687 bytes assets/kenney-input-prompts/xbox_stick_r_left.png | Bin 0 -> 655 bytes assets/kenney-input-prompts/xbox_stick_r_press.png | Bin 0 -> 697 bytes assets/kenney-input-prompts/xbox_stick_r_right.png | Bin 0 -> 654 bytes assets/kenney-input-prompts/xbox_stick_r_up.png | Bin 0 -> 679 bytes decisions.md | 357 +++++++ docs/art-provenance.md | 58 +- index.html | 9 +- src/changelog.ts | 57 +- src/main.ts | 1112 ++++++++++++++++---- src/render/board.ts | 16 +- src/render/camera.ts | 16 +- src/sim/autopilot.ts | 142 +++ src/sim/demo.ts | 38 + src/sim/record.ts | 2 +- src/sim/testScenes.ts | 36 +- src/sim/turn.ts | 26 - src/sim/types.ts | 2 - src/style.css | 417 +++++++- src/ui/almanac.ts | 27 +- src/ui/clip.ts | 90 +- src/ui/controls.ts | 11 +- src/ui/demoInput.ts | 54 + src/ui/dialog.ts | 21 + src/ui/gamepad.ts | 84 +- src/ui/holdRing.ts | 95 ++ src/ui/keyCursor.ts | 137 +++ src/ui/keyDemo.ts | 216 ++++ src/ui/keyOverlay.ts | 53 + src/ui/padDemo.ts | 130 +++ src/ui/padDialog.ts | 83 +- src/ui/padDialogMap.ts | 27 +- src/ui/padGlyph.ts | 85 +- src/ui/padHold.ts | 73 ++ src/ui/padLayouts.ts | 188 ++++ src/ui/padMap.ts | 52 +- src/ui/padOverlay.ts | 156 +++ src/ui/padPlaces.ts | 47 + src/ui/padTiles.ts | 101 +- src/ui/routePreview.ts | 27 + test/autopilot.test.ts | 190 ++++ test/demoInput.test.ts | 42 + test/farTiles.test.ts | 172 +++ test/gamepad.test.ts | 305 +++++- test/holdRing.test.ts | 19 + test/hoover.test.ts | 88 -- test/keyCursor.test.ts | 153 +++ test/keyDemo.test.ts | 150 +++ test/padDemo.test.ts | 170 +++ test/padDialog.test.ts | 42 +- test/padGlyph.test.ts | 65 +- test/padHold.test.ts | 74 ++ test/padMap.test.ts | 67 +- test/padPlaces.test.ts | 45 + test/padTiles.test.ts | 14 +- test/q75.test.ts | 142 +++ test/routePreview.test.ts | 67 ++ test/test-scenes.test.ts | 19 + tools/make-pad-layouts.py | 93 ++ 116 files changed, 5728 insertions(+), 465 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f02416..f1fcd73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,10 +13,32 @@ saved before the change is dropped and the next visit starts a new run. ## [Unreleased] +### Added + +- **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. + ### Changed -- **The level finishes itself** (Q56, user request, 2026-11-01): once the last enemy is gone, the new turnless action `hoover` (`canHoover` / `hoover` in `sim/turn.ts`) gathers the coins, charges and uncovered gems, then `autoFinish` in `main.ts` walks to the exit with auto-move, waiting for crumbling crates. Setting "Finish the level for me" (on by default). Rules version unchanged (a new action; old records replay as before). -- **Fullscreen from the first tap, click or key** (Q57, user request, 2026-11-01): `startFullscreen` in `main.ts`; setting "Start in fullscreen" (on by default). +- **Move works on a tile that is also a bomb target** (Q75b, user bug report, 2026-11-02): with an RPG, an empty tile two away in a line was bright blue and Move was greyed there, so you could fire at it but not walk to it. `cursorActions` (`ui/padTiles.ts`) now offers Move as the walk ("Walk here · N steps") on any tile auto-move can reach, whatever else targets it; Bomb stays beside it. Tests: `test/q75.test.ts`. +- **The cursor reaches every faded tile while a walk is set** (Q75, user bug report, 2026-11-02): with an auto-move destination set, only the destination was a faded tile and a cursor candidate, so a coin elsewhere (an enemy's drop beside a bomb) looked unreachable. `currentFar` (`main.ts`) now lists all faded tiles always; Move on another sets a new destination. Rules were fine; test: `test/q75.test.ts`. +- **Moving the mouse no longer hands the board back** (user request, 2026-11-02): with the pad's or the keyboard's cursor showing, only a click, a tap or a key switches to the mouse; a plain mouse move (e.g. to take a screenshot) does not. +- **A controller can change the menu's switches: the selected item shows its confirm button** (Q74, user bug report, 2026-11-02): in a dialog with a selector (menu, almanac, New run, changes list) a short press of Cross did nothing and nothing said why (the hold rule, Q59). The selected item now carries the confirm button's picture (`.sel-glyph`, `ui/padDialog.ts` `setSel`, with `data-face`, so the hold ring of `ui/holdRing.ts` fills round it); a face button let go before its ring filled calls the new optional `PadTargets.tap` (`ui/gamepad.ts`), which shows the toast "Hold to choose" once per dialog (`showsTapHint` in `ui/padDialogMap.ts`, pure, tested). The almanac's tabs before and after the shown one carry the LB / RB pictures. The 0.5 s rule is unchanged. **Autopilot:** it now waits while every target is cut off by a still-ticking bomb or burning flame (new phase `bombs`, "Autopilot: waiting for the bombs"; before, a coin behind its own bomb was skipped and the level ended without it), waits in that state no longer count towards the stall limit, and the route preview is off while it walks. Tests: `test/autopilot.test.ts`, `test/padDialog.test.ts`, `test/gamepad.test.ts`. +- **The hold ring is anchored to the picture that began it** (Q72, 2026-11-02): the ring lingered at its old spot for the dialog's 150 ms pop-out, because it was placed only when the pad was polled. It now follows its picture every animation frame (`ui/holdRing.ts`) and hides the same frame the picture is removed, hidden, zero-sized or inside a closing or closed dialog (`ringVisible`); a completed hold's button stays used up until release. Test: `test/holdRing.test.ts`. +- **Hold to confirm acts when the ring fills, not on release** (Q59 amended, user request, 2026-11-02): waiting for the button to be let go felt late. A face button now acts the moment its ring is full (`stepHold` in `ui/padHold.ts` returns `progress` 1 then `fire`; `ui/gamepad.ts` delivers `press` then), and the ring stays full (green) until the button is let go. The button is then **used up** (`HoldState.done`): it does not fire again, and it starts no new hold in whatever the action opened (a dialog, the demo ending into the game, a new level, the banner) until it has been let go; a second face button meanwhile is still ignored. A context change after completion only clears the ring (`holdEnd`); the used-up state ends on release, unplug, blur or a swapped pad. Holds that have not completed behave as before (early release, a context change, an instant button, blur, unplug, a swap or an error cancel with no action). The demo's hold-to-play starts play at completion, and the same held button then does nothing in the game. New event `release` in `HoldEvent`; `PadTargets.holdEnd` now means "the ring goes". Tests: `test/padHold.test.ts`, the hold-to-confirm block of `test/gamepad.test.ts`. +- **Virtual controller: white d-pad, outline on top, brighter face buttons; stick demos; time bar on the TV** (user request, 2026-11-02): the d-pad and stick pictures are white at rest (they were dimmed grey) and show a press by their red arm; shoulders and Start light with a glow; the face buttons are dimmer at rest but brightened so PS5's blue and red read on the dark demo (replaced the same day: they are now grey at rest and coloured only while pressed, since a lit one was hard to tell from an idle one). The pictures are drawn under the line drawing (so its lines stay on top) and each face picture is clipped to its drawn circle (`--clip`, from `GLYPH_SCALE`); hold rings stay above. New pad scenes: the stick scene now zooms the demo's view to 2x (`updateCamera`'s `zoom`, `demoZoom` in `main.ts`, reset by `resetView`, `skipTo` and `endDemo`) and the stick pans it in each direction, then pressing it returns to you; **padLook** pushes the stick toward an enemy (`padDemoOrder`, `PAD_STILL_CAPTIONS.padLook`). The scene's progress bar (`#demo-time`) moved from the caption panel to the TV's bottom edge, inside the glass. +- **The virtual controller is a real line drawing** (Q63, 2026-11-02): the hand-drawn SVG outline is replaced by OpenGameArt's "Controller style 1 / 3 / 5" pictures (CC BY 4.0, `assets/controller-art/`, scaled and trimmed by `tools/make-pad-layouts.py`) for Xbox (and generic), PlayStation and Nintendo, drawn as an image with a light glow so the lines read on the dark demo, and Kenney's pictures sit on the drawn buttons (measured positions; the layout lost `outline` and `lstick`). README Attribution and art provenance updated; the CC BY-SA Xbox photo credit is gone. The positions come from `ui/padLayouts.ts` (generated by `tools/make-pad-layouts.py`) through `ui/padPlaces.ts` (pure, tested); the face buttons follow `faceOf`, so Nintendo's A and B land where they are without CSS special cases. Removed the CSS body and grips and the per-part percentages. Art provenance #16 is data, not art. +- **Nintendo pads: A confirms, B backs out; the pad demo's presses stay lit longer** (user request, 2026-11-02; amends the position-only mapping): on a Nintendo pad Move/Go and every dialog confirm (primary button, shop Buy, the demo's hold-to-play) are the right button (A), and Wait and every dialog dismiss are the bottom button (B); Bomb (Y) and Charge (X) stay left and top. Xbox, PlayStation and generic pads are unchanged. The family-aware rule is in the pure modules: `faceOf`, `faceActionOf`, `confirmFace`, `cancelFace` in `ui/padMap.ts` (`mapPad` takes the family), `faceGlyph` / `glyphFor` in `ui/padGlyph.ts`, `assignFaces` / `faceAction` in `ui/padDialogMap.ts`; the action bar's `data-face` follows the family, open dialogs are assigned again when it becomes known, and the demo's virtual controller draws A on the right and B at the bottom. `FACE_TO_BUTTON` in `ui/padTiles.ts` became `barButtonFor`. Separately, a pressed button on the virtual controller stays lit for `PAD_LIT_SECONDS` = 0.7 s (`ui/padOverlay.ts`; it was about 0.3 s) in the demo and the clips, with the beats respaced (`PAD_AFTER_SECONDS` = 0.3 s for the cursor to hop and rest after a d-pad press) so presses never overlap. Tests: Nintendo mapping, glyphs and dialog faces. +- **No automatic fullscreen; the cursor stays on the destination while walking** (Q70, user request, 2026-11-02; supersedes Q57 and the Q60 auto rule): the game no longer goes fullscreen on the first tap, click or key, with or without a controller. Removed: `startFullscreen`, the "Start in fullscreen" setting (its stored value is no longer read), `ui/fullscreenRule.ts` and its test. Kept: the top bar's fullscreen button, the menu's "Fullscreen" switch and its "Press F11" toast. Also fixed: with a controller (or the keyboard cursor), after the first step of an auto-move walk the cursor returned to the player's tile, where only Wait was offered. It now starts each turn on the destination while the walk is on (`cursorStart` in `ui/padTiles.ts`, pure, tested), so Go ("Keep walking") continues it, and on the player again when the walk ends. +- **Fullscreen only with a controller; a button and a menu switch** (Q60, user request, 2026-11-02; amends Q57): the auto-fullscreen part (`shouldAutoFullscreen` in `ui/fullscreenRule.ts`, `padConnected`, the setting "Start in fullscreen") was superseded by Q70 above and no longer exists; the rest stands. New: a round fullscreen button in the top bar (`#fullscreen-button`, shown only if `document.fullscreenEnabled`, state from `fullscreenchange`; Game Icons `larger` / `smaller`; on a phone's portrait bar all three round buttons shrink to 31 px so the bar does not grow), and a "Fullscreen" switch in the menu's Settings for the controller. A controller press is no browser gesture, so a refusal shows the toast "Press F11, or use the fullscreen button" (the Electron wrapper, Q41, will make it work). No pad button is assigned. +- **The demo's input button** (Q61, user request, 2026-11-02): a pill under the scene buttons cycling **Input: touch**, **mouse**, **controller: ps5**, **xbox**, **switch**, **generic** (`ui/demoInput.ts`, pure, tested). It overrides the detected input for the demo only (`demoForced` in `main.ts`, not saved): the scene restarts for it; mouse is the touch scenes with Kenney's Cursor Pack arrow (`kenney-cursor/cursor_none.png`) in the finger's place and the hint "Click anywhere to play"; the controllers use the pad's scenes and virtual controller with that family's pictures. The pick stays until the demo ends or a controller is plugged in or unplugged (no click or button press drops it; only holding the confirm button, which ends the demo, leaves it). `Demo.input` joins `Demo.pad`; `syncDemoInput` handles touch/mouse and family switches without changing the scene list. The "Hold {move} to play" hint follows the effective input. Clips keep following the real input. +- **Hold to confirm on the pad** (Q59, user request, 2026-11-02): a face button now acts only when held until a ring has filled (`HOLD_SECONDS` = 0.5 in `ui/padHold.ts`, pure, tested), and then (amended the same day, see "Hold to confirm acts when the ring fills" above: the moment the ring fills, not on release); releasing early, a swapped pad or a window that loses focus cancels. The ring (`.hold-ring` in `style.css`, a CSS conic gradient, no art; `ui/holdRing.ts` for the page, `PadOverlay.hold` for the demo's controller) fills clockwise from 12 o'clock round the button's picture on the action bar, in dialogs and on the banner. `PadTargets` gains `hold(face, progress)` and `holdEnd()`; `press` for a face button is delivered when the ring fills (`ui/gamepad.ts`; first built as on release); the other buttons (d-pad, shoulders, View, R3, Start) stay instant, and touch, mouse and keyboard are unchanged. Demo: the confirm face button (Cross / A at the bottom; a Nintendo pad's A on the right), held, starts play; other face buttons no longer end it, and the hint reads "Hold {button} to play" whenever the demo is shown for a controller (a detected one, or one picked with the demo's input button). The waking press still only shows the cursor. +- **Controller demos and clips** (Q58, 2026-11-02): while a pad is in use, the demo shows a virtual controller (`ui/padOverlay.ts`; first CSS shapes with Kenney Input Prompts pictures, since Q63 a line drawing with the pictures on it: d-pad, face buttons, shoulders, right stick, Start) lighting the pressed buttons and a dashed cursor on the board instead of the finger, and the dialogs' clips do the same for the finger kinds (`FINGER_KINDS` in `ui/clip.ts`; a clip switches finger or controller at its next loop, and reduced motion hides the controller like the finger). The pad has its own scene list (`PAD_DEMO_ORDER`, built by `padDemoOrder` in `ui/padDemo.ts`): the cursor, Move, Bomb, Wait, L1, R1, the stick, Start, then the touch demo's gameplay scenes without the touch-only ones (long-press bomb, auto-move, the top bar, "What is that?") and the charge swap (`PAD_LATER` in `sim/demo.ts`). `ui/padDemo.ts` (pure, tested) turns a turn's action into pad presses (`padPlan`, `cursorPath`, `pressPart`, `padHops`). Scenes carry an optional `padCaption` with `{dpad}`, `{move}` ... pictures (`padCaptionHtml`), redrawn if the kind of pad becomes known mid-scene. The two lists keep separate saved places and switch only at the next scene. Almanac Tests tab: a "Demo controller" sample. Ending the demo during the R1 scene no longer leaves "What is that?" on. Art provenance: 36 more Input Prompts files (the controller outline was first noted as hand-made; the Q63 line drawing replaced it). +- **Autopilot replaces the hoover, sim part** (Q73, user request, 2026-11-02; supersedes Q56 below): the turnless `hoover` action is gone (`canHoover` / `hoover` removed from `sim/turn.ts`, `test/hoover.test.ts` deleted). New pure `sim/autopilot.ts`: `autopilotTargets` (every drop and uncovered gem) and `autopilotPlan` (the next ordinary move toward the cheapest order through them, fewest total turns, exit last; targets on the way count as free; waits for crumbling crates; re-planned every turn with `safeStep`). Tests `test/autopilot.test.ts`; test scenes `testAutopilot` and `testAutopilotCrates`. **SIM_VERSION 42 to 43** (a record holding `hoover` can no longer replay). +- **Autopilot, UI part** (Q73, user request, 2026-11-02): `autopilotTick` in `main.ts` replaces `autoFinish`. When the last enemy dies it shows the note `#autopilot` ("Autopilot: cleaning up" / "waiting for the crates" / "waiting for the bombs" / "off to the exit"; top centre, not a popover, `role="status"`, with the Esc cap or the L1 picture and a Stop button), waits `AUTOPILOT_START_SECONDS` (1.0), then plays `autopilotPlan(state, predictDanger(state))` every `AUTOPILOT_STEP_SECONDS` (0.25) as `auto` turns, pausing while a dialog or offer is open. Stops: Esc, L1, the Stop button, turning the setting off, or a stall (6 waits in a row, not counting waits while a bomb or flame is on the board, or no safe turn: toast "Autopilot stopped / Your turn"). `boardLocked()` makes the board ignore taps, keys, the action bar (dimmed by `body.autopilot`) and the pad's cursor and buttons while it walks; looking around, dialogs, the menu and the fullscreen button still work; `act()` has a backstop. `cursorWanted` (`ui/padTiles.ts`) takes an `autopilot` flag (tested). The setting is "Autopilot" (input `autopilot`), still stored under the old `autoFinish` key. +- ~~**The level finishes itself** (Q56, user request, 2026-11-01): once the last enemy is gone, the new turnless action `hoover` (`canHoover` / `hoover` in `sim/turn.ts`) gathers the coins, charges and uncovered gems, then `autoFinish` in `main.ts` walks to the exit with auto-move, waiting for crumbling crates. Setting "Finish the level for me" (on by default). Rules version unchanged (a new action; old records replay as before).~~ Superseded by Q73 above. +- ~~**Fullscreen from the first tap, click or key** (Q57, user request, 2026-11-01): `startFullscreen` in `main.ts`; setting "Start in fullscreen" (on by default).~~ Superseded by Q70 above. - **Controller support, stage 1** (Q29, 2026-11-01): the pure core, not yet wired in: `ui/padMap.ts` (gamepad snapshot to intents, with repeat, stick hysteresis, deadzone, controller family) and `ui/spatial.ts` (directional focus), with tests. - **Controller support, stage 2** (Q29, 2026-11-01): board play with a gamepad, wired in: `ui/gamepad.ts` (reads the first standard pad, routes intents to the game), the demo's scenes can be switched with the d-pad, and a face button, LB or Start ends it. Board play is "tile first" (below): LB puts away "What is that?" or what is left picked and returns the cursor to the player's tile, RB and View are "What is that?". - **Controller support, stage 3: dialogs and the banner** (Q29, 2026-11-01): a whole run is playable by pad. A dialog's footer buttons sit on the face buttons with their pictures (primary on Cross, the others on Triangle then Square, the one that leaves on Circle, which closes any dismissable dialog); where they do not fit, or the body has controls, a d-pad selector (`body.pad .pad-sel`, yellow outline) moves over the body's elements and the footer and Cross presses the focused one. Gem offer and shop cards: moving selects (and plays the clip), Cross takes (the shop: Buy). LB/RB switch almanac tabs, the right stick scrolls the body, the death/cleared/victory banner's buttons are on Cross, Triangle, Square, and the seed field is skipped. `ui/padDialogMap.ts` (pure, tested) and `ui/padDialog.ts` (DOM glue, watches the dialog with a MutationObserver so rebuilt bodies keep the selector); `dialog.ts` gains `dismissDialog()` and a `dialogopen` event. diff --git a/PLAN.md b/PLAN.md index 6e89685..17d299d 100644 --- a/PLAN.md +++ b/PLAN.md @@ -954,6 +954,9 @@ src/ explosions.ts blast shapes, chain reactions enemies.ts intent planning per enemy type danger.ts predicted flame tiles this turn / next turn + path.ts auto-move's way, safe steps, `playerPath` (shared by auto-move, the route preview and the autopilot) + autopilot.ts the autopilot (Q73): targets, the cheapest order, the next turn; waits for crates and bombs + testScenes.ts the Developer-mode test scenes (a board and a state for each edge case) curses.ts shop.ts offers, prices, purchase record.ts run record, replay, hash, SIM_VERSION @@ -966,21 +969,33 @@ src/ reveal.ts controller target: how far to move the view to keep a tile on screen (pure) ui/ DOM: action bar, HUD, shop, death choice, certificate, About padMap.ts controller: gamepad snapshot to intents (pure; decisions.md 2026-11-01) + padHold.ts controller: hold a face button until a ring fills, act when the ring fills (pure; decisions.md 2026-11-02) + holdRing.ts controller: the ring element moved over the held button's picture + padPlaces.ts controller demo: where each part sits on the controller picture, per family (pure) + padLayouts.ts controller demo: the measured positions (GENERATED by tools/make-pad-layouts.py; do not edit) + demoInput.ts the demo's input button: touch, mouse, keyboard or a pad family, and what the demo is shown for (pure) spatial.ts controller: which element to focus next in a direction (pure) gamepad.ts controller: reads the pad each frame, routes intents to main.ts (stage 2) padTarget.ts controller: the first target, and jumping between targets (pure) padGlyph.ts controller: which button picture goes on each action button (pure) targetLabel.ts controller: the one-line label of what confirming does (pure) padTiles.ts controller: actionable tiles, what each button does on a tile, cursor moves (pure) + routePreview.ts auto-move's way for a far tile only looked at (cursor on a faded tile, mouse hover): same landing tiles and red flag as the real route (pure) + keyCursor.ts keyboard cursor (Q65): who has the board (pad / keys / none), key roles, which button a key means, Enter's default, Esc's step, whether a focused control takes Enter/Space (pure) padDialogMap.ts controller in dialogs: footer buttons to face buttons, which action a face button does (pure) padDialog.ts controller in dialogs and the banner: DOM glue (selector, pictures, tabs, scroll; stage 3) + padOverlay.ts controller demo (Q58): the virtual controller: a line-drawing picture (assets/controller-art/) with Kenney glyphs on it (DOM, for the demo and clips) + padDemo.ts controller demo: a turn as pad presses, cursor path, pad scene order and still-scene captions (pure) + keyDemo.ts keyboard demo (Q65 B): a turn as the fewest key presses, replayed through the game's rules; key captions, scene order (pure) + keyOverlay.ts keyboard demo: the key overlay, Kenney's keyboard glyphs on a CSS grid (DOM) audio.ts Kenney pack sound effects (§13); ZzFX for any it lacks persist.ts localStorage save/load, export/import main.ts assets/kenney/ Kenney New Platformer Pack sprites + License.txt (§13) static/ Vite publicDir (NOT public/ — see §17) test/ Vitest -tools/ compose-icons.sh (composed sprites and the app icons), screenshots.mjs (Playwright) +tools/ compose-icons.sh (composed sprites and the app icons), screenshots.mjs (Playwright), + make-pad-layouts.py (the controller pictures and ui/padLayouts.ts), audit-art.mjs (which art is byte-identical to Kenney's) ``` - `step()` **returns events** (`moved`, `bombPlaced`, `bombTicked`, `exploded`, `blockDestroyed`, diff --git a/README.md b/README.md index a980888..815b5ea 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ only `explodorama.earlyRuns` is shared. The released copy also has a **Coming next** list; runs finished on the next copy are marked **Early adopter** on its board. Developer mode is separate and can be switched on in either. -Rules version: **42** (`SIM_VERSION` in [src/sim/record.ts](src/sim/record.ts)). It goes up by one +Rules version: **43** (`SIM_VERSION` in [src/sim/record.ts](src/sim/record.ts)). It goes up by one whenever a rule change would make an old run replay differently, independent of the release version (one release can span several). A run saved under another rules version is not resumed, and a seed gives the same dungeon only within one rules version. @@ -89,6 +89,37 @@ view-only; **click or tap anywhere else to play** (a key does nothing: Ctrl or S 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. +**With a controller** the demo shows a **virtual controller** under the caption (a line drawing of the pad, d-pad, four +face buttons, shoulders, stick and Start, with the pictures of the pad in use) that lights the +button for each press (lit for 0.7 s, `PAD_LIT_SECONDS` in `ui/padOverlay.ts`; a d-pad press lets go, then the cursor hops), and a dashed **cursor** on the board that hops between the blue tiles, in +place of the finger. It has its own scene list: the **cursor** (the d-pad), **Move**, **Bomb**, **Wait**, **L1** +(back to you), **R1** ("What is that?": the aim jumps to an enemy, Move explains it), the **stick** +(the view zooms in to twice the size and the stick slides it left, right, up and down, then pressing it brings it back to you), **look** (the +stick pushed toward an enemy to look at it) and **Start** (the real +menu opens over the scene, with your run's state in it, and closes again), then the same gameplay +scenes as the touch demo. A thin **progress bar** of the scene's turns runs along the bottom edge of the TV (inside the glass, above the caption), in every demo. The idle white pictures (d-pad, stick) are white; the face buttons are grey at rest and take their colour while pressed; the pictures lie under the drawing, so its lines stay on top. The touch-only scenes (the long-press bomb, auto-move, the top bar tour, +"What is that?" by tap) and the charge swap (a dialog's choice) are left out. The pad's demo keeps +its own saved place, apart from the touch demo's. When the input changes (a pad press, or a mouse, +touch or key) the demo switches **at the next scene**, never in the middle of one: to the pad's list +from its first scene, or back to the touch list where it was left. Start or LB ends the demo as ever, but a face button must be **held to play**: **hold the confirm +button (Cross, or A on an Xbox pad: the bottom button; on a Nintendo pad A is the right-hand button)** until the ring round it on the virtual controller fills: play starts then, and the button, still held, does nothing in the game until you let go of it. The +last line reads "Hold (the button's picture, A on a Nintendo pad) to play" instead of "Tap anywhere to play"; a short press of any face button does nothing. +**The input button** under the scene buttons names what the demo is shown for and cycles on each click: **Input: touch** (the finger), +**Input: mouse** (the same scenes with a pointer instead of the finger; its last line reads "Click anywhere to play"), +**Input: keyboard** (see below), then +**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. +**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 +for a bomb next to you, **Space** to wait, and for a far tile **Shift + arrows** (the first only wakes the dashed cursor on you, as in play) and then the action +key. Its scenes: Move, Bomb, Wait, the **Shift cursor**, **?** (then an arrow to the enemy, **Enter** explains), **Alt + arrows** (the view zooms to twice the size and slides, **Home** brings it back) and +**Esc** (the real menu opens), then the touch demo's gameplay scenes without the touch-only ones and the charge swap, with captions worded for keys. The last line reads +"Press Enter or Esc to play" (those two end the demo; ← and → change scenes, Tab, Enter and Space work the demo's own buttons, and every other key does nothing). Its place is saved apart from the other lists, and the dialogs' clips follow the real input. +The Almanac's **Tests** tab (in a Developer run) has a **Demo controller** sample that presses each +part in turn, on an Xbox, PlayStation and Switch pad. + <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"> You are the green astronaut (animated: it bobs while waiting, hops from tile to tile, crouches @@ -154,17 +185,30 @@ exit is where you are going. If the only way somewhere is over the open exit, au It **won't take a deadly step**: onto an enemy, onto a tile an enemy's arrow points at, into flame this turn, or any other move that would cost a life this turn. The next step then shows red, and Go / the destination wait for you to deal with it (Go falls back to picking Move, so you can step aside yourself). +**With a controller or the keyboard cursor**, every tile auto-move can take you to (the same ones a tap would walk to, with a safe first step, not already bright blue) shows as a **faded blue**, next to the bright blue of the single actions. The cursor hops onto them like any other tile, and **Move/Go** there (**M** or Enter on the keyboard) is the same as tapping that tile: the label says "Walk here · N steps" and the first step is taken. With a way already set, the cursor can still hop to every faded tile (Move there sets a new way); on the destination itself the label says "Keep walking · N steps". **The way is previewed** before you confirm: with the cursor on a faded tile, or the **mouse** (not touch) hovering over one with nothing picked, the board shows the route exactly as auto-move would draw it (landing tiles, arrows, the first one red when unsafe) without setting it or taking a step. Tapping, clicking and touch are unchanged. **Once every enemy is gone**, the way needs no more presses: you walk there by yourself, two steps a second (still one turn per step). -**The level finishes itself** (Settings in the menu, "Finish the level for me", on by default): the -moment the last enemy is gone, the **hoover** (the `hoover` action, no turn) gathers every coin, -charge and uncovered gem lying on the floor, as if walked over, then the flag is put on the exit -and auto-move walks there (the walk costs turns; only the gathering is free). Gems offer one after -another (`waitingGems`); a charge that needs a choice (one is already carried) pauses the gathering -until answered, then it goes on. Your own move, Esc / L1, or tapping a flag elsewhere ends it for -the level. The usual auto-move safety applies (never a deadly or unsafe step; if no way is safe it -waits for you); no way at all to the exit is the existing boxed-in rule (a life). +**Autopilot** (Settings in the menu, "Autopilot", on by default): the moment the last enemy is +gone, a note at the top says **Autopilot: cleaning up**, and after a one-second beat the character +walks round by itself, four moves a second, to every coin, charge and gem still lying on the floor, +then onto the exit. Every move is an ordinary turn (a Run if you own it), so **gathering costs +turns** and can lose the par bonus. The route is the one that takes the fewest turns in all (the +exit last; a coin on the way is picked up free). A gem, or a charge that needs a choice (one is +already carried), opens its dialog on that turn and the walk **waits for your answer**, then goes +on. If a gem lies under a crate that is still crumbling, the note says **Autopilot: waiting for the +crates** and it waits; when only the exit is left it says **Autopilot: off to the exit**. The usual +auto-move safety applies (never a deadly or unsafe step); if it can only wait for many turns, or +no safe turn exists, it stops with the toast "Autopilot stopped · Your turn". If a coin, charge or gem +lies behind a bomb that is still ticking (or flame still burning), it waits for that too (**Autopilot: waiting for the bombs**) and then fetches it. No way at all to the +exit is the existing boxed-in rule (a life). +*Stopping it* (for that level): **Esc** on the keyboard, **L1** on a controller (its picture is on the note), +or the small **Stop** button on the note (touch and mouse). Turning "Autopilot" off in the menu stops it at once. +*While it walks* the board takes no input: taps, clicks, long-press bombs, the action bar (dimmed), +the keys (WASD, arrows, Q/E/Z/C, action keys, Space, Enter, `?`, Shift + arrows) and the controller's +cursor and face buttons are ignored, and the controller's cursor is hidden. Still working: its +dialogs, the menu and Start (opening one pauses the walk), the fullscreen button, the top bar's +info taps, and looking around (Alt + arrows, Home, the right stick, dragging the board). **How far one move of auto-move goes** (and the first one, when you tap a far tile): a tile next to you is always a step. Otherwise it's a **Run** (2 tiles straight along the way) when you have @@ -191,13 +235,16 @@ Wait) and the power-ups you work yourself (Run, Kick, Throw, Diagonal, the Deton a finger shows how. Enemies have only their picture. With reduced motion turned on, each clip is a still picture of its moment instead. +**Fullscreen button:** a round button in the top bar, between "?" and the menu, shown only where the browser has fullscreen; it shows +"enter" or "leave" by the state (it follows F11 and Esc too). On a phone's portrait bar, where three round buttons would make the bar taller, all three are a little smaller. With a controller, the menu's **Fullscreen** switch does the same, but a controller press is no browser gesture, so a plain browser may refuse: a note says **Press F11, or use the fullscreen button** (the planned desktop wrapper, Q41, will make it work). + **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), **Start in fullscreen** (on by default; the first tap, click or key press switches to fullscreen, because browsers allow it only after such a gesture; a controller press does not count, and iPhone Safari has no fullscreen for pages) and **Finish the level for me**, and its +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 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), **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), **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). @@ -240,7 +287,7 @@ piled up). The top bar shows it too. | **Decoy** | three pawns | Press the charge button, then tap where it goes — **up to 3 tiles away in a straight line**, with nothing in the way (a wall, crate, bomb, enemy or flame stops it, as it stops a slide): a pale copy of you. Enemies that chase you (Seekers, Ghosts, Hunters, Sprinters) go after it instead, **until the level ends** — or until an enemy reaches it or flame burns it, which destroys it. It doesn't block anyone. | | **ICBM** | bomb with a target badge | 3 launches: the charge button fires a bomb as an RPG's (or **I**, then a direction) and it goes off the moment it lands, at any distance. Your bomb limit doesn't count. | | **Flame Pass** | flame with a shield badge | **Works by itself while you carry it:** flame can't hurt you for the next **3 fire hits** (an orange ring round you; the charge button and the top bar count them), level after level; when they are used the charge slot is empty. Enemies still can. A power-up that worked for good until 2026-10-25, when the balance lab found it won the game alone. | -| **Teleport** | the Move figure with an outbox badge | **3 jumps**: press the charge button (or **P**), then tap any empty tile within 3 steps (counting across and down), over anything. **With Run, 4 steps;** **with Diagonal, a square** (a diagonal step counts as one). A power-up until 2026-10-25, for the same reason. | +| **Teleport** | the Move figure with an outbox badge | **3 jumps**: press the charge button (or **P**), then tap any empty tile within 3 steps (counting across and down), over anything (with the keyboard: **P**, then **Shift + arrows** to the tile and **Enter**, or a plain arrow for the nearest one that way). **With Run, 4 steps;** **with Diagonal, a square** (a diagonal step counts as one). A power-up until 2026-10-25, for the same reason. | | **Acid** | bomb with a flask badge | **1 bomb** that burns like a Nuke — **every tile within 2 steps** (across plus down), through crates and round corners — and **cracks every pillar** in that area, diagonal ones included (cracks show on it; since 2026-10-26: until then it burned a plain cross and cracked only a pillar an arm of it ran into). It can catch you too: get 3 steps away. **Any later blast** that reaches a cracked pillar **breaks** it: its tile becomes floor, dust trickles from the ceiling over the 8 tiles round it, and **4 turns later** it falls as **rubble**, for good — nothing gets through rubble, not even flame. Whatever is under it is crushed: enemies (a trap to set), drops, bombs, and **you** — unless you carry a Shield, which pushes you out to an empty tile next to you (with none, nothing saves you; a Star doesn't). If the rubble cuts you off from the exit or from an enemy, the level can't be won: that costs a life at once (**trapped**). The outer wall never breaks. Since 2026-10-25. | A Pierce, Power, Nuke or Acid bomb wears its charge's icon as a small badge. **Bomb** always puts down a @@ -381,7 +428,7 @@ go: **Blast 2 → 4 (max 5)**. | **Diagonal** | the Move figure with a diagonal-arrows badge | **Move** can also step diagonally, where one of the two tiles beside the step is open — not between two blocked tiles (no squeezing since 2026-10-25; the lab found the squeeze was most of its strength) (or **Q E Z C**: up-left, up-right, down-left, down-right); auto-move cuts corners with it. With the Teleport charge, it widens Teleport's reach to a square. A charge until 2026-10-23. | | **Slide** | the Move figure with a two-way-arrow badge | **Move** can also slide in a straight line until the next tile is a wall, crate or bomb — **tap the tile where it stops** (or **L**, then a direction) — picking up whatever it passes. It can't stop early: an enemy on the way is crashed into (a life lost), and flame on the way burns (unless Flame Pass or a Star). The open exit stops it. | | **RPG** | bomb with a forward-arrow badge | **Bomb** can also fire a bomb in a straight line (or **F**, then a direction): it flies until the next tile is a wall, crate, bomb or enemy and lands just before it, then counts down as usual. | -| **Detonator** | bomb with a ! badge | Your bombs pulse with a red glow: **tap one** and it goes off this turn (or **X**, then tap it). No button. | +| **Detonator** | bomb with a ! badge | Your bombs pulse with a red glow: **tap one** and it goes off this turn (or **X**, then tap it; with the keyboard, **Shift + arrows** to the bomb and **X** or **Enter**). No button. | | **Proximity alert** | bomb with a warning-sign badge | Your bombs go off the turn an enemy is **right next to** one — one that walks up, or one already there when you place it — wherever you are standing. (Until 2026-10-25, anywhere in its reach.) The danger overlay shows it ahead, from the enemies' arrows. | | **Crate Pass** | crate with an ✕ badge | Walk through crates. | | **Bomb Pass** | bomb with a shield badge | Walk over bombs. | @@ -505,17 +552,62 @@ what it does. | **Spikes** | 8 | Up one turn, down the next. Up, they cost a life to whoever stands on them (they show in the danger overlay when up). Enemies never step on them. | | **Oil barrel** | 11 | A crate with a warning sign: it blocks like a crate. Flame **bursts** it: oil spills on it and the floor round it, and the **next turn** it explodes with a blast of 2, lighting the spill. It isn't your bomb: it doesn't count to your limit, and the Detonator can't set it off. | +### Keyboard + +The whole game plays with the keyboard alone. There are two ways to aim, and they mix freely: + +- **Plain arrows / W A S D** (and **Q E Z C** diagonally, with Diagonal) take the nearest tile that + way for the action: with nothing picked, a step (a kick into a bomb); after **M**, **B**, **R**, + **K**, **T** ... the nearest tile along that line it can use ("**B**, then a direction"). Never a move that would cost a life. +- **Shift + arrows (or Shift + W A S D) wake the keyboard cursor**, the controller's tile-first cursor (see + *Controller*): a dashed square on your own tile that hops between the blue tiles, any of them, not + only along your four lines, including the faded blue tiles auto-move can walk to (**M** or Enter there: "Walk here"); a label over the action bar says what each key does on that tile, and a + faded ghost shows the first result. The press that wakes it only shows it. While it shows, + **M**, **B**, **V** (Move, Bomb, Charge), **R K T P F I L X** (an action that is possible there) and **Space** + (Wait, only on your own tile) act **on the cursor's tile**; **Enter** does the tile's first action (or confirms the + action you picked); **Esc** backs out one step (what is picked or the flag, then the cursor back to you, + then puts it away). It starts on you each turn, or on your destination while auto-move is walking you there ("Keep walking"). A plain arrow, the mouse or a tap puts it away. +- **?** is "What is that?": it aims at the nearest tile with something to explain; **arrows or Shift + arrows** hop between those tiles, + **Enter** or **?** again explains the aimed one (its dialog has **Walk there** for a far tile), **Esc** stops asking. +- **Alt + arrows** pan the view (it repeats while held) and **Home** brings it back to you. + +| Key | Does | +|---|---| +| **M**, **B**, **V**, **Space** | Move, Bomb, Charge, Wait (on the cursor's tile if it shows, else pick, then a direction) | +| **R K T G P F I L X** | Run, Kick, Throw, Diagonal, Teleport, RPG, ICBM, Slide, Detonate: pick it, then a direction or the cursor | +| **Arrows, W A S D**; **Q E Z C** | The nearest tile that way; a diagonal step (with Diagonal) | +| **Shift + arrows** | Wake the keyboard cursor / hop it; hop the "What is that?" aim | +| **Enter** | The cursor tile's first action, or confirm the picked action or the aim; presses the end-of-level banner's button; in a dialog, its green (or only) button | +| **Esc** | Back out one step; with nothing to back out, the menu; closes a dialog | +| **Tab**, **Space** / **Enter** | In a dialog: move between its buttons and press one (Enter alone presses the green one) | +| **?** | "What is that?" (**Enter** or **?** again explains the aim) | +| **Alt + arrows**, **Home** | Pan the view, follow you again | + +Enter and Space belong to a button that has the focus (it presses it) and leave the game alone. + ### Controller A gamepad with the standard layout (a PlayStation, Xbox or Nintendo pad, over USB or Bluetooth, in a browser) plays the board **tile first**. While the pad is in use a dashed square (the cursor) is always on -the board, resting on your own tile at the start of every turn, level and run and after a dialog; -every tile you can do something on lights up blue, and the **d-pad** moves the square between them. A line over the action +the board, starting on your own tile each turn, level and run and after a dialog (or on your **destination** while auto-move is walking you there, so Go, "Keep walking", is at hand); +every tile you can do something on lights up blue, and the **d-pad** moves the square between them. Tiles that only +**auto-move** reaches are a **faded blue**: the square hops onto them too, and Move/Go there walks (label "Walk here · N steps"; the other face buttons are greyed). **Move/Go works on any tile you can walk to, even when it is also a bomb or charge target** (an RPG, throw or ICBM landing, say): the bright tile then offers both, Move walks there and Bomb fires. Holding the d-pad +hops fast (first repeat after 250 ms, then every 75 ms), so crossing a board takes a moment. A line over the action bar lists what the tile offers, one line per button ("Cross Move to this tile", "Square Place a bomb on this tile", "Circle Wait a turn"), and a faded ghost shows the result of the first one. The four face buttons are the action bar's: **Move/Go** (Cross, A), **Bomb** (Square, X), **Charge** (Triangle, Y) and **Wait** (Circle, B, only on your own tile), with their pictures on the buttons -while the pad is in use. Pressing one **does that action on the cursor's tile at once**. On the +while the pad is in use. The mapping is by position (bottom Move, right Wait, left Bomb, top Charge) with one +exception, a **Nintendo pad**, where A is the "go ahead" button and B means "back": **Move/Go is A, the right +button, and Wait is B, the bottom one**; Bomb (Y, left) and Charge (X, top) stay where they are (`FACE_ACTIONS` in +`ui/padMap.ts`; the pictures, the dialogs' confirm and dismiss buttons, the hold-to-play button and the demo's virtual +controller follow). **Hold the button to confirm it** (Q59): a ring round its picture fills clockwise from 12 o'clock +in half a second, and **the action happens the moment the ring is full**, while the button is still down (the ring +stays full and green until you let go). That button is then used up: it does not act again, and it starts no new +hold in whatever the action opened (a dialog, the next level, the game after the demo) until you have let go of it. +Letting go early (or the pad leaving, or the window losing focus) does nothing. One face button is held at a +time; another one pressed meanwhile is ignored. The d-pad, LB, RB, View, R3 and Start stay instant, and so do touch, mouse and keyboard. +When it fires, it **does that action on the cursor's tile**. On the action bar, the buttons that cannot be used on that tile are greyed (pressing one just shakes it), and one that would cost a life gets a red outline and a warning mark; it is still done if you press it. **LB** puts "What is that?" away, then anything left picked, and brings the cursor back to your @@ -523,13 +615,13 @@ own tile. A small legend above the line shows the L1/LB and R1/RB pictures and w "Cancel" or "Stop asking", dimmed when L1 has nothing to undo; "What is that?"). **RB or View** is "What is that?": it aims at what can be explained, the d-pad jumps between those tiles, and Cross (or RB again) explains the tile; LB leaves it. **Start** opens the menu (and closes it), **R3** brings the view back to you, and the **right stick** looks around the -board. The left stick does nothing for now. A key or the mouse hands the board back to the keyboard -and mouse; the next pad press brings the cursor back on your tile (a face button pressed then only -shows it, it does not act). +board. The left stick does nothing for now. A key, a click or a tap hands the board back to the keyboard +and mouse (just moving the mouse does not); the next pad press brings the cursor back on your tile (a face button pressed then only +shows it, it does not act, and shows no ring). Dialogs and the screens after a death, a level or a run are played by pad too, so a whole run needs no keyboard or mouse. A dialog's buttons sit on the face buttons, with their pictures on them: the -main one (Choose in a gem's choice, Walk there, Take in a charge swap) on **Cross**, the others, left to right, on +main one (Choose in a gem's choice, Walk there, Take in a charge swap) on **Cross** (on a Nintendo pad: **A**, the right-hand button, and the one that leaves is **B**, the bottom one), the others, left to right, on **Triangle** then **Square**, and the one that leaves (Close, Cancel, Back, Got it) on **Circle**, which closes any dialog that can be closed. Where that does not fit (the menu, the almanac, New run, the changes list) a **d-pad** selector with a yellow outline moves over the buttons, links and @@ -538,7 +630,13 @@ in New run), and Circle still presses the one that leaves. In a gem's choice or cards (which selects them and plays their clip) and Cross takes the selected one (in the shop, Cross buys and Triangle and Square are Restock and Next level). **LB and RB** (and View) switch the almanac's tabs, and the **right stick** scrolls a long dialog. The seed field in New run is skipped (type it with a -keyboard). The banner's buttons are on Cross, Triangle and Square the same way. +keyboard). The banner's buttons are on Cross, Triangle and Square the same way. In dialogs and on +the banner the face buttons are held to confirm too, with the ring round the button's picture (the picture buttons; in a dialog with a selector the selected item shows the confirm button's picture too, a switch, button, link or card alike, and the ring fills round it; the item acts when the ring is full). Let go too early, a toast says "Hold to choose" (once per dialog). In the almanac the tabs before and after the shown one carry the LB / RB pictures. + +The demo and the clips in dialogs (the almanac's, the gem choice's, the shop's) show the controller +while a pad is in use: the demo and the clips about a move, a bomb or a power-up show a small virtual +controller lighting up the buttons and a dashed cursor, instead of the finger (see **The demo**). A +clip changes with the input at its next loop. ## Running it @@ -643,7 +741,7 @@ Art: [New Platformer Pack](https://kenney.nl/assets/new-platformer-pack) 1.1, [Game Icons](https://kenney.nl/assets/game-icons) and [Board Game Icons](https://kenney.nl/assets/board-game-icons) and [Crosshair Pack](https://kenney.nl/assets/crosshair-pack) (the controller's target square) and -[Input Prompts](https://kenney.nl/assets/input-prompts) (the controller buttons on the action bar, in dialogs and on the banner), all by +[Input Prompts](https://kenney.nl/assets/input-prompts) (the controller buttons on the action bar, in dialogs and on the banner, and the d-pad, right stick and Start / Options / Plus pictures of the demo's virtual controller), all by [Kenney](https://www.kenney.nl), under [CC0](http://creativecommons.org/publicdomain/zero/1.0/) (public domain), the [Splat Pack](https://kenney.nl/assets/splat-pack) (the oil spills) and [Foliage Sprites](https://kenney.nl/assets/foliage-sprites) (the bushes). Kenney doesn't require credit, but the pack was chosen deliberately and the game @@ -653,7 +751,7 @@ The files used are copied, unchanged and under their original file names, into [`assets/kenney/`](assets/kenney/), [`assets/kenney-icons/`](assets/kenney-icons/) and [`assets/kenney-board-icons/`](assets/kenney-board-icons/) (Game Icons and Board Game Icons), [`assets/kenney-crosshair/`](assets/kenney-crosshair/) (one dashed square), -[`assets/kenney-input-prompts/`](assets/kenney-input-prompts/) (eighteen button pictures), +[`assets/kenney-input-prompts/`](assets/kenney-input-prompts/) (69 button and key pictures: the controller buttons and, from Keyboard & Mouse, 15 keys of the keyboard demo's key overlay), [`assets/kenney-ui/`](assets/kenney-ui/) (keeping the pack's colour folders), [`assets/kenney-ui-rpg/`](assets/kenney-ui-rpg/) and [`assets/kenney-splat/`](assets/kenney-splat/) (a few of the Splat Pack's splats, scaled down to @@ -662,10 +760,17 @@ shapes, which `tools/compose-icons.sh` tints green and outlines into `assets/com `canopy_*`), each with its pack's licence file. One exception: the demo's pointing hand, `assets/kenney-ui-rpg/cursorHand_beige.svg`, is the RPG Expansion's `cursorHand_beige` cut out of the pack's vector sheet (`Vector/uipack_rpg_vector.svg`: just its three shapes, unchanged), since the PNG is only 27 px -across. They +across. The mouse demo's pointer is `cursor_none.png` from Kenney's [Cursor Pack](https://kenney.nl/assets/cursor-pack) (`assets/kenney-cursor/`), and the fullscreen button's two icons are Game Icons' `larger` and `smaller` (black, 2x). They are not covered by this project's MIT licence. Who made every picture and sound, and what Claude drew by hand, is listed in [docs/art-provenance.md](docs/art-provenance.md). +The controller line drawings in the demo's virtual controller are OpenGameArt's +[Controller style 1](https://opengameart.org/content/controller-style-1), +[3](https://opengameart.org/content/controller-style-3) and +[5](https://opengameart.org/content/controller-style-5) by [xUMR](https://opengameart.org/users/xumr), under +[CC BY 4.0](https://creativecommons.org/licenses/by/4.0/); scaled down and trimmed; they are in +[`assets/controller-art/`](assets/controller-art/). + Sounds: the New Platformer Pack's 10 effects, in [`assets/kenney/sounds/`](assets/kenney/sounds/), and the UI Pack's `tap-a` and `switch-a`, in [`assets/kenney-ui/Sounds/`](assets/kenney-ui/Sounds/), under their own names but converted from the packs' `.ogg` to `.mp3`, since older iPhones can't diff --git a/assets/controller-art/LICENSE.txt b/assets/controller-art/LICENSE.txt new file mode 100644 index 0000000..15112f4 --- /dev/null +++ b/assets/controller-art/LICENSE.txt @@ -0,0 +1,13 @@ +Controller line drawings used by the demo's virtual controller (src/ui/padOverlay.ts). + +Source (OpenGameArt.org): + padart-xbox.png Controller style 1 https://opengameart.org/content/controller-style-1 + padart-nintendo.png Controller style 3 https://opengameart.org/content/controller-style-3 + padart-playstation.png Controller style 5 https://opengameart.org/content/controller-style-5 + +Author: xUMR (https://opengameart.org/users/xumr) +Licence: Creative Commons Attribution 4.0 International (CC BY 4.0) + https://creativecommons.org/licenses/by/4.0/ + +Changes made: scaled down to 1200 px wide, trimmed to the drawing plus a small margin and saved as +grey + alpha PNG, by tools/make-pad-layouts.py. The drawings are otherwise unchanged. diff --git a/assets/controller-art/padart-nintendo.png b/assets/controller-art/padart-nintendo.png new file mode 100644 index 0000000..62097d7 Binary files /dev/null and b/assets/controller-art/padart-nintendo.png differ diff --git a/assets/controller-art/padart-playstation.png b/assets/controller-art/padart-playstation.png new file mode 100644 index 0000000..4483de0 Binary files /dev/null and b/assets/controller-art/padart-playstation.png differ diff --git a/assets/controller-art/padart-xbox.png b/assets/controller-art/padart-xbox.png new file mode 100644 index 0000000..40c02af Binary files /dev/null and b/assets/controller-art/padart-xbox.png differ diff --git a/assets/kenney-cursor/License.txt b/assets/kenney-cursor/License.txt new file mode 100644 index 0000000..7c3ee64 --- /dev/null +++ b/assets/kenney-cursor/License.txt @@ -0,0 +1,22 @@ + + + Cursor Pack (1.1) + + Created/distributed by Kenney (www.kenney.nl) + Creation date: 05-06-2024 + + ------------------------------ + + License: (Creative Commons Zero, CC0) + http://creativecommons.org/publicdomain/zero/1.0/ + + This content is free to use in personal, educational and commercial projects. + Support us by crediting Kenney or www.kenney.nl (this is not mandatory) + + ------------------------------ + + Donate: http://support.kenney.nl + Patreon: http://patreon.com/kenney/ + + Follow on Twitter for updates: + http://twitter.com/KenneyNL \ No newline at end of file diff --git a/assets/kenney-cursor/cursor_none.png b/assets/kenney-cursor/cursor_none.png new file mode 100644 index 0000000..1713c14 Binary files /dev/null and b/assets/kenney-cursor/cursor_none.png differ diff --git a/assets/kenney-icons/larger.png b/assets/kenney-icons/larger.png new file mode 100644 index 0000000..a1dd9fe Binary files /dev/null and b/assets/kenney-icons/larger.png differ diff --git a/assets/kenney-icons/smaller.png b/assets/kenney-icons/smaller.png new file mode 100644 index 0000000..bf24535 Binary files /dev/null and b/assets/kenney-icons/smaller.png differ diff --git a/assets/kenney-input-prompts/keyboard_alt.png b/assets/kenney-input-prompts/keyboard_alt.png new file mode 100644 index 0000000..0ac1740 Binary files /dev/null and b/assets/kenney-input-prompts/keyboard_alt.png differ diff --git a/assets/kenney-input-prompts/keyboard_arrow_down.png b/assets/kenney-input-prompts/keyboard_arrow_down.png new file mode 100644 index 0000000..1b4952e Binary files /dev/null and b/assets/kenney-input-prompts/keyboard_arrow_down.png differ diff --git a/assets/kenney-input-prompts/keyboard_arrow_left.png b/assets/kenney-input-prompts/keyboard_arrow_left.png new file mode 100644 index 0000000..4926a04 Binary files /dev/null and b/assets/kenney-input-prompts/keyboard_arrow_left.png differ diff --git a/assets/kenney-input-prompts/keyboard_arrow_right.png b/assets/kenney-input-prompts/keyboard_arrow_right.png new file mode 100644 index 0000000..12bfb86 Binary files /dev/null and b/assets/kenney-input-prompts/keyboard_arrow_right.png differ diff --git a/assets/kenney-input-prompts/keyboard_arrow_up.png b/assets/kenney-input-prompts/keyboard_arrow_up.png new file mode 100644 index 0000000..88a73f2 Binary files /dev/null and b/assets/kenney-input-prompts/keyboard_arrow_up.png differ diff --git a/assets/kenney-input-prompts/keyboard_arrows.png b/assets/kenney-input-prompts/keyboard_arrows.png new file mode 100644 index 0000000..07cb4bc Binary files /dev/null and b/assets/kenney-input-prompts/keyboard_arrows.png differ diff --git a/assets/kenney-input-prompts/keyboard_b.png b/assets/kenney-input-prompts/keyboard_b.png new file mode 100644 index 0000000..7fdbc04 Binary files /dev/null and b/assets/kenney-input-prompts/keyboard_b.png differ diff --git a/assets/kenney-input-prompts/keyboard_enter.png b/assets/kenney-input-prompts/keyboard_enter.png new file mode 100644 index 0000000..30d18ab Binary files /dev/null and b/assets/kenney-input-prompts/keyboard_enter.png differ diff --git a/assets/kenney-input-prompts/keyboard_escape.png b/assets/kenney-input-prompts/keyboard_escape.png new file mode 100644 index 0000000..e1d73c0 Binary files /dev/null and b/assets/kenney-input-prompts/keyboard_escape.png differ diff --git a/assets/kenney-input-prompts/keyboard_home.png b/assets/kenney-input-prompts/keyboard_home.png new file mode 100644 index 0000000..d7bd6ae Binary files /dev/null and b/assets/kenney-input-prompts/keyboard_home.png differ diff --git a/assets/kenney-input-prompts/keyboard_m.png b/assets/kenney-input-prompts/keyboard_m.png new file mode 100644 index 0000000..b264115 Binary files /dev/null and b/assets/kenney-input-prompts/keyboard_m.png differ diff --git a/assets/kenney-input-prompts/keyboard_question.png b/assets/kenney-input-prompts/keyboard_question.png new file mode 100644 index 0000000..6d33822 Binary files /dev/null and b/assets/kenney-input-prompts/keyboard_question.png differ diff --git a/assets/kenney-input-prompts/keyboard_shift.png b/assets/kenney-input-prompts/keyboard_shift.png new file mode 100644 index 0000000..f21512f Binary files /dev/null and b/assets/kenney-input-prompts/keyboard_shift.png differ diff --git a/assets/kenney-input-prompts/keyboard_space.png b/assets/kenney-input-prompts/keyboard_space.png new file mode 100644 index 0000000..310d37a Binary files /dev/null and b/assets/kenney-input-prompts/keyboard_space.png differ diff --git a/assets/kenney-input-prompts/keyboard_v.png b/assets/kenney-input-prompts/keyboard_v.png new file mode 100644 index 0000000..0cab778 Binary files /dev/null and b/assets/kenney-input-prompts/keyboard_v.png differ diff --git a/assets/kenney-input-prompts/playstation5_button_options.png b/assets/kenney-input-prompts/playstation5_button_options.png new file mode 100644 index 0000000..d547265 Binary files /dev/null and b/assets/kenney-input-prompts/playstation5_button_options.png differ diff --git a/assets/kenney-input-prompts/playstation_dpad.png b/assets/kenney-input-prompts/playstation_dpad.png new file mode 100644 index 0000000..7dad022 Binary files /dev/null and b/assets/kenney-input-prompts/playstation_dpad.png differ diff --git a/assets/kenney-input-prompts/playstation_dpad_down.png b/assets/kenney-input-prompts/playstation_dpad_down.png new file mode 100644 index 0000000..b48421c Binary files /dev/null and b/assets/kenney-input-prompts/playstation_dpad_down.png differ diff --git a/assets/kenney-input-prompts/playstation_dpad_left.png b/assets/kenney-input-prompts/playstation_dpad_left.png new file mode 100644 index 0000000..e7741d0 Binary files /dev/null and b/assets/kenney-input-prompts/playstation_dpad_left.png differ diff --git a/assets/kenney-input-prompts/playstation_dpad_right.png b/assets/kenney-input-prompts/playstation_dpad_right.png new file mode 100644 index 0000000..efa028a Binary files /dev/null and b/assets/kenney-input-prompts/playstation_dpad_right.png differ diff --git a/assets/kenney-input-prompts/playstation_dpad_up.png b/assets/kenney-input-prompts/playstation_dpad_up.png new file mode 100644 index 0000000..08ea054 Binary files /dev/null and b/assets/kenney-input-prompts/playstation_dpad_up.png differ diff --git a/assets/kenney-input-prompts/playstation_stick_r.png b/assets/kenney-input-prompts/playstation_stick_r.png new file mode 100644 index 0000000..b42f84e Binary files /dev/null and b/assets/kenney-input-prompts/playstation_stick_r.png differ diff --git a/assets/kenney-input-prompts/playstation_stick_r_down.png b/assets/kenney-input-prompts/playstation_stick_r_down.png new file mode 100644 index 0000000..12d4ebb Binary files /dev/null and b/assets/kenney-input-prompts/playstation_stick_r_down.png differ diff --git a/assets/kenney-input-prompts/playstation_stick_r_left.png b/assets/kenney-input-prompts/playstation_stick_r_left.png new file mode 100644 index 0000000..af62330 Binary files /dev/null and b/assets/kenney-input-prompts/playstation_stick_r_left.png differ diff --git a/assets/kenney-input-prompts/playstation_stick_r_press.png b/assets/kenney-input-prompts/playstation_stick_r_press.png new file mode 100644 index 0000000..fb23e21 Binary files /dev/null and b/assets/kenney-input-prompts/playstation_stick_r_press.png differ diff --git a/assets/kenney-input-prompts/playstation_stick_r_right.png b/assets/kenney-input-prompts/playstation_stick_r_right.png new file mode 100644 index 0000000..14d46de Binary files /dev/null and b/assets/kenney-input-prompts/playstation_stick_r_right.png differ diff --git a/assets/kenney-input-prompts/playstation_stick_r_up.png b/assets/kenney-input-prompts/playstation_stick_r_up.png new file mode 100644 index 0000000..c28998a Binary files /dev/null and b/assets/kenney-input-prompts/playstation_stick_r_up.png differ diff --git a/assets/kenney-input-prompts/switch_button_plus.png b/assets/kenney-input-prompts/switch_button_plus.png new file mode 100644 index 0000000..ed88e3e Binary files /dev/null and b/assets/kenney-input-prompts/switch_button_plus.png differ diff --git a/assets/kenney-input-prompts/switch_dpad.png b/assets/kenney-input-prompts/switch_dpad.png new file mode 100644 index 0000000..4d8fd7c Binary files /dev/null and b/assets/kenney-input-prompts/switch_dpad.png differ diff --git a/assets/kenney-input-prompts/switch_dpad_down.png b/assets/kenney-input-prompts/switch_dpad_down.png new file mode 100644 index 0000000..8462aca Binary files /dev/null and b/assets/kenney-input-prompts/switch_dpad_down.png differ diff --git a/assets/kenney-input-prompts/switch_dpad_left.png b/assets/kenney-input-prompts/switch_dpad_left.png new file mode 100644 index 0000000..bd7f2c6 Binary files /dev/null and b/assets/kenney-input-prompts/switch_dpad_left.png differ diff --git a/assets/kenney-input-prompts/switch_dpad_right.png b/assets/kenney-input-prompts/switch_dpad_right.png new file mode 100644 index 0000000..0b0f4bd Binary files /dev/null and b/assets/kenney-input-prompts/switch_dpad_right.png differ diff --git a/assets/kenney-input-prompts/switch_dpad_up.png b/assets/kenney-input-prompts/switch_dpad_up.png new file mode 100644 index 0000000..e9f4571 Binary files /dev/null and b/assets/kenney-input-prompts/switch_dpad_up.png differ diff --git a/assets/kenney-input-prompts/switch_stick_r.png b/assets/kenney-input-prompts/switch_stick_r.png new file mode 100644 index 0000000..b42f84e Binary files /dev/null and b/assets/kenney-input-prompts/switch_stick_r.png differ diff --git a/assets/kenney-input-prompts/switch_stick_r_down.png b/assets/kenney-input-prompts/switch_stick_r_down.png new file mode 100644 index 0000000..12d4ebb Binary files /dev/null and b/assets/kenney-input-prompts/switch_stick_r_down.png differ diff --git a/assets/kenney-input-prompts/switch_stick_r_left.png b/assets/kenney-input-prompts/switch_stick_r_left.png new file mode 100644 index 0000000..af62330 Binary files /dev/null and b/assets/kenney-input-prompts/switch_stick_r_left.png differ diff --git a/assets/kenney-input-prompts/switch_stick_r_press.png b/assets/kenney-input-prompts/switch_stick_r_press.png new file mode 100644 index 0000000..fb23e21 Binary files /dev/null and b/assets/kenney-input-prompts/switch_stick_r_press.png differ diff --git a/assets/kenney-input-prompts/switch_stick_r_right.png b/assets/kenney-input-prompts/switch_stick_r_right.png new file mode 100644 index 0000000..14d46de Binary files /dev/null and b/assets/kenney-input-prompts/switch_stick_r_right.png differ diff --git a/assets/kenney-input-prompts/switch_stick_r_up.png b/assets/kenney-input-prompts/switch_stick_r_up.png new file mode 100644 index 0000000..c28998a Binary files /dev/null and b/assets/kenney-input-prompts/switch_stick_r_up.png differ diff --git a/assets/kenney-input-prompts/xbox_button_menu.png b/assets/kenney-input-prompts/xbox_button_menu.png new file mode 100644 index 0000000..824ea18 Binary files /dev/null and b/assets/kenney-input-prompts/xbox_button_menu.png differ diff --git a/assets/kenney-input-prompts/xbox_dpad.png b/assets/kenney-input-prompts/xbox_dpad.png new file mode 100644 index 0000000..5f134dc Binary files /dev/null and b/assets/kenney-input-prompts/xbox_dpad.png differ diff --git a/assets/kenney-input-prompts/xbox_dpad_down.png b/assets/kenney-input-prompts/xbox_dpad_down.png new file mode 100644 index 0000000..c7bebfe Binary files /dev/null and b/assets/kenney-input-prompts/xbox_dpad_down.png differ diff --git a/assets/kenney-input-prompts/xbox_dpad_left.png b/assets/kenney-input-prompts/xbox_dpad_left.png new file mode 100644 index 0000000..ae0336b Binary files /dev/null and b/assets/kenney-input-prompts/xbox_dpad_left.png differ diff --git a/assets/kenney-input-prompts/xbox_dpad_right.png b/assets/kenney-input-prompts/xbox_dpad_right.png new file mode 100644 index 0000000..5803562 Binary files /dev/null and b/assets/kenney-input-prompts/xbox_dpad_right.png differ diff --git a/assets/kenney-input-prompts/xbox_dpad_up.png b/assets/kenney-input-prompts/xbox_dpad_up.png new file mode 100644 index 0000000..d807037 Binary files /dev/null and b/assets/kenney-input-prompts/xbox_dpad_up.png differ diff --git a/assets/kenney-input-prompts/xbox_stick_r.png b/assets/kenney-input-prompts/xbox_stick_r.png new file mode 100644 index 0000000..b42f84e Binary files /dev/null and b/assets/kenney-input-prompts/xbox_stick_r.png differ diff --git a/assets/kenney-input-prompts/xbox_stick_r_down.png b/assets/kenney-input-prompts/xbox_stick_r_down.png new file mode 100644 index 0000000..12d4ebb Binary files /dev/null and b/assets/kenney-input-prompts/xbox_stick_r_down.png differ diff --git a/assets/kenney-input-prompts/xbox_stick_r_left.png b/assets/kenney-input-prompts/xbox_stick_r_left.png new file mode 100644 index 0000000..af62330 Binary files /dev/null and b/assets/kenney-input-prompts/xbox_stick_r_left.png differ diff --git a/assets/kenney-input-prompts/xbox_stick_r_press.png b/assets/kenney-input-prompts/xbox_stick_r_press.png new file mode 100644 index 0000000..fb23e21 Binary files /dev/null and b/assets/kenney-input-prompts/xbox_stick_r_press.png differ diff --git a/assets/kenney-input-prompts/xbox_stick_r_right.png b/assets/kenney-input-prompts/xbox_stick_r_right.png new file mode 100644 index 0000000..14d46de Binary files /dev/null and b/assets/kenney-input-prompts/xbox_stick_r_right.png differ diff --git a/assets/kenney-input-prompts/xbox_stick_r_up.png b/assets/kenney-input-prompts/xbox_stick_r_up.png new file mode 100644 index 0000000..c28998a Binary files /dev/null and b/assets/kenney-input-prompts/xbox_stick_r_up.png differ diff --git a/decisions.md b/decisions.md index 52c0c45..9cfc5c6 100644 --- a/decisions.md +++ b/decisions.md @@ -2552,6 +2552,8 @@ class. Not built yet. ### 2026-11-01 — The level finishes itself once the last enemy is gone +**Superseded 2026-11-02 by "Autopilot replaces the hoover (Q73)"; the `hoover` action no longer exists.** + User request: on a controller, tapping each coin, gem and charge and then the exit was a pain. Decided with the user: (1) the gathering is a new sim action `hoover` taking no turn (like `buy`), so runs record and replay it and old records are untouched (no SIM_VERSION bump); (2) the walk to @@ -2571,3 +2573,358 @@ F11 is respected). A controller press is not a gesture, so a pad-only player sta touching the screen or a key. A setting, "Start in fullscreen" (on by default), turns it off. No in-game toggle key (F is the RPG bomb; F11 is the browser's own). The Electron build (Q30) will simply start fullscreen. iPhone Safari offers no Fullscreen API for pages, so nothing happens there. + +### 2026-11-02 — Fullscreen only with a controller; a button; a menu switch (Q60) + +User request, amending 2026-11-01. Decided: (1) the first-gesture auto-fullscreen now happens only +while a controller is connected (`shouldAutoFullscreen`, `ui/fullscreenRule.ts`, pure): the pad +player has no pointer to press a button with, a phone or mouse player does, and being pushed into +fullscreen annoyed them; "connected" is a pad in `navigator.getGamepads()` or one the pad input has +seen (Chrome lists a pad only after its first press); the "Start in fullscreen" setting stays and +is worded for it; (2) a visible fullscreen toggle in the top bar, only where `document.fullscreenEnabled`, +following `fullscreenchange` (so F11 and Esc update it); on a phone's portrait bar three stacked +round buttons would grow the bar, so all three shrink to 31 px then (the bar's height is +unchanged); icons are Game Icons `larger` / `smaller`; (3) for a controller, a "Fullscreen" switch +in the menu's Settings, no pad button (every one is taken); a pad press is no browser gesture, so +in a plain browser the request can be refused: the page calls it anyway and, if it rejects, shows +"Press F11, or use the fullscreen button". The Electron wrapper (Q41) will make it work. + +### 2026-11-02 — No automatic fullscreen; the cursor stays on the destination while walking (Q70) + +User request after testing. Decided: (1) the game never goes fullscreen by itself. This supersedes +2026-11-01 "Fullscreen from the first tap, click or key" and the Q60 rule that made it happen only +with a controller (`shouldAutoFullscreen` and `ui/fullscreenRule.ts` are gone, with the "Start in +fullscreen" setting and its stored value, which is simply no longer read). What stays from Q60: the +top bar's fullscreen button, the menu's "Fullscreen" switch (with the toast "Press F11, or use the +fullscreen button" when a pad press is refused, since it is no browser gesture) and the +`fullscreenchange` syncing; (2) bug report: with a pad, after the first step of an auto-move walk the +cursor went back to the player's tile, where only Wait was offered, though the route was still drawn. +Now the cursor starts each turn on the destination while a walk is on (`cursorStart` in +`ui/padTiles.ts`, pure; `ensureCursor` in `main.ts`; the destination is a faded candidate, label +"Keep walking · N steps"), so Go continues at once; when the walk ends (arrived, cancelled, blocked, +new level) it starts on the player again. The keyboard cursor shares the code. + +### 2026-11-02 — The demo's input button (Q61) + +User request: a demo button cycling touch, mouse, controller (ps5, xbox, switch, generic). Decided: +(1) the pick overrides detection for the demo only, session only (`demoForced`, not stored), and +the scene restarts for it (a switch between the two scene lists is the existing between-scenes +switch, from the button it is immediate); (2) what clears it: the demo ending, or the controller setting changing (one plugged in or unplugged, +`dropDemoPick`); amended the same day at the user's request, an earlier rule also dropped a touch or +mouse pick on a real pad press. Nothing else clears it: no mouse move, click, tap or button press, so +the button keeps cycling and the pick stays wherever the user stopped (a mouse player must be able to +watch the controller demos, and a pad player the touch ones); only holding the confirm button, which +ends the demo, leaves it; (3) mouse is the touch scenes with a pointer in the finger's place and "Click anywhere +to play"; the pointer is `cursor_none` from Kenney's Cursor Pack (assets/kenney-cursor; user supplied the +pack, 2026-11-02, replacing a stopgap sword); the touch +captions still say "finger"; (4) the Q59 hint "Hold {move} to play" and the pad family drawn in +captions and on the virtual controller follow the effective input, not the real pad; (5) clips +keep following the real input. No rules change (no SIM_VERSION bump). + +### 2026-11-02 — Controller demos: the pad's own demo and clips (Q58) + +User request: with a pad in use, the demo and the dialogs' clips show the controller, not the finger. +Decided: (1) the virtual controller (`ui/padOverlay.ts`) is CSS shapes with Kenney Input Prompts +glyphs on them, no drawn art (superseded by the later Q63 entries: it is now a line drawing from OpenGameArt) (art-provenance #16 notes the outline as layout, like the old-TV look); +(2) the input is switched only between scenes (and a clip at its next loop), never mid-script, so +a scene is never half finger and half pad; (3) switching to the pad starts its list at scene 1, and +switching back resumes the touch list where it was left: the two lists keep separate saved places; +(4) the pad's list is its own scenes (cursor, L1, R1, the stick, Start) plus the controls and +gameplay scenes without the touch-only ones (long-press bomb, auto-move, the top bar, "What is +that?" by tap); (5) `chargeSwap` and any dialog choice are deferred through `PAD_LATER` +(`sim/demo.ts`): the pad cannot show a dialog's answer yet, and `padPlan` returns null for a choice, +a pick, or a tile where no single face button does the action; (6) the stick scene only lights the +stick, because the demo shows a room whole and there is nothing to scroll; (7) the Start scene +opens the real menu over the scene, showing the run's own state, not the scene's; (8) the turn +plan (`ui/padDemo.ts`) is pure and works from the concrete action, so every plain move, bomb, charge +and wait has a pad version without per-scene scripts. No rules change (no SIM_VERSION bump). + +### 2026-11-02 — Hold to confirm on the pad (Q59) + +User request: introduce holding a button to confirm it, with a ring filling clockwise from 12 +o'clock; the action happens on release and only if the ring filled; on the demo screen the player +holds the bottom face button to play. Decided: (1) pad only: touch, mouse and keyboard already have +deliberate gestures, and the pad's face buttons are easy to hit by accident; (2) the pad's four +face buttons only (board actions, the face-button choices in dialogs and on the banner, and the +demo's start); the d-pad, shoulders, View, R3 and Start stay instant, since they move, cancel or +look and cost nothing to hit by mistake; (3) 0.5 s as one constant, `HOLD_SECONDS` in +`ui/padHold.ts`, long enough to be deliberate and short enough not to slow a turn; (4) fire on +release, not on completion, so the ring filling is a warning that can still be backed out of by +letting go early, which does nothing; (5) one hold at a time, a second face button is ignored +until the first is released; a swapped or unplugged pad and a window that loses focus cancel; (6) +in the demo the bottom button (Cross/A, Move) is the way in and other face buttons do nothing, so a +stray press cannot throw the demo away; (7) the waking press (see 2026-11-01) still only shows the +cursor, with no ring; (8) the ring is a CSS conic gradient, no art, and one floating element is +moved over the glyph (`ui/holdRing.ts`, inside the open `<dialog>` because it is in the top +layer); it does not animate, so reduced motion changes nothing. The demo scenes' own scripted +presses call the game directly and show no ring. No rules change (no SIM_VERSION bump). + +### 2026-11-02 — Nintendo pads: A confirms, B backs out (amends the 2026-11-01 position-only mapping) + +User request: on a Nintendo Switch pad, A is the "go ahead" button and B is "back", but the face +buttons were mapped by position only (bottom Move, left Bomb, top Charge, right Wait), so on a +Switch pad the bottom button, labelled B, confirmed, against a decade of habit. Decided: (1) only +the Nintendo family differs; Xbox, PlayStation and generic keep the position mapping, because their +bottom button is already the confirm one; (2) on a Nintendo pad **Move/Go and every dialog +"confirm" (primary footer button, shop Buy, the selector's press, the banner's main button, the +demo's hold-to-play) are the right button (A, W3C index 1)**, and **Wait and every dialog +"dismiss" are the bottom button (B, index 0)**; Bomb stays left (Y, index 2), Charge top (X, +index 3); (3) the rule lives in the pure modules, not in `main.ts`: `padMap.ts` has one table +(`FACE_ACTIONS`) with `faceOf`, `faceActionOf`, `confirmFace` and `cancelFace`; `mapPad` takes the +family and says what each face button means (the intent still carries the raw index); `padGlyph.ts` +names a face button's picture by its position (`faceGlyph`) and an action's picture as the face +button that does it (`glyphFor`); `padDialogMap.ts` (`assignFaces`, `faceAction`) takes the family; +(4) the action bar's `data-face` (where the hold ring looks) follows the family, and a dialog or +banner already open is assigned again when the family becomes known; (5) the demo's virtual +controller puts the Move picture on the right and Wait at the bottom for Nintendo (CSS class +`.nintendo` on the overlay), so the picture sits where the button is. Also (same day): a pressed +button on the virtual controller stays lit for `PAD_LIT_SECONDS` = 0.7 s (was 0.3 to 0.35), and the +demo's and the clips' beats were respaced so presses never overlap (a d-pad press is lit, lets go, +then the cursor hops and rests `PAD_AFTER_SECONDS` = 0.3 s). No rules change (no SIM_VERSION bump). + +### 2026-11-02 — The virtual controller is laid out from measured data (Q63) + +Pavel disliked the CSS-drawn pad outline (rounded boxes and two tilted grips), and its hand-placed +percentages put buttons only roughly where they are on a real pad. Decided: (1) each family has a +**measured layout** (`ui/padLayouts.ts`, generated by `tools/make-pad-layouts.py`, not edited by +hand): a 1000-wide box, the body as an SVG path, and the centre and radius of the d-pad, right and left +stick, four face buttons and Start, and the shoulder boxes; (2) `ui/padPlaces.ts` (pure, tested) turns a +family into each lit part's place and size, with the face position taken from `faceOf` (so Nintendo's Move +on the right and Wait at the bottom need no special case; the `.nintendo` CSS is gone); (3) `padOverlay.ts` +draws the body, the left stick (never lit) and the shoulders in an inline SVG and lays Kenney's pictures +over it in percent of the box, so one size variable (`--pad-size`) still scales everything; `setFamily` +swaps layout and pictures at once; (4) the Xbox layout was measured from a CC BY-SA 3.0 photo by mliu92 +(attribution in the README), so the layouts carry that licence, and generic pads use it; the PS5 and +Switch Pro ones are from Pavel's own photos; (5) the SNES / N64 Switch Online pads are not covered. +The outline is hand-drawn by Claude from measurements, so it stays on the hand-made list (art +provenance #16, Q39). + +### 2026-11-02 — The virtual controller uses real line-art pictures (Q63, supersedes the outline and photo parts above) + +Pavel rejected the hand-drawn SVG outline. Decided: (1) the body is a picture, one per family, from +OpenGameArt's "Controller style 1" (Xbox, also generic), "5" (PlayStation) and "3" (Nintendo), CC BY 4.0 +by xUMR (https://opengameart.org/users/xumr); `tools/make-pad-layouts.py` scales them to 1200 px wide and trims them into +`assets/controller-art/` (README Attribution, art provenance, `LICENSE.txt` there); "Controller style 4" +(DualShock-style) is not used; (2) the drawings already hold every button, both sticks and the shoulders, +so `padOverlay.ts` shows only the `<img>` (lines made visible by a CSS drop-shadow) and Kenney's pictures +over the drawn buttons; the layout lost `outline` and `lstick`; (3) the positions are measured by Claude +from the drawings (the script's table), so mliu92's CC BY-SA 3.0 Xbox photo and Pavel's PS5 and Switch Pro +photos are no longer used and the CC BY-SA licence note is gone; (4) the hand-made outline is gone from +the Q39 list. Size is still the one `--pad-size` variable. + +### 2026-11-02 — Virtual controller polish, stick demos, time bar on the TV + +After Pavel tried the demo: (1) the white pictures (d-pad, stick) are not dimmed at rest, since the dimming made them read grey and then "turn white" when pressed; the d-pad and stick show a press by the red arm already in their own picture, shoulders and Start by a scale and a glow; only the four coloured face buttons have an idle (opacity 0.75, brightness 1.6) and a lit look. (2) The pictures are drawn under the line drawing, which is on top, and each face picture is clipped to its drawn circle (`clip-path: circle(--clip)`, `--clip` = 50% / `GLYPH_SCALE`; lit, the clip shrinks by the 1.15 scale so the circle stays put); hold rings stay above the drawing. (3) Reduced motion keeps no scale and the plain clip. (4) The stick scene needs a view that can scroll, but the demo shows rooms whole, so `updateCamera` got `zoom` (with `whole`): the scene shows 1/2 of the room, the stick calls `panView` + `endPan` (eased), and pressing it `resetPan`s. `demoZoom` is reset in `resetView` (so every new scene and the end of the demo) and in `skipTo`; the view mask and the action bar's place follow the zoomed view. A second scene, padLook, pushes the stick toward an enemy of the enemies room (`padDemoOrder`: the stick, look, Start). Chosen over a taller board because it works for any room and costs only the zoom parameter. (5) The time bar `#demo-time` is a fixed element on the TV's bottom edge, inside the glass (above the CRT, under the caption panel), instead of in the panel; the panel is shorter by it, and `--demo-panel` / the TV's size math measure the panel as before, so nothing else changed. + +### 2026-11-02 — Attribution policy + +User decision: the deployed game always links to the repository (the menu's "Source code" link, `REPO_URL` +in `main.ts`, in the live and the next copy), and the repository's README Attribution section lists every +credit. That is the way the game satisfies CC BY 4.0's attribution for xUMR's controller drawings +("in any reasonable manner", with a link to where the credit information is; section 3(a)): the README +names the author with a link to their OpenGameArt profile and each work's page, names the licence with +its link, and says what was changed (scaled and trimmed); `assets/controller-art/LICENSE.txt` repeats +it beside the files. Conditions: the repository stays publicly readable, and the link stays in every +build. Not legal advice. Kenney's CC0 art needs no credit (the README credits it anyway); no credit in the menu itself. + +### 2026-11-02 — The keyboard gets the controller's cursor, beside the plain arrows (Q65 part A) + +An audit found the keyboard could not play everything: tile targets only along the player's four straight +lines (nearest valid tile: Teleport, Decoy, Detonate only partly usable), `?` then an arrow moved the player, +Enter on the banner pressed its first button whatever had the focus, Space waited with a button focused, no +pan, dialogs opened with no default. Decided, a hybrid: (1) the plain arrows / W A S D keep `directionPlan` +(2026-11-01 recorded that the keyboard deliberately keeps it; it is fast and a habit), now documented as +"B, then a direction"; (2) **Shift + arrows** wake a keyboard cursor that is the pad's tile-first cursor +(`padTiles.ts`, `padTarget.ts` reused untouched), so every blue tile is reachable. The wake press only shows +the cursor. While it shows, the action keys use `padDo` on its tile, Enter does the first possible action +(never Wait, nothing on the player's tile) or confirms a picked one, Esc backs out stepwise (cancel, cursor +home, hide); (3) `padActive` as the cursor's switch became `inputMode` ('pad' | 'keys' | null, `ui/keyCursor.ts`): +the cursor key presses, modifiers, Alt-pan and Home keep the keyboard's mode; a plain arrow or any key or mouse +press otherwise hands the board back, as the pad rule says; `cursorWanted` is unchanged (main.ts passes +`inputMode !== null`); (4) `?` aims over `explainableTiles` and Enter or `?` explains it, so far auto-move comes through the info +dialog's "Walk there"; the aim's keyboard mode ends with "What is that?"; (5) Alt + arrows `panView` (0.5 tile a press, +repeating), Home `resetPan`; (6) Enter and Space leave a focused button, link or input to the browser +(`targetTakesKey`), and a closed dialog blurs the button that got the focus back; a dialog panel answers +Enter with its green (or only) button; (7) the label and hint draw key caps (`kbd.key-cap`) instead of pad +pictures. The pad's behaviour is unchanged. Part B (the demo for keyboards) is separate. + +### 2026-11-02 — The demo plays for the keyboard too (Q65 part B) + +The demo's input button gains **keyboard** as its third entry (touch, mouse, keyboard, ps5, xbox, switch, +generic; the pick rules of Q61 unchanged). The keyboard demo is the controller demo's shape, not the touch one: +the touch scenes minus the touch-only ones (long-press bomb, auto-move, top bar tour, "What is that?" by tap) and +the charge swap, with its own saved place (`demoAtKeys`), a **key overlay** (`ui/keyOverlay.ts`: Kenney's +Keyboard & Mouse glyphs, Default style, on a CSS grid; white at rest, a pressed key lit yellow for +`PAD_LIT_SECONDS`, so presses never overlap) and a dashed board cursor when the keys use Shift. Decided: +(1) each turn is played with the **fewest key presses the game accepts** (`ui/keyDemo.ts` `keyPlan`): a plain arrow +for a step, an action key then an arrow ("B, then a direction"; also M and V), the same key twice to confirm Freeze +on yourself, else Shift + arrows over `cursorPath` then the action key. The first Shift + arrow only wakes the cursor on +the player (as in play), so it counts as a press and repeats the first hop's direction. Every candidate is +replayed through the game's own pure rules (`runKeys`: keyButton, moveCursor, tileActions, resolvePick, tapChoices, +isDeadly) and only one that ends on the action's tile with the same action id is used; the test does the same +on every demo scene and seed. `runKeys` mirrors main.ts `directionPlan` (without its questions) rather than +sharing it; a change there needs one here. (2) Each turn is assumed to start with the cursor asleep. In play, a Shift + arrow right +after another cursor turn hops at once; the demo always wakes it, which only costs it a press. (3) Only M B V Space, +the arrows, Shift, Alt, Enter, Esc, ? and Home are drawn; R K T P F I L X G and Q E Z C have no cap, since the cursor +plus M B V reaches the same tiles; W A S D are named in the first caption, not drawn. (4) New scenes: the Shift +cursor, `?` then an arrow then Enter, Alt + arrows (the 2x zoomed look view of the stick scene) then Home, Esc opens +the menu. Captions are `SceneDef.keyCaption` with `{arrows}`, `{M}`, `{shift}` ... tokens (`keyCaptionHtml`, throws on +an unknown one). (5) The hint reads "Press Enter or Esc to play"; the demo's key handler already ended it on those +and swallows every other key. (6) Clips keep following the real input. The key pictures were copied from the pack +(15 files), art-provenance and README Attribution counts updated. +Correction (2026-11-02): "swallows every other key" in (5) is too strong. In the demo ← and → change scenes, Esc and Enter end it, and Tab, Enter and Space work the demo's own buttons; the rest does nothing. The keyboard cursor's wake (Shift + arrow, `?`) is also off while the demo plays or a dialog is open (`modeAfterKey`'s `locked`). +Known gaps (left as they are): the demo's `runKeys` mirrors main.ts `directionPlan` instead of sharing it (a shared pure `directionPlan` would remove the double upkeep); the keyboard demo's `?` scene does not jump the aim to the enemy as the real game's aim does. + +### 2026-11-02 — Hold to confirm acts when the ring fills (amends Q59 decision 4, "fire on release") + +User request (Pavel tried it): waiting for the button to be let go felt late. Decided: (1) the action +happens the moment the ring is full, while the button is still down; decision (4) of the Q59 entry +("fire on release, so the ring is a warning that can be backed out of") is replaced: letting go +before the ring is full is still the way to back out, and the full ring is now the point of no +return; (2) the button is then **used up** (`HoldState.done` in `ui/padHold.ts`): it cannot fire again +and, since a new hold cannot start while one is held or used up, it starts none in whatever the +action opened (a dialog, the demo ending into the game, a level change, the banner) until it has +been let go; a second face button is ignored meanwhile, as before; (3) the ring (`hold` callbacks) +reaches 1 at completion and stays full (green) until release, or goes at once if the place the +buttons act in changes (`holdEnd`; the button stays used up); unplug, blur, a swapped pad and a pad +error end the used-up state too; an instant button (Start, a bumper) does not; (4) holds that have not +completed keep every Q59 cancel rule (early release, context change, instant button, blur, unplug, +swap, error); (5) the demo's hold-to-play starts play at completion and the same held button then +does nothing in the game; the waking press rules (2026-11-01) are unchanged. No rules change (no +SIM_VERSION bump). Not verifiable without a real pad: how it feels, and that a held button across +the demo-to-game change shows no ring. + +### 2026-11-02 — The hold ring is anchored to its originator (Q72) + +Bug (Pavel, with a pad): the ring stayed on screen a moment after the dialog that began it closed. +Cause: the ring was positioned only inside `hold()` calls from the pad poll, and the dialog stays +open for a 150 ms pop-out (`closing`) after a button closes it; the ring, a child of the dialog, +stayed at the old spot meanwhile, and `holdEnd` came only when the poll saw the context change. +Decided: the ring belongs to the picture it was shown on (`holdRing.ts` keeps it as `origin`), +follows it every animation frame, and hides in the frame the picture is not connected, not drawn +(`checkVisibility`, zero size) or inside a dialog that is closing or closed (`ringVisible`, pure, +tested). A completed hold's button still counts as used up until release (Q68); only the ring goes. +The demo's ring lives inside the demo controller, so it goes with it (and `releaseAll`). Not +verifiable without a real pad: that it feels immediate on the actual hardware. + +### 2026-11-02 — Face buttons grey at rest, coloured when pressed + +User request: on the demo's virtual controller a pressed A/B/X/Y was hard to tell from an idle one +when both were coloured (the earlier brightening of the idle colours made it worse). Decided: the +face glyphs are grey at rest (`grayscale(1)`, brightened so they read on the dark demo) and take +their colour only while pressed (`.pad-part.face.on`: full colour, a little saturation and +brightness, the scale pulse); the filter transition makes the change gradual. Amends the same +day's "brighter idle colours" point. The white pictures (d-pad, stick, shoulders, Start) are +unchanged. CSS only (`style.css`, `.pad-overlay .pad-part.face`). + +### 2026-11-02 — Faded blue: auto-move's reach, for the pad's and keyboard's cursor (Q69) + +Pavel's idea: next to the bright blue (single actions) show a second, faded blue on every tile +auto-move can take you to, so the cursor can hop there and confirm. Decided: (1) `farTiles` +(`ui/padTiles.ts`) is the set a tap would walk to (`tapMeaning` = 'walk') with a safe, not deadly +first move (`autoMoveAction`), minus the bright tiles and the player's own, with the walk's length +for the label; with a destination set, only it (so Go can be pressed from the cursor). +(2) The cursor's candidates are the bright plus the faded tiles, one set for `moveCursor` and +`keepCursor`; no special order, nearest in the direction wins. (3) On a faded tile only Move/Go is +possible, and it taps the tile with nothing picked, so every tap rule and safety check applies; +the ghost shows nothing there. (4) Faded tiles are drawn only with the pad's or keyboard's cursor +and when nothing is picked; mouse and touch stay as they were (Pavel was asked; answer pending). +(5) No modifier button: the d-pad repeat is faster instead (250 ms, then 75 ms; shared with menus +and dialogs). Keyboard Shift+arrow repeat is the OS's. Cost: a scan of the floor, 40 ms on a +13 x 17 level, once per state (`currentFar`). Demo scenes for it come later. + +Added the same day (Pavel approved): **the way is previewed before it is chosen.** With the cursor +on a faded tile, or the mouse (pointer type mouse, no button down; never touch) over a far walkable +tile, with nothing picked, no way set, no dialog and no demo, the board draws auto-move's way as the +real one is drawn (`routePreview` in `ui/routePreview.ts`, pure: the same `autoMoveAction`, +`autoRoute`, `isDeadly` rules, first tile red when unsafe; `View.preview`; one drawing block in +`render/board.ts`). Nothing is set and no step taken; Move/Go, M, Enter or a click then commits it +as before. The result is cached per tile, state and danger (`previewNow`). Click and tap behaviour +is unchanged; mouse hover is the only mouse change (the faded tint itself is still pad and keyboard only). + +### 2026-11-02 — Autopilot replaces the hoover (Q73) + +Supersedes "The level finishes itself once the last enemy is gone" (2026-11-01, Q56). User +request: the turnless hoover made gathering free, which is not the game's economy, and it was +not a walk the player could watch. Decided with the user: (1) the `hoover` action is removed +entirely (`types.ts`, `turn.ts`: the branch, `canHoover`, `hoover`); (2) in its place, once the last +enemy is gone, an autopilot walks the character with ordinary turns (move, run, diagonal, wait), so +replays need no new action; the sim part is the pure `sim/autopilot.ts` (`autopilotTargets`, +`autopilotPlan`), the UI part follows separately; (3) the route is the one with the FEWEST TOTAL +TURNS, not nearest-first: pairwise walking distances from `playerPath`, then the cheapest order +through every reachable coin, charge and uncovered gem ending at the exit (exhaustive over subsets +for up to 10 targets, nearest-first beyond), a target lying on the chosen leg counted as collected +free, ties broken by fewest direction changes, then smaller y, then smaller x (deterministic, so +replays agree); (4) it is re-planned every turn from the current state, using auto-move's safety +rules (`safeStep`); unreachable targets are skipped; a gem under a crumbling crate, or no way to the +exit while crates crumble, means waiting; (5) the autopilot's turns count, par bonus included (no +par protection; accepted). (6) SIM_VERSION 42 to 43, because a record holding `hoover` can no +longer replay. Autopilot's wording, speed, note and stop controls are decided in the UI part. + +UI part (same day, decided with the user): (7) the note is `#autopilot`, top centre below the top +bar, a pill like the toast but not a popover (dialogs cover it), text `role="status"`: "Autopilot: +cleaning up", "Autopilot: waiting for the crates", "Autopilot: off to the exit". It shows at once +on the last kill, even if a dialog from that turn is open; the walk starts after a 1.0 s beat +(and after the answer). (8) Speed: 4 moves a second (`AUTOPILOT_STEP_SECONDS` 0.25; auto-move +stays at 0.5), no new animation; a Run only when owned. (9) Stop: Esc, pad L1 (its picture is on +the note) and a Stop button on the note for touch and mouse; stopping ends it for the level; so +does turning the setting off. A stall (6 waits in a row outside the crates phase, or no safe turn) +stops it with the toast "Autopilot stopped / Your turn". (10) While it walks the board takes no +input (`boardLocked`): taps, long-press, the action bar (dimmed), movement and action keys, `?`, +Shift + arrows, the pad cursor and face buttons; it still takes dialogs (a gem or charge pauses +the walk until answered), the menu, fullscreen, the top bar's info taps and looking around. A hold +begun before the lock is cancelled through the pad's context string. (11) The setting keeps its +stored key `autoFinish` so players' choices survive the rename. + +## A controller's selector shows what to hold (Q74, 2026-11-02) + +Bug report: with a controller the menu's switches (Sound, Autopilot) could not be changed. They +could: a hold of Cross toggles them. But a short press did nothing and nothing showed that it +should be held, because in selector mode only the footer's dismissing button had a picture, so +the hold ring (which goes round a `.pad-glyph[data-face]`) had nothing to go round. Decided: the +selected item carries the confirm button's picture (added on selection, removed when the selection +moves; skipped where the item has a picture already, the dismissing button's Circle and a shop +item's Buy), so the ring shows on it; a confirm button let go early shows the toast "Hold to +choose" once per dialog; the almanac's neighbouring tabs show LB / RB. The 0.5 s rule is +unchanged. Same day, the autopilot (decision 4 above) also waits for a ticking bomb or burning +flame that cuts it off from a target (phase `bombs`), and such waits are not stalls. + +## Move on a tile that is also a bomb target walks (Q75b, 2026-11-02) + +Bug report (controller): the player stood two tiles from an empty floor tile in a straight line, +holding an RPG. The cursor on it said "Fire an RPG bomb to this tile", Bomb was possible and Move +greyed, so the player could bomb a tile but not walk there. Cause (confirmed): the tile is bright +blue (an RPG target), `farTiles` leaves bright tiles out of the faded set, and `tileActions` offers +Move only where Move itself can target (next to you, or 2 straight with Run). `tapMeaning` does say +'walk' for such a tile (nothing picked), so a tap would have walked. Decided: the rule is "wherever +auto-move can walk, Move is offered". New pure `walkTo` / `cursorActions` in `ui/padTiles.ts`: on a +bright tile where Move is not possible but a safe, non-deadly walk exists, the Move entry becomes +the walk (`far: true`, "Walk here · N steps", "Keep walking" for the destination), and the other +buttons stay as `tileActions` has them. Pressing it takes `padDo`'s existing `far` path (nothing +picked, tap the tile), so it walks. Tiles Move can target keep their plain Move; faded tiles are +unchanged. The faded tint still leaves bright tiles out (they are blue already). No SIM_VERSION +change. Test: `test/q75.test.ts`. + +## The cursor still reaches every faded tile while a walk is set (Q75, 2026-11-02) + +Bug report (controller): a coin an enemy dropped, in a pocket beside a bomb target, could not be +reached by the cursor, and no faded tile showed anywhere; the screenshot had L1 "Cancel" on, which +means an action was picked or an auto-move destination was set. Investigated: the rules are right +(`test/q75.test.ts`: `tapMeaning` says 'walk', the coin is a `farTiles` entry, the first step is +not deadly, and the d-pad hops from the RPG tile down to it). The cause is Q69/Q70's rule that, +with a destination set, `farTiles` lists only the destination: any other walk was out of reach of +the cursor and nothing was tinted, with only "Cancel" to get out. Decided: `currentFar` in +`main.ts` no longer passes the destination, so every faded tile stays a candidate and tinted while +a walk is set; the cursor starts on the destination as before (`cursorStart`), and pressing Move +on another faded tile sets a new destination (`tapTile`). `farTiles`' `destination` parameter stays +for its tests. With an action picked, faded tiles are still hidden, as the picked button's targets +are the point. No SIM_VERSION change. + +### 2026-11-02 — Moving the mouse no longer takes the board off the pad or keyboard cursor + +User request: the cursor mode switched back to the mouse whenever the mouse moved (more than 3 px), +which made screenshots difficult while testing with a controller. Decided: only a click or tap +(`pointerdown`) or a key hands the board back (`handBack`); a plain mouse move does not. Amends the +Q65 keyboard-cursor rule 'a mouse move of 3 px or more hands the cursor back, like the pad'. The mouse's +hover route preview only shows while no pad or keyboard cursor is active, so it does not appear while +the mouse merely moves over a pad-driven board; a click on the board is the way back. diff --git a/docs/art-provenance.md b/docs/art-provenance.md index 15f759f..3e2cb16 100644 --- a/docs/art-provenance.md +++ b/docs/art-provenance.md @@ -19,12 +19,13 @@ Makers used below: | Group | Files | Maker | |---|---|---| -| Kenney, unchanged | 144 images and fonts | Kenney | +| Kenney, unchanged | 198 images and fonts | Kenney | | Splat Pack splats | 14 | Kenney, scaled 256 → 128 px (alpha identical after scaling; saved as grey + alpha) | | Pointing hand `cursorHand_beige.svg` | 1 | Kenney, three shapes cut out of the RPG Expansion vector sheet | | Sounds | 12 | Kenney, converted `.ogg` → `.mp3` | | `assets/composed/` | 34 | Script `tools/compose-icons.sh` from Kenney files (see below) | | `static/icons/` | 4 | Script `tools/compose-icons.sh`: Kenney bomb on a sky-blue square | +| Controller pictures `assets/controller-art/padart-{xbox,playstation,nintendo}.png` | 3 | OpenGameArt "Controller style 1", "5", "3" by xUMR (https://opengameart.org/users/xumr), CC BY 4.0; scaled to 1200 px wide and trimmed by `tools/make-pad-layouts.py` (style 1: Xbox, also generic; 5: PlayStation; 3: Nintendo; style 4, DualShock-style, is not used) | | Drawn in code | many | Claude by hand (see below) | | Three sounds made in code | 3 | Claude by hand (ZzFX numbers) | | `docs/screenshots/` | 30 | Script `tools/screenshots.mjs` (screenshots of the game itself) | @@ -49,6 +50,7 @@ These are the items with no Kenney shape behind them, or with Claude's own drawi | 13 | **Boom, bomb set down, ICBM launch sounds** | `src/ui/feedback.ts` | ZzFX parameter lists written by Claude; the sound is synthesised in the browser. | | 14 | **Old-TV look of the demo** (scanlines, vignette, snow) | `src/style.css` `#demo-crt`, ~1394-1508 | CSS gradients and an inline SVG noise filter. | | 15 | **Button/panel layout, dialogs, toasts, caption panels** | `src/style.css` | CSS using Kenney's UI Pack frames; the arrangement is Claude's. | +| 16 | **Where the glyphs sit on the controller pictures** (`src/ui/padLayouts.ts`, generated by `tools/make-pad-layouts.py`) | the measurement table in the script | Positions and sizes of the buttons, sticks and shoulders, measured by Claude from the line drawings (see the pictures below). Data, not art. The old hand-made SVG outline is gone (Q39 progress: one fewer hand-made item). SNES / N64 Switch Online pads are not covered. | Also in code but **not** hand-drawn: the decoy is Kenney's `character_green_front` sprite tinted pale (`drawDecoy`), and sprites tinted when drawn (the splats on the floor, oil) keep Kenney's shape. @@ -118,8 +120,11 @@ Badge = green disc with dark rim, upper right, Kenney symbol on it. Base sprites | kenney-icons/White/upRight.png | game-icons | PNG/White/2x/upRight.png | | kenney-icons/White/warning.png | game-icons | PNG/White/1x/warning.png | | kenney-icons/question.png | game-icons | PNG/Black/2x/question.png | +| kenney-icons/larger.png | game-icons | PNG/Black/2x/larger.png | +| kenney-icons/smaller.png | game-icons | PNG/Black/2x/smaller.png | | kenney-ui-rpg/panel_beigeLight.png | ui-pack-rpg-expansion | PNG/panel_beigeLight.png | | kenney-crosshair/crosshair041.png | crosshair-pack | PNG/Black Retina/crosshair041.png | +| kenney-cursor/cursor_none.png | cursor-pack | PNG/Basic/Default/cursor_none.png | | kenney-input-prompts/playstation_button_color_circle.png | input-prompts_1.5A | PlayStation Series/Default/playstation_button_color_circle.png | | kenney-input-prompts/playstation_button_color_cross.png | input-prompts_1.5A | PlayStation Series/Default/playstation_button_color_cross.png | | kenney-input-prompts/playstation_button_color_square.png | input-prompts_1.5A | PlayStation Series/Default/playstation_button_color_square.png | @@ -138,6 +143,57 @@ Badge = green disc with dark rim, upper right, Kenney symbol on it. Base sprites | kenney-input-prompts/xbox_button_color_b.png | input-prompts_1.5A | Xbox Series/Default/xbox_button_color_b.png | | kenney-input-prompts/xbox_button_color_x.png | input-prompts_1.5A | Xbox Series/Default/xbox_button_color_x.png | | kenney-input-prompts/xbox_button_color_y.png | input-prompts_1.5A | Xbox Series/Default/xbox_button_color_y.png | +| kenney-input-prompts/playstation5_button_options.png | input-prompts_1.5A | PlayStation Series/Default/playstation5_button_options.png | +| kenney-input-prompts/playstation_dpad_down.png | input-prompts_1.5A | PlayStation Series/Default/playstation_dpad_down.png | +| kenney-input-prompts/playstation_dpad_left.png | input-prompts_1.5A | PlayStation Series/Default/playstation_dpad_left.png | +| kenney-input-prompts/playstation_dpad.png | input-prompts_1.5A | PlayStation Series/Default/playstation_dpad.png | +| kenney-input-prompts/playstation_dpad_right.png | input-prompts_1.5A | PlayStation Series/Default/playstation_dpad_right.png | +| kenney-input-prompts/playstation_dpad_up.png | input-prompts_1.5A | PlayStation Series/Default/playstation_dpad_up.png | +| kenney-input-prompts/playstation_stick_r.png | input-prompts_1.5A | PlayStation Series/Default/playstation_stick_r.png | +| kenney-input-prompts/playstation_stick_r_down.png | input-prompts_1.5A | PlayStation Series/Default/playstation_stick_r_down.png | +| kenney-input-prompts/playstation_stick_r_left.png | input-prompts_1.5A | PlayStation Series/Default/playstation_stick_r_left.png | +| kenney-input-prompts/playstation_stick_r_press.png | input-prompts_1.5A | PlayStation Series/Default/playstation_stick_r_press.png | +| kenney-input-prompts/playstation_stick_r_right.png | input-prompts_1.5A | PlayStation Series/Default/playstation_stick_r_right.png | +| kenney-input-prompts/playstation_stick_r_up.png | input-prompts_1.5A | PlayStation Series/Default/playstation_stick_r_up.png | +| kenney-input-prompts/switch_button_plus.png | input-prompts_1.5A | Nintendo Switch/Default/switch_button_plus.png | +| kenney-input-prompts/switch_dpad_down.png | input-prompts_1.5A | Nintendo Switch/Default/switch_dpad_down.png | +| kenney-input-prompts/switch_dpad_left.png | input-prompts_1.5A | Nintendo Switch/Default/switch_dpad_left.png | +| kenney-input-prompts/switch_dpad.png | input-prompts_1.5A | Nintendo Switch/Default/switch_dpad.png | +| kenney-input-prompts/switch_dpad_right.png | input-prompts_1.5A | Nintendo Switch/Default/switch_dpad_right.png | +| kenney-input-prompts/switch_dpad_up.png | input-prompts_1.5A | Nintendo Switch/Default/switch_dpad_up.png | +| kenney-input-prompts/switch_stick_r.png | input-prompts_1.5A | Nintendo Switch/Default/switch_stick_r.png | +| kenney-input-prompts/switch_stick_r_down.png | input-prompts_1.5A | Nintendo Switch/Default/switch_stick_r_down.png | +| kenney-input-prompts/switch_stick_r_left.png | input-prompts_1.5A | Nintendo Switch/Default/switch_stick_r_left.png | +| kenney-input-prompts/switch_stick_r_press.png | input-prompts_1.5A | Nintendo Switch/Default/switch_stick_r_press.png | +| kenney-input-prompts/switch_stick_r_right.png | input-prompts_1.5A | Nintendo Switch/Default/switch_stick_r_right.png | +| kenney-input-prompts/switch_stick_r_up.png | input-prompts_1.5A | Nintendo Switch/Default/switch_stick_r_up.png | +| kenney-input-prompts/xbox_button_menu.png | input-prompts_1.5A | Xbox Series/Default/xbox_button_menu.png | +| kenney-input-prompts/xbox_dpad_down.png | input-prompts_1.5A | Xbox Series/Default/xbox_dpad_down.png | +| kenney-input-prompts/xbox_dpad_left.png | input-prompts_1.5A | Xbox Series/Default/xbox_dpad_left.png | +| kenney-input-prompts/xbox_dpad.png | input-prompts_1.5A | Xbox Series/Default/xbox_dpad.png | +| kenney-input-prompts/xbox_dpad_right.png | input-prompts_1.5A | Xbox Series/Default/xbox_dpad_right.png | +| kenney-input-prompts/xbox_dpad_up.png | input-prompts_1.5A | Xbox Series/Default/xbox_dpad_up.png | +| kenney-input-prompts/xbox_stick_r.png | input-prompts_1.5A | Xbox Series/Default/xbox_stick_r.png | +| kenney-input-prompts/xbox_stick_r_down.png | input-prompts_1.5A | Xbox Series/Default/xbox_stick_r_down.png | +| kenney-input-prompts/xbox_stick_r_left.png | input-prompts_1.5A | Xbox Series/Default/xbox_stick_r_left.png | +| kenney-input-prompts/xbox_stick_r_press.png | input-prompts_1.5A | Xbox Series/Default/xbox_stick_r_press.png | +| kenney-input-prompts/xbox_stick_r_right.png | input-prompts_1.5A | Xbox Series/Default/xbox_stick_r_right.png | +| kenney-input-prompts/xbox_stick_r_up.png | input-prompts_1.5A | Xbox Series/Default/xbox_stick_r_up.png | +| kenney-input-prompts/keyboard_arrow_up.png | input-prompts_1.5A | Keyboard & Mouse/Default/keyboard_arrow_up.png | +| kenney-input-prompts/keyboard_arrow_down.png | input-prompts_1.5A | Keyboard & Mouse/Default/keyboard_arrow_down.png | +| kenney-input-prompts/keyboard_arrow_left.png | input-prompts_1.5A | Keyboard & Mouse/Default/keyboard_arrow_left.png | +| kenney-input-prompts/keyboard_arrow_right.png | input-prompts_1.5A | Keyboard & Mouse/Default/keyboard_arrow_right.png | +| kenney-input-prompts/keyboard_arrows.png | input-prompts_1.5A | Keyboard & Mouse/Default/keyboard_arrows.png | +| kenney-input-prompts/keyboard_m.png | input-prompts_1.5A | Keyboard & Mouse/Default/keyboard_m.png | +| kenney-input-prompts/keyboard_b.png | input-prompts_1.5A | Keyboard & Mouse/Default/keyboard_b.png | +| kenney-input-prompts/keyboard_v.png | input-prompts_1.5A | Keyboard & Mouse/Default/keyboard_v.png | +| kenney-input-prompts/keyboard_space.png | input-prompts_1.5A | Keyboard & Mouse/Default/keyboard_space.png | +| kenney-input-prompts/keyboard_shift.png | input-prompts_1.5A | Keyboard & Mouse/Default/keyboard_shift.png | +| kenney-input-prompts/keyboard_alt.png | input-prompts_1.5A | Keyboard & Mouse/Default/keyboard_alt.png | +| kenney-input-prompts/keyboard_enter.png | input-prompts_1.5A | Keyboard & Mouse/Default/keyboard_enter.png | +| kenney-input-prompts/keyboard_escape.png | input-prompts_1.5A | Keyboard & Mouse/Default/keyboard_escape.png | +| kenney-input-prompts/keyboard_question.png | input-prompts_1.5A | Keyboard & Mouse/Default/keyboard_question.png | +| kenney-input-prompts/keyboard_home.png | input-prompts_1.5A | Keyboard & Mouse/Default/keyboard_home.png | | kenney-ui/Extra/icon_play_light.png | ui-pack | PNG/Extra/Double/icon_play_light.png | | kenney-ui/Extra/icon_repeat_light.png | ui-pack | PNG/Extra/Double/icon_repeat_light.png | | kenney-ui/Green/button_rectangle_depth_flat.png | ui-pack | PNG/Green/Double/button_rectangle_depth_flat.png | diff --git a/index.html b/index.html index c570cc0..c51263e 100644 --- a/index.html +++ b/index.html @@ -34,6 +34,7 @@ </div> <div id="hud-buttons"> <button id="info-button" type="button" aria-label="What is that?" aria-pressed="false"></button> + <button id="fullscreen-button" type="button" aria-label="Fullscreen" aria-pressed="false" title="Fullscreen" hidden></button> <button id="menu-button" type="button" aria-label="Menu"><span></span><span></span><span></span></button> </div> </header> @@ -56,10 +57,10 @@ </div> </dialog> <div id="view-mask" aria-hidden="true"></div> -<h1 id="demo-title" hidden>EXPLODORAMA!</h1> +<h1 id="demo-title" hidden>EXPLODORAMA!<span class="demo-hint">Tap anywhere to play</span></h1> +<div id="demo-time"></div> <div id="demo" hidden aria-live="polite"> <p id="demo-caption"></p> - <div id="demo-time"></div> <div class="demo-nav"> <button id="demo-prev" type="button" aria-label="Previous scene"></button> <span id="demo-count"></span> @@ -67,7 +68,9 @@ <button id="demo-shuffle" type="button" aria-label="Shuffle the scenes" title="Shuffle the scenes" aria-pressed="false"></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 class="demo-nav demo-input-row"> + <button id="demo-input" type="button" title="Show the demo for another kind of input"></button> + </div> </div> <div id="demo-finger" hidden aria-hidden="true"></div> <div id="demo-you" hidden aria-hidden="true"></div> diff --git a/src/changelog.ts b/src/changelog.ts index 204352f..aa556cc 100644 --- a/src/changelog.ts +++ b/src/changelog.ts @@ -48,24 +48,62 @@ 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: '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: 'Fullscreen from the first tap', + title: 'Fast move by controller and keyboard', text: [ - 'The first tap, click or key press switches the game to fullscreen (browsers do not allow it sooner). Esc or F11 leaves it, and it stays out. Turn it off with "Start in fullscreen" under Settings in the menu.', + '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-01', + 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: 'The level finishes itself', + title: 'Autopilot', text: [ - 'When the last enemy is gone, a magic force gathers the coins, charges and gems still lying about, and you walk to the exit on your own, along a safe way. Gems and charges still ask what you want, one after another. The gathering is free; the walk costs turns as usual.', - 'Any move of your own, or a tap that puts the flag elsewhere, ends it for that level. If you would rather do it all yourself, turn off "Finish the level for me" under Settings in the menu.', + 'When the last enemy is gone, the character walks round on its own, four 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-01', + date: '2026-11-02', }, { kind: 'added', @@ -73,8 +111,9 @@ export const UNRELEASED: Change[] = [ 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, 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 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.', - 'In dialogs, Cross, Triangle and Square press the buttons, Circle closes, the d-pad moves a highlight over the choices, LB and RB switch the almanac’s tabs, and the right stick scrolls.', + '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', }, diff --git a/src/main.ts b/src/main.ts index 1f07103..ad33942 100644 --- a/src/main.ts +++ b/src/main.ts @@ -29,11 +29,12 @@ import { isAvailable, isValidTarget, ACTIONS } from './sim/actions' import { approachTile, canPick, membersOf, pickChoices, pickTargets, resolvePick, tapChoices, tapMeaning } from './sim/picks' import { enemyAt } from './sim/enemies' import { autoMoveAction, autoRoute, playerPath, safeStep as isSafeStep } from './sim/path' +import { autopilotPlan } from './sim/autopilot' import { isDeadly, predictDanger, type Danger } from './sim/danger' import { newRecord, replay, SIM_VERSION, type RunRecord } from './sim/record' import { cellAt, eq, inBounds, isBurning, newRun, PAR_BONUS } from './sim/state' import { LAST_LEVEL } from './sim/levelgen' -import { canHoover, step } from './sim/turn' +import { step } from './sim/turn' import { CHARGE_IDS, DEFAULT_OPTIONS, DIRS, type Action, type ActionId, type ChargeId, type Dir, type GameEvent, type GameState, type RunOptions, type Vec } from './sim/types' import { openAlmanac } from './ui/almanac' import { chargeToast, showToast } from './ui/toast' @@ -44,11 +45,18 @@ import { CHANNEL, storageKey, TITLE } from './channel' import { createControls } from './ui/controls' import type { Step } from './ui/spatial' import { createPadInput } from './ui/gamepad' -import { glyphFor, shoulderGlyph } from './ui/padGlyph' -import type { PadFamily } from './ui/padMap' +import { glyphFor, padCaptionHtml, shoulderGlyph } from './ui/padGlyph' +import { confirmFace, 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' +import { KEY_STILL_CAPTIONS, KEY_STILLS, keyCaptionHtml, keyDemoOrder, keyPlan, pressParts, type KeyPress, type KeyStill } from './ui/keyDemo' +import { createKeyOverlay } from './ui/keyOverlay' +import { createHoldRing } from './ui/holdRing' import { padDialog } from './ui/padDialog' import { explainableTiles, firstTarget, jumpTarget } from './ui/padTarget' -import { actionableTiles, cursorDeadly, cursorLines, cursorWanted, FACE_TO_BUTTON, ghostAction, keepCursor, l1Hint, moveCursor, restingCursor, tileActions, type BarButton, type TileAction } from './ui/padTiles' +import { routePreview, type RouteView } from './ui/routePreview' +import { actionableTiles, cursorDeadly, cursorLines, barButtonFor, cursorWanted, cursorActions, farTileAt, farTiles, ghostAction, cursorStart, keepCursor, l1Hint, moveCursor, tileActions, type BarButton, type FarTile, type TileAction } from './ui/padTiles' +import { defaultButton, escStep, KEY_CAPS, keyButton, keyDirection, keyRole, modeAfterKey, panStep, targetTakesKey, type InputMode } from './ui/keyCursor' import { closeDialog, dialogOpen, MENU_TITLE, openDialog, setDialogInDemo, type DialogButton } from './ui/dialog' import { actionInfo, CHARGE_TEXT, chargeInfo, hudInfo, tileInfo, type Info } from './ui/info' import { openChargeOffer } from './ui/chargeOffer' @@ -57,7 +65,7 @@ import { bindTimeline } from './ui/timeline' import { openOffer } from './ui/offer' import { showShop, updateShop } from './ui/shop' import { shopAfter } from './sim/shop' -import { startClip, drawClips } from './ui/clip' +import { startClip, drawClips, setClipPad } from './ui/clip' import { makeScene } from './ui/scenes' import { openScores, recordScore } from './ui/scores' import { countsForScores, scoreEntry } from './sim/scores' @@ -66,11 +74,13 @@ import { hashSeed } from './sim/rng' import { TEST_KINDS, TEST_SCENES, type TestKind } from './sim/testScenes' import { CONTROL_KINDS, DYING_KINDS, LIVING_KINDS, SCENES, type Gesture, type Scene, type SceneKind } from './sim/demo' import { spriteUrl } from './render/sprites' +import { demoHintText, demoInputFamily, demoInputLabel, effectiveDemoInput, isKeysInput, isPadInput, nextDemoInput, type DemoInput } from './ui/demoInput' import { countFrame, timed } from './ui/fps' import { canVibrate, feedbackSettings, initFeedback, setFeedbackSettings, turnFeedback, vibrate } from './ui/feedback' import playIcon from '../assets/kenney-ui/Extra/icon_play_light.png' import newRunIcon from '../assets/kenney-ui/Extra/icon_repeat_light.png' import handIcon from '../assets/kenney-ui-rpg/cursorHand_beige.svg' +import mouseIcon from '../assets/kenney-cursor/cursor_none.png' import questionIcon from '../assets/kenney-icons/question.png' // `?seed=123&level=4` gives a reproducible board (screenshots, bug reports). Otherwise a random @@ -87,6 +97,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 +/** The tile under a mouse pointer with no button down (never a finger): a far walkable one previews auto-move's way. */ +let mouseHover: Vec | null = null /** * The controller's "What is that?" aim: the tiles that have something to explain, the current one, * and what pressing again says. Gone when "What is that?" ends, or a mouse, touch or key acts. @@ -123,7 +135,19 @@ const LONG_PRESS_MS = 450 * Half a second, twice as fast as at first (user request, 2026-10-23: "at least 50% faster"). */ const AUTO_STEP_SECONDS = 0.5 +/** The autopilot (Q73) moves four times a second, and waits this long after the last enemy before its first move (the note shows meanwhile). */ +const AUTOPILOT_STEP_SECONDS = 0.25 +const AUTOPILOT_START_SECONDS = 1.0 +/** Waits in a row (not for crates, nor with a bomb or flame on the board) after which the autopilot gives the turn back. */ +const AUTOPILOT_STALL_WAITS = 6 let lastActionAt = -Infinity +/** Armed while enemies stand; the level's last kill fires it once (`autopilotTick`). */ +let autopilotArmed = true +/** Under way: it plays the turns of the level's cleaning up. The player's Esc, L1 or Stop ends it for the level; the board takes no input meanwhile (`boardLocked`). */ +let autopilot = false +/** When its first move may come (the start beat), and how many waits in a row it has made. */ +let autopilotStartAt = 0 +let autopilotWaits = 0 /** Kinds of action charge picked up this run: only the first of each kind opens a dialog. */ const chargesSeen = new Set<ChargeId>() @@ -134,8 +158,13 @@ function firstCharge(e: GameEvent): ChargeId | null { return e.drop.kind } +/** The demo's zoom on the room (render/camera.ts `updateCamera`): above 1 only in the looking-around scenes (the pad's stick, the keyboard's Alt + arrows), so the view can pan. */ +let demoZoom = 1 + /** A fresh view of the board: no animations under way, nothing picked, the camera snapped. */ function resetView(): void { + stopAutopilot() + demoZoom = 1 clearAnimation(anim) selected = null inspecting = false @@ -241,9 +270,9 @@ function act(action: Action, auto = false): void { // A gem's offer is answered before anything else (its dialog opens a moment after the pickup). if (state.offer && action.id !== 'choose') return if (state.chargeOffer && action.id !== 'sellCharge' && action.id !== 'swapCharge' && action.id !== 'fireCharge') return + // The autopilot plays alone: only its own turns and the answers to its dialogs get through (the inputs are locked already; this is the backstop). + if (autopilot && !auto && action.id !== 'choose' && action.id !== 'sellCharge' && action.id !== 'swapCharge' && action.id !== 'fireCharge' && action.id !== 'continue') return lastActionAt = time - // A move of the player's own ends the automatic finish; answering a dialog, or the hoover, does not. - if (!auto && action.id !== 'hoover' && action.id !== 'choose' && action.id !== 'sellCharge' && action.id !== 'swapCharge' && action.id !== 'fireCharge') finishing = false if (!auto && MOVES.has(action.id) && 'target' in action && !path?.some((t) => eq(t, action.target!))) destination = null // step() throws on an action the rules don't allow; then nothing happens, and it is reported. let result: ReturnType<typeof step> @@ -358,13 +387,16 @@ function showChargeOffer(): void { function afterChange(): void { if (aim && (state.status !== 'playing' || !inspecting)) aim = null - refreshCursor() + // "What is that?" was what woke the keyboard's aim (not the cursor): when it ends, the board is the plain keys' again. + if (keysFromInspect && !inspecting) handBack(null) danger = predictDanger(state) // The walk is worked out again every turn, since bombs and crates change; reaching the // destination, or it becoming unreachable, ends auto-move. if (destination && (eq(state.player, destination) || state.status !== 'playing')) destination = null path = destination ? playerPath(state, destination) : null if (!path) destination = null + // After the danger and the destination: the cursor's faded tiles are safe ones (`farTiles`). + refreshCursor() deadlyWait = isAvailable(state, 'wait') && isDeadly(state, { id: 'wait' }) const picked = selected deadlyTargets = picked @@ -399,7 +431,7 @@ function afterChange(): void { */ function tapTile(p: Vec): void { aim = null - if (state.status !== 'playing') return + if (state.status !== 'playing' || boardLocked()) return if (inspecting) { inspecting = false afterChange() @@ -430,7 +462,6 @@ function tapTile(p: Vec): void { else if (means) act({ id: means, target: p }) else if (destination) { destination = null - finishing = false afterChange() } } @@ -444,7 +475,7 @@ function refuse(p: Vec): void { /** The "What is that?" button: on, or off again (dropping any action picked). */ function inspect(): void { - if (state.status !== 'playing') return + if (state.status !== 'playing' || boardLocked()) return aim = null inspecting = !inspecting selected = null @@ -456,7 +487,8 @@ function inspect(): void { * request, 2026-10-23: setting the way used to wait for Go). An unsafe first step isn't taken. */ function setDestination(p: Vec): void { - finishing = false + // "Walk there" from a tile's info dialog, opened while the autopilot waited: the player takes over. + stopAutopilot() destination = p afterChange() stepTowardDestination() @@ -575,8 +607,13 @@ function showMenu(): void { // 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')) { input.addEventListener('change', () => { - if (input.name === 'autofinish') return setAutoFinish(input.checked) - if (input.name === 'fullscreen') return setFullscreen(input.checked) + if (input.name === 'autopilot') { + setAutopilot(input.checked) + // Turned off mid-walk: it stops at once. + if (!input.checked) stopAutopilot() + return + } + if (input.name === 'fullscreenNow') return toggleFullscreen() setFeedbackSettings({ ...feedbackSettings(), [input.name]: input.checked }) // Turned on: a short buzz, so the player knows at once whether it works on their phone. if (input.name === 'vibration' && input.checked) vibrate(200) @@ -591,8 +628,10 @@ function feedbackChoices(): string { return ( `<fieldset class="choices feedback"><legend>Settings</legend>` + `<label><input type="checkbox" name="sound"${checked(f.sound)}><span><b>Sound</b></span></label>` + - (document.fullscreenEnabled ? `<label><input type="checkbox" name="fullscreen"${checked(fullscreenOn())}><span><b>Start in fullscreen</b> The first tap or key press switches to fullscreen. F11 or Esc leaves it.</span></label>` : '') + - `<label><input type="checkbox" name="autofinish"${checked(autoFinishOn())}><span><b>Finish the level for me</b> Once the last enemy is gone, gather the coins, gems and charges and walk to the exit.</span></label>` + + (document.fullscreenEnabled + ? `<label><input type="checkbox" name="fullscreenNow"${checked(!!document.fullscreenElement)}><span><b>Fullscreen</b> Now. A controller press can’t switch it in a browser: press F11, or use the button at the top.</span></label>` + : '') + + `<label><input type="checkbox" name="autopilot"${checked(autopilotOn())}><span><b>Autopilot</b> Once the last enemy is gone, walk round picking up the coins, gems and charges left, then to the exit. Gems and charges still ask you.</span></label>` + (canVibrate() ? `<label><input type="checkbox" name="vibration"${checked(f.vibration)}><span><b>Vibration</b></span></label>` : '') + `</fieldset>` ) @@ -663,107 +702,154 @@ function stepTowardDestination(): boolean { return true } -/** The "Finish the level for me" setting (on unless turned off): see `autoFinish`. */ -const FINISH_KEY = storageKey('autoFinish') -function autoFinishOn(): boolean { +/** + * The Autopilot setting (on unless turned off): see `autopilotTick`. It was "Finish the level for + * me" until Q73, and keeps that stored key so a player's choice survives the rename. + */ +const AUTOPILOT_KEY = storageKey('autoFinish') +function autopilotOn(): boolean { try { - return localStorage.getItem(FINISH_KEY) !== '0' + return localStorage.getItem(AUTOPILOT_KEY) !== '0' } catch { return true } } -function setAutoFinish(on: boolean): void { +function setAutopilot(on: boolean): void { try { - localStorage.setItem(FINISH_KEY, on ? '1' : '0') + localStorage.setItem(AUTOPILOT_KEY, on ? '1' : '0') } catch { // Storage is blocked: the setting lasts until the page closes (it reads back as on). } } -/** The "Start in fullscreen" setting (on unless turned off): see `startFullscreen`. */ -const FULLSCREEN_KEY = storageKey('fullscreen') -function fullscreenOn(): boolean { - try { - return localStorage.getItem(FULLSCREEN_KEY) !== '0' - } catch { - return true - } +/** The note for a controller (or anything else) that cannot switch fullscreen: a pad press is no browser gesture. */ +function fullscreenHint(): void { + showToast(handIcon, 'Fullscreen', 'Press F11, or use the fullscreen button') } -function setFullscreen(on: boolean): void { - try { - localStorage.setItem(FULLSCREEN_KEY, on ? '1' : '0') - } catch { - // Storage is blocked: the setting lasts until the page closes. + +/** The fullscreen button's, and the menu switch's, way to switch: in, or out. A refusal (a pad press, no gesture) says what to do instead. */ +function toggleFullscreen(): void { + if (!document.fullscreenEnabled) return + const done = document.fullscreenElement ? document.exitFullscreen() : document.documentElement.requestFullscreen() + done.catch(() => { + syncFullscreenUi() + fullscreenHint() + }) +} + +/** The top bar's fullscreen button (user request, 2026-11-02): only where the browser has fullscreen; shows the state, and the menu's switch follows. */ +const fullscreenButton = document.getElementById('fullscreen-button')! +function syncFullscreenUi(): void { + const on = !!document.fullscreenElement + fullscreenButton.setAttribute('aria-pressed', String(on)) + fullscreenButton.setAttribute('aria-label', on ? 'Leave fullscreen' : 'Fullscreen') + fullscreenButton.title = on ? 'Leave fullscreen' : 'Fullscreen' + const now = document.querySelector<HTMLInputElement>('#dialog-body input[name=fullscreenNow]') + if (now) now.checked = on +} +fullscreenButton.hidden = !document.fullscreenEnabled +fullscreenButton.addEventListener('click', toggleFullscreen) +document.addEventListener('fullscreenchange', syncFullscreenUi) + +/** The autopilot's note (style.css `#autopilot`): its words, how to stop it (Esc, or L1 for a pad), and a Stop button. Not a popover, so a dialog covers it. */ +const autopilotNote = document.createElement('div') +autopilotNote.id = 'autopilot' +autopilotNote.hidden = true +const autopilotText = document.createElement('span') +autopilotText.setAttribute('role', 'status') +const autopilotHint = document.createElement('span') +autopilotHint.className = 'autopilot-hint' +const autopilotStop = document.createElement('button') +autopilotStop.type = 'button' +autopilotStop.textContent = 'Stop' +autopilotStop.addEventListener('click', () => stopAutopilot()) +autopilotNote.append(autopilotText, autopilotHint, autopilotStop) +document.body.append(autopilotNote) +/** What the note shows now, so it is rebuilt only when it changes. */ +let autopilotShown = '' +const AUTOPILOT_TEXT = { gather: 'Autopilot: cleaning up', crates: 'Autopilot: waiting for the crates', bombs: 'Autopilot: waiting for the bombs', exit: 'Autopilot: off to the exit' } as const + +/** Whether the autopilot has the board: the board's inputs are ignored, a dialog (which it waits for) still works. */ +function boardLocked(): boolean { + return autopilot && state.status === 'playing' && !dialogOpen() +} + +/** Puts the note up (or its words and its stop hint right), once a frame; it hides with the autopilot. */ +function syncAutopilotNote(phase: keyof typeof AUTOPILOT_TEXT): void { + const hint = padActive ? `pad${padKind}` : 'key' + const key = `${phase}|${hint}` + if (key !== autopilotShown) { + autopilotShown = key + autopilotText.textContent = AUTOPILOT_TEXT[phase] + // L1's picture for a pad, the Esc cap for the keyboard (hidden on touch by the style sheet). + autopilotHint.replaceChildren(padActive ? buttonPicture(spriteUrl(shoulderGlyph(padKind, 'l'))) : keyCap('Esc')) } + autopilotNote.style.top = `${controls.insets().top + 8}px` + autopilotNote.hidden = false } /** - * Fullscreen from the first tap, click or key (user request, 2026-11-01). A browser allows it only - * after such a gesture, so the page cannot start fullscreen by itself; a controller press is no - * gesture. Once only: leaving it again (Esc, F11) is not undone. Not where the browser has no - * Fullscreen API (iPhone Safari), nor for a test URL. + * Autopilot (Q73, user requests 2026-11-01 and 2026-11-02): once the last enemy is gone, the + * character walks round at four moves a second, one real turn each, to every coin, charge and gem + * left on the floor (the order that takes the fewest turns, sim/autopilot.ts), then onto the exit. A + * gem's or charge's dialog opens on its turn and the walk waits for the answer. Esc, L1 or the + * note's Stop button ends it for the level. Gathering costs turns, so a par bonus can be lost. */ -function startFullscreen(): void { - if (testing || !document.fullscreenEnabled) return - // A touch or pen tap counts as a gesture only when the finger lifts (pointerup), and Esc never does. - const events = ['pointerup', 'click', 'keydown'] as const - const go = (e: Event): void => { - if (e instanceof KeyboardEvent && e.code === 'Escape') return - if (!fullscreenOn()) return - if (document.fullscreenElement) return stop() - document.documentElement.requestFullscreen().then(stop, () => {}) - } - const stop = (): void => { - for (const type of events) window.removeEventListener(type, go, true) - } - for (const type of events) window.addEventListener(type, go, true) +function startAutopilot(): void { + autopilot = true + autopilotWaits = 0 + autopilotStartAt = time + AUTOPILOT_START_SECONDS + selected = null + inspecting = false + destination = null + aim = null + cursor = null + resetPan() + document.body.classList.add('autopilot') + // The note shows at once, even if a dialog from the kill turn is open (the walk starts after the answer). + syncAutopilotNote(autopilotPlan(state, danger).phase) + afterChange() } -startFullscreen() -/** Armed while enemies stand; the level's last kill fires it once (`autoFinish`). */ -let finishArmed = true -/** Under way: the hoover gathers what lies about, and the player walks to the exit. Any move of the player's own ends it. */ -let finishing = false +/** Ends the autopilot for the level (it is armed again by the next level's enemies); `toast`: tells the player the turn is theirs. */ +function stopAutopilot(toast = false): void { + if (!autopilot) return + autopilot = false + autopilotShown = '' + autopilotNote.hidden = true + document.body.classList.remove('autopilot') + destination = null + afterChange() + if (toast) showToast(handIcon, 'Autopilot stopped', 'Your turn') +} -/** - * Once the last enemy is gone (user request, 2026-11-01: on a controller, tapping each coin, gem and - * charge and then the exit was a pain): the hoover gathers the coins, charges and uncovered gems, one - * choice dialog after another, then auto-move walks to the exit. The walk costs turns as ever; only - * the gathering is free. Auto-move's own safety rules apply (never onto an unsafe tile). The player's - * own move, or tapping a flag elsewhere, ends it for the level. - */ -function autoFinish(): void { - if (demo || state.status !== 'playing') return void (finishing = false) +/** One frame of the autopilot: starts it on the level's last kill, then takes a turn every step, unless a dialog is open or a choice waits. */ +function autopilotTick(): void { + if (demo || state.status !== 'playing') return stopAutopilot() if (state.enemies.length > 0) { - finishArmed = true - finishing = false - return + autopilotArmed = true + return stopAutopilot() } - if (finishArmed) { - finishArmed = false - finishing = autoFinishOn() - } - if (!finishing || state.offer || state.chargeOffer) return - if (selected || inspecting || dialogOpen() || time - lastActionAt < AUTO_STEP_SECONDS) return - if (canHoover(state)) return act({ id: 'hoover' }) - if (!destination) { - if (!playerPath(state, state.exit)) { - // Crates still falling apart (the last kill starts them): turns pass by waiting, until the way - // opens and the gems under them are gathered. No way and nothing crumbling: boxed in is the - // game's own rule (a life lost), not this one's. - if (!state.crumbling.length) return void (finishing = false) - const wait: Action = { id: 'wait' } - if (!isDeadly(state, wait)) act(wait, true) - return - } - destination = state.exit - afterChange() + if (autopilotArmed) { + autopilotArmed = false + if (autopilotOn()) startAutopilot() } + if (!autopilot) return + if (state.offer || state.chargeOffer || dialogOpen()) return + if (time < autopilotStartAt || time - lastActionAt < AUTOPILOT_STEP_SECONDS) return + const plan = autopilotPlan(state, danger) + syncAutopilotNote(plan.phase) + // Boxed in with nothing crumbling (the game's own rule, a life lost, not this one's), or waits that lead nowhere. + // Waiting for crates, or for bombs and flame to clear (a fuse can be longer than the limit), is not a stall. + autopilotWaits = plan.action?.id === 'wait' && plan.phase !== 'crates' && plan.phase !== 'bombs' && state.bombs.length === 0 && state.burning.length === 0 ? autopilotWaits + 1 : 0 + if (!plan.action || autopilotWaits > AUTOPILOT_STALL_WAITS) return stopAutopilot(true) + destination = plan.goal + act(plan.action, true) } /** Once every enemy is gone, the flag needs no more taps: auto-move steps on its own. */ function autoStep(): void { - if (demo || state.status !== 'playing' || !destination || state.enemies.length > 0) return + if (demo || autopilot || state.status !== 'playing' || !destination || state.enemies.length > 0) return if (selected || inspecting || dialogOpen() || time - lastActionAt < AUTO_STEP_SECONDS) return stepTowardDestination() } @@ -773,6 +859,7 @@ function autoStep(): void { * so this cancels it. With "What is that?" on, it explains the button instead. */ function pick(id: ActionId, pressed = selected === id): void { + if (boardLocked()) return aim = null if (inspecting) { inspecting = false @@ -892,7 +979,6 @@ function diagonalPlan(diag: Vec): StepPlan | null { * to put away. */ function cancel(): boolean { - finishing = false if (inspecting) inspecting = false else if (selected || destination) { selected = null @@ -905,25 +991,107 @@ function cancel(): boolean { window.addEventListener('keydown', (e) => { // The dialog has the keyboard while it is open (Esc closes it, Tab moves between its buttons). + const role = keyRole(e) + // Alt + Left / Right is the browser's Back / Forward on Windows and Linux: never let it through, dialog or not (a text field keeps its own). + const target = e.target as HTMLElement | null + if (role === 'pan' && !(target?.isContentEditable || ['INPUT', 'TEXTAREA'].includes(target?.tagName ?? ''))) e.preventDefault() + // The view: Alt + arrows pan it (and repeat while held), Home follows the player again. + if (!dialogOpen() && (role === 'pan' || role === 'recenter')) { + e.preventDefault() + const step = panStep(e.code) + if (step) panView(step.x, step.y) + else resetPan() + return + } if (e.repeat || dialogOpen()) return + // The autopilot has the board: Esc stops it, every other key waits (looking round, above, and the menu's dialog still work). + if (boardLocked()) { + if (e.code === 'Escape') stopAutopilot() + return + } + // Enter and Space mean something to a button or link that has the focus (it presses it): the game stays out. + if ((e.code === 'Enter' || e.code === 'NumpadEnter' || e.code === 'Space') && targetTakesKey(e.target as Element | null)) return const d = KEY_DIRS[e.code] + const keys = inputMode === 'keys' + const dir = keyDirection(e.code) // With Diagonal, Q / E / Z / C step diagonally as W A S D step straight: at once, or with Move // (or Diagonal) picked. const diagonalKeys = isAvailable(state, 'diagonal') && (!selected || selected === 'move' || selected === 'diagonal') const diag = diagonalKeys ? DIAGONAL_KEYS[e.code] : undefined - if (diag) stepDiagonal(diag) + if (role === 'hop' && dir) keyHop(dir.dx, dir.dy) + else if (inspecting && dir && role === 'direction') aimStep(dir.dx, dir.dy) + else if (inspecting && role === 'diagonal') return + else if (diag) stepDiagonal(diag) else if (d) direction(d) - else if (e.code === 'Space') pick('wait') - else if (e.key === '?' || e.code === 'Slash') inspect() - else if (ACTION_KEYS[e.code]) pick(ACTION_KEYS[e.code]!) + else if (e.code === 'Space') keyAction('wait') + else if (role === 'inspect') keyInspect() + else if (ACTION_KEYS[e.code]) keyAction(ACTION_KEYS[e.code]!) else if (e.code === 'Escape') { - // Cancels "What is that?", what is picked or the flag; with nothing to cancel, opens the menu. + // With the keyboard's cursor: cancels what is open, else brings the cursor home, else puts it away. + // Else cancels "What is that?", what is picked or the flag; with nothing to cancel, opens the menu. + if (keys) return keyEsc() if (!cancel()) showMenu() - } else if (e.code === 'Enter') { - // Presses the banner's button. - controls.pressBanner() + } else if (e.code === 'Enter' || e.code === 'NumpadEnter') { + // The cursor's tile: its first action, or the picked one; else presses the banner's button. + if (keys && state.status === 'playing') keyEnter() + else controls.pressBanner() } }) +window.addEventListener('keyup', (e) => { + if (keyDirection(e.code) || e.code.startsWith('Alt')) endPan() +}) +// A key released while the window was elsewhere is never seen: the pan would run on. +addEventListener('blur', () => endPan()) + +/** Shift + arrow: the keyboard's cursor hops that way. The press that wakes it only shows it, on the player. */ +function keyHop(dx: Step, dy: Step): void { + if (demo || state.status !== 'playing' || boardLocked()) return + if (inspecting) return aimStep(dx, dy) + if (keyWoke) cursor = null + ensureCursor() + if (!keyWoke) cursorMove(dx, dy) +} + +/** An action key: acts on the cursor's tile if the cursor shows (as the pad's face button does), else picks it as ever. */ +function keyAction(id: ActionId): void { + if (boardLocked()) return + if (inputMode === 'keys' && cursor && !inspecting) { + const button = keyButton(cursor.actions, id) + if (button) return padDo(button) + // Space is Wait, which only the player's own tile can do: away from it the key does not wait for you. + if (id === 'wait') return controls.flashPad('wait') + } + pick(id) +} + +/** `?`: turns "What is that?" on, aimed at the nearest tile; pressed again on an aim, explains that tile. */ +function keyInspect(): void { + if (inspecting && aim?.target) return tapTile(aim.target) + inspect() + if (inspecting) aimInspect() +} + +/** Enter with the keyboard's cursor: explains the aimed tile; confirms the picked action on the cursor's tile; else does the tile's first action. */ +function keyEnter(): void { + if (boardLocked()) return + if (inspecting) return aim?.target ? tapTile(aim.target) : aimInspect() + if (!cursor) return + if (selected) { + const tile = cursor.tile + cursor = null + return tapTile(tile) + } + const button = defaultButton(cursor.actions, cursor.tile, state.player) + if (button) padDo(button) + else controls.flashPad('move') +} + +/** Esc with the keyboard's cursor: cancels what is open or brings the cursor home, else puts it away (the next Esc, with the cursor gone, opens the menu). */ +function keyEsc(): void { + const step = escStep({ inspecting, picked: !!(selected || destination), onPlayer: !cursor || eq(cursor.tile, state.player) }) + if (step === 'cancel' || step === 'home') padBack() + else handBack(null) +} /** Start timing a long press on `p`, or with null, cancel any press in progress. */ /** A press on the board that may become a drag: where it is now, and whether it moves the view. */ @@ -935,7 +1103,7 @@ function startPress(p: Vec | null): void { clearTimeout(pressTimer) armed = null pressTile = p - if (!p || selected || inspecting || state.status !== 'playing' || !isValidTarget(state, 'bomb', p)) return + if (!p || boardLocked() || selected || inspecting || state.status !== 'playing' || !isValidTarget(state, 'bomb', p)) return // A shortcut, as a tap is: never a bomb that costs a life (with a fuse of 1). if (isDeadly(state, { id: 'bomb', target: p })) return pressTimer = window.setTimeout(() => { @@ -959,6 +1127,20 @@ const LOOK_TILES_PER_SECOND = 9 /** The controller is what the player is using now (body.pad); a mouse or key takes it back. */ let padActive = false +/** + * Who has the board (ui/keyCursor.ts): the controller ('pad', `padActive`), the keyboard's cursor + * and aim ('keys': Shift + arrows, decisions.md 2026-11-02), or the mouse, touch and plain keys (null). + * The cursor and the aim are on screen for the first two. + */ +let inputMode: InputMode = null +/** This key press woke the keyboard's cursor (the mode was not 'keys' before it): it only shows the cursor. */ +let keyWoke = false +/** The keyboard's mode came from "What is that?" (`?` or an arrow while it was on), not from the cursor: it ends with it. */ +let keysFromInspect = false +function setInputMode(mode: InputMode): void { + inputMode = mode + padActive = mode === 'pad' +} /** This pad input is the one that woke the pad (a key or the mouse had had the board): a face button then only shows the cursor. */ 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. */ @@ -980,38 +1162,79 @@ function aimAt(tiles: Vec[], target: Vec | null): void { * The controller's cursor (decisions.md 2026-11-01, "tile first"): one actionable tile, shown with * the dashed square while every actionable tile is blue, and what each face button would do there. */ -let cursor: { tile: Vec; tiles: Vec[]; actions: TileAction[] } | null = null +let cursor: CursorState | null = null +/** The cursor: its tile, the bright blue ones, auto-move's faded ones (`far`, pad and keyboard only), and what the buttons do on its tile. */ +interface CursorState { + tile: Vec + tiles: Vec[] + far?: FarTile[] + actions: TileAction[] +} /** The kind of pad in use, for the names in the cursor's label. */ let padKind: PadFamily = 'generic' +/** The faded tiles for the state as it is (`farTiles` walks the whole floor, so once per turn, not per hop). */ +let farCache: { state: GameState; danger: Danger; destination: Vec | null; far: FarTile[] } | null = null +function currentFar(): FarTile[] { + if (!farCache || farCache.state !== state || farCache.danger !== danger || farCache.destination !== destination) { + farCache = { state, danger, destination, far: farTiles(state, safeStep) } + } + return farCache.far +} + +/** + * Auto-move's way for a far tile only looked at, nothing set (decisions.md 2026-11-02): where the + * pad's or keyboard's cursor rests on a faded tile, or the mouse hovers over one. Null while an action + * is picked, a way is set, a dialog or the demo has the screen, or "What is that?" is on. + * Worked out again only when the tile, the board or the danger changes. + */ +let previewCache: { state: GameState; danger: Danger; tile: Vec | null; view: RouteView | null } | null = null +function previewNow(): RouteView | null { + if (selected || destination || inspecting || demo || dialogOpen() || autopilot || state.status !== 'playing') return null + const tile = aimShown() ? (cursor?.actions.some((a) => a.far) ? cursor.tile : null) : inputMode === null ? mouseHover : null + if (!tile) return null + if (!previewCache || previewCache.state !== state || previewCache.danger !== danger || !previewCache.tile || !eq(previewCache.tile, tile)) { + const far = aimShown() || farTileAt(state, safeStep, tile) + previewCache = { state, danger, tile, view: far ? routePreview(state, tile, safeStep) : null } + } + return previewCache.view +} + +/** Every tile the cursor can rest on: the bright ones and the faded ones. */ +function cursorCandidates(c: { tiles: readonly Vec[]; far?: readonly FarTile[] }): Vec[] { + return [...c.tiles, ...(c.far ?? []).map((f) => f.tile)] +} + function setCursor(tile: Vec): void { - cursor = { tile, tiles: actionableTiles(state), actions: tileActions(state, tile) } + const far = currentFar() + const tiles = actionableTiles(state) + cursor = { tile, tiles, far, actions: cursorActions(state, safeStep, tile, destination, new Set(tiles.map((t) => t.y * state.width + t.x))) } revealTile(tile.x, tile.y, state.width, state.height) } /** The board changed under the cursor: it stays on a tile that is still actionable, else moves to the nearest that is, else goes. */ function refreshCursor(): void { if (!cursor) return - const tiles = actionableTiles(state) + const tiles = cursorCandidates({ tiles: actionableTiles(state), far: currentFar() }) const tile = state.status === 'playing' ? keepCursor(tiles, cursor.tile) : null if (tile) setCursor(tile) else cursor = null } /** - * The cursor is always there while a pad plays the board, resting on the player (each turn, level, - * run, and after a dialog), and gone otherwise. Called every frame, and by the pad's handlers. + * The cursor is always there while a pad plays the board, starting on the player (each turn, level, + * run, and after a dialog), or on the destination while auto-move walks (`cursorStart`), and gone otherwise. Called every frame, and by the pad's handlers. */ function ensureCursor(): void { - const wanted = cursorWanted({ padActive, demo: !!demo, dialogOpen: dialogOpen(), inspecting, offerPending: !!(state.offer || state.chargeOffer), status: state.status }) + const wanted = cursorWanted({ padActive: inputMode !== null, demo: !!demo, dialogOpen: dialogOpen(), inspecting, offerPending: !!(state.offer || state.chargeOffer), autopilot, status: state.status }) if (!wanted) cursor = null - else if (!cursor) setCursor(restingCursor(state)) + else if (!cursor) setCursor(cursorStart(state, destination)) } /** A d-pad press: the cursor moves to the next actionable tile that way (from the player's tile, if it was resting). */ function cursorMove(dx: Step, dy: Step): void { ensureCursor() - if (cursor) setCursor(moveCursor(cursor.tiles, cursor.tile, dx, dy)) + if (cursor) setCursor(moveCursor(cursorCandidates(cursor), cursor.tile, dx, dy)) } /** @@ -1026,6 +1249,11 @@ function padDo(button: BarButton): void { if (!c || !action?.possible) return controls.flashPad(button) cursor = null if (button === 'wait') return pick('wait', false) + // A faded tile: nothing is picked, the tile is tapped, which is auto-move's walk there (or its next step). + if (action.far) { + selected = null + return tapTile(c.tile) + } selected = button afterChange() tapTile(c.tile) @@ -1036,7 +1264,6 @@ function padDo(button: BarButton): void { * dialog) and the flag, as Esc does. The cursor then goes back to the player's own tile. */ function padBack(): void { - finishing = false if (inspecting) inspecting = false else if (selected || destination) { selected = null @@ -1047,6 +1274,12 @@ function padBack(): void { afterChange() } +/** An arrow with "What is that?" on: the aim jumps to the next tile that has something to say that way, nothing wraps round. With no aim yet (it was turned on by mouse, or the aim was put away), the first press makes it. */ +function aimStep(dx: Step, dy: Step): void { + if (aim?.target) aimAt(aim.tiles, jumpTarget(aim.tiles, aim.target, dx, dy)) + else aimInspect() +} + /** "What is that?" is on: aim at the nearest tile with something to explain. */ function aimInspect(): void { const tiles = explainableTiles(state) @@ -1056,7 +1289,7 @@ function aimInspect(): void { /** Whether the controller's aim or cursor is on screen: the square and the label go together. */ function aimShown(): boolean { - return !!(aim ?? cursor) && padActive && !demo && !dialogOpen() && state.status === 'playing' + return !!(aim ?? cursor) && inputMode !== null && !demo && !dialogOpen() && !autopilot && state.status === 'playing' } let lastBarKey = '' @@ -1065,37 +1298,120 @@ let lastHintKey = '' /** The label's height plus its gap, for the hint above it; -1 = to be measured. */ let padLabelHeight = -1 addEventListener('resize', () => (padLabelHeight = -1)) +/** The demo's virtual controller (ui/padOverlay.ts), under its words; shown when the demo plays for a pad. */ +const demoPad = createPadOverlay('large') +demoPad.el.hidden = true +document.getElementById('demo-caption')!.after(demoPad.el) +/** The keyboard demo's key overlay (ui/keyOverlay.ts), in the controller's place; shown when the demo plays for the keyboard. */ +const demoKeys = createKeyOverlay('large') +demoKeys.el.hidden = true +demoPad.el.after(demoKeys.el) +/** The dialogs' clips show a small controller instead of the finger while a pad is in use (ui/clip.ts). */ +function syncClipPad(): void { + setClipPad(padActive ? padKind : null) +} +/** Hold to confirm (ui/padHold.ts): the ring round a face button's picture on the page, and on the demo's controller. */ +const holdRing = createHoldRing() +/** + * The input the demo was last picked for with its button (Q61), or null: then the demo follows + * what is detected. Session only. It ends with the demo, or when the controller setting changes (one plugged in or + * unplugged: `dropDemoPick`); no click, tap or button press drops it (Pavel, 2026-11-02), only holding the confirm + * button, which ends the demo. + */ +let demoForced: DemoInput | null = null +/** What the demo is shown for now (ui/demoInput.ts): the pick, else the detected input. */ +const demoInput = (): DemoInput => effectiveDemoInput(demoForced, padActive, padKind) +/** The pad family the demo's controller and captions draw: a picked controller's, else the real pad's. */ +const demoFamily = (): PadFamily => demoInputFamily(demoInput()) ?? padKind +/** The demo's last line: "Tap anywhere to play", "Click anywhere to play", or for a pad, hold the confirm face button (bottom; a Nintendo pad's right-hand A), the only way a pad ends the demo. */ +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) +} +/** 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 { + demoPad.setFamily(demoFamily()) + // The demo's caption names the buttons of the pad it was drawn for: draw it again. + if (demo?.pad && padCaptionNow) setPadCaption(padCaptionNow.text, padCaptionNow.icon, padCaptionNow.mono) + syncDemoHint() +} +/** A controller was plugged in or unplugged: the demo goes back to following what is detected, drawn at once. */ +function dropDemoPick(): void { + if (!demoForced) return + demoForced = null + if (demo) { + syncInputButton() + skipTo(demo.index) + } +} const pad = createPadInput({ active() { padWoke = !padActive - padActive = true + setInputMode('pad') + keysFromInspect = false document.body.classList.add('pad') + syncClipPad() + // The demo's last line shows the button of the pad that is really in hand. + if (padWoke && demo) syncDemoHint() }, error: (e) => reportError('gamepad', e), // The kind of pad, for the pictures on the action bar, in dialogs and the banner, and in the cursor's label. family(f) { + dropDemoPick() padKind = f controls.setPadFamily(f) padDialog.setFamily(f) + // The pad woke mid-demo: its controller shows at once, not from the next scene. + if (demo && isPadInput(demoInput())) demoPad.el.hidden = false + syncDemoFamily() + syncClipPad() }, + gone: dropDemoPick, toast: (title, text) => showToast(handIcon, title, text), dir(dx, dy) { if (demo) return dy === 0 && dx !== 0 ? padScene(dx) : undefined if (dialogOpen()) return padDialog.move(dx, dy) - if (state.status !== 'playing') return + if (state.status !== 'playing' || boardLocked()) return if (inspecting) { // "What is that?": the d-pad jumps between the tiles it can explain, nothing wraps round. With // no aim yet (it was turned on by key or mouse, or the aim was put away), the first press makes it. - if (aim?.target) aimAt(aim.tiles, jumpTarget(aim.tiles, aim.target, dx, dy)) - else aimInspect() + aimStep(dx, dy) return } cursorMove(dx, dy) }, + // A face button held (ui/padHold.ts): the ring fills round its picture; the press follows the moment it is full (the ring stays full until the button is let go or the place changes). + hold(face, progress) { + if (demo) { + // In the demo only the confirm button (Cross / A; a Nintendo pad's right-hand A) does anything: it starts play. + if (face === confirmFace(padKind)) { + // A pad that woke mid-demo (Chrome lists one only after a press) finds the controller still hidden: show it, so the hold is seen. + if (demoPad.el.hidden) demoPad.el.hidden = false + demoPad.hold('move', progress) + } + return + } + // On the board the press that only woke the pad shows no ring, as it does nothing but show the cursor; a dialog or the banner acts on it, so it has its ring. + const onBoard = !dialogOpen() && state.status === 'playing' + // The autopilot has the board: nothing to confirm there, so no ring. + if ((padWoke && onBoard) || boardLocked()) return + holdRing.show(face, progress, dialogOpen() ? '#dialog' : onBoard ? '#actions' : '#banner') + }, + holdEnd() { + holdRing.hide() + demoPad.hold('move', null) + }, + // A face button let go too early: in a dialog, a hint that it must be held. + tap(face) { + if (!demo && dialogOpen()) padDialog.tap(face) + }, press(button, face) { if (demo) { - // A button that acts ends it, as a tap or Esc does. - if (button !== 'info' && button !== 'reset') endDemo() + // 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() return } if (button === 'menu') { @@ -1113,6 +1429,12 @@ const pad = createPadInput({ // The end-of-level or end-of-run banner: its buttons sit on the face buttons. if (face !== undefined && padDialog.banner(face)) return if (state.status !== 'playing') return + // The autopilot has the board: L1 stops it, the right stick's click recentres the view, the rest waits. + if (boardLocked()) { + if (button === 'cancel') stopAutopilot() + else if (button === 'reset') resetPan() + return + } if (button === 'reset') resetPan() else if (button === 'cancel') padBack() else if (button === 'info') { @@ -1125,14 +1447,14 @@ const pad = createPadInput({ if (inspecting) aimInspect() } } else if (face !== undefined) { - // Cross explains the aimed tile while "What is that?" is on; the other face buttons do nothing then. + // Move/Go (Cross, A) explains the aimed tile while "What is that?" is on; the other face buttons do nothing then. if (inspecting) { - if (face === 0 && aim?.target) tapTile(aim.target) + if (button === 'move' && aim?.target) tapTile(aim.target) } else { // The press that woke the pad (a key or mouse had put the cursor away) only shows the cursor. const woke = padWoke ensureCursor() - if (!woke) padDo(FACE_TO_BUTTON[face]) + if (!woke && (button === 'move' || button === 'wait' || button === 'bomb' || button === 'charge')) padDo(barButtonFor(button)) } } }, @@ -1142,7 +1464,23 @@ const pad = createPadInput({ panView(dx * LOOK_TILES_PER_SECOND * timeDelta, -dy * LOOK_TILES_PER_SECOND * timeDelta) }, lookEnd: () => endPan(), -}) +}, undefined, undefined, () => `${demo ? 'demo' : dialogOpen() ? 'dialog' : boardLocked() ? 'autopilot' : state.status}`) +/** A pad button's picture, for the cursor's label and hint (style.css `.pad-glyph`). */ +function buttonPicture(src: string): HTMLImageElement { + const img = document.createElement('img') + img.className = 'pad-glyph' + img.alt = '' + img.draggable = false + img.src = src + return img +} +/** A key's cap, for the same label and hint when the keyboard plays the board (style.css `.key-cap`). */ +function keyCap(text: string): HTMLElement { + const cap = document.createElement('kbd') + cap.className = 'key-cap' + cap.textContent = text + return cap +} /** The one-line note over the action bar: what confirming the controller's target does (style.css `#pad-label`). */ const padLabel = document.createElement('div') padLabel.id = 'pad-label' @@ -1158,16 +1496,43 @@ document.body.append(padHint) document.getElementById('dialog')!.addEventListener('dialogopen', () => { cursor = null }) -/** The mouse, touch or keyboard has the board again: the marker goes, with the cursor and the aim, so Esc and clicks are not swallowed by them. */ -function padOff(): void { - padActive = false - aim = null - cursor = null - document.body.classList.remove('pad') +/** A click, tap or key (not a mouse move) gives the board back to the mouse, touch or keyboard: the marker goes, with the cursor and the aim, so Esc and clicks are not swallowed by them. */ +function handBack(next: InputMode): void { + const was = padActive + const before = inputMode + setInputMode(next) + if (next === null) keysFromInspect = false + if (was && demo) syncDemoHint() + // Staying with the keyboard's cursor keeps it, and the aim; any other change starts them afresh. + if (!(before === 'keys' && next === 'keys')) { + aim = null + cursor = null + } + document.body.classList.toggle('pad', padActive) + syncClipPad() } -for (const type of ['pointerdown', 'keydown'] as const) addEventListener(type, padOff, true) -// A real mouse move does too, but not a hair's jitter (or the move the browser fakes under a still mouse). -addEventListener('pointermove', (e) => padActive && Math.hypot(e.movementX, e.movementY) >= 3 && padOff(), true) +// Any key or press hands the board over, except the keys the keyboard's cursor itself uses (ui/keyCursor.ts `modeAfterKey`). +addEventListener( + 'keydown', + (e) => { + const next = modeAfterKey(inputMode, e, inspecting, dialogOpen() || !!demo) + keyWoke = next === 'keys' && inputMode !== 'keys' + // An arrow or `?` that woke the aim while "What is that?" was on, or `?` itself, is not the cursor's: it goes with "What is that?". + if (keyWoke) keysFromInspect = keyRole(e) !== 'hop' + else if (next === 'keys' && keyRole(e) === 'hop') keysFromInspect = false + handBack(next) + }, + true, +) +addEventListener('pointerdown', () => handBack(null), true) +// A mouse or touch click on a button of the game leaves it focused in Chrome, so Space would press it again instead of waiting. +// A click from the keyboard (detail 0) keeps its focus, as do the dialogs' and the demo's buttons. +addEventListener('click', (e) => { + const button = (e.target as Element | null)?.closest?.('button') + if (e.detail > 0 && button && !button.closest('#dialog, .demo-nav')) button.blur() +}) +// Moving the mouse does NOT hand the board back (Pavel, 2026-11-02: it switched out of the pad's cursor whenever the mouse was +// touched, e.g. to take a screenshot): only a click or tap does (`pointerdown` above), or a key. // LittleJS would also read the pad (its own d-pad-as-keys); this game does it itself. setGamepadsEnable(false) @@ -1212,14 +1577,31 @@ let demo: { real: GameState beats: Beat[] rest: boolean + /** Playing for a gamepad (Q58): PAD_DEMO_ORDER, the virtual controller instead of the finger. */ + pad: boolean + /** Playing for the keyboard (Q65): KEY_DEMO_ORDER, the key overlay instead of the finger. */ + keys: boolean + /** The input it is drawn for (ui/demoInput.ts): touch's finger, the mouse's pointer, or a controller's family. */ + input: DemoInput + /** The pad demo's cursor, shown on the board as the pad's own is (null: not shown). */ + cursor: CursorState | null } | null = null // The scenes' seeds: from `?seed` for a repeatable demo, otherwise random. let demoSeed = Number(params.get('seed')) || randomSeed() /** - * 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. + * Besides the scenes made in the sim, seven acted out on a still room: a tour of the top bar and + * "What is that?" on an enemy (user request, 2026-10-24; they come after the controls' scenes), + * and the pad's own five (Q58: the cursor, L1, R1, the stick, Start). */ -type DemoItem = SceneKind | 'topBar' | 'whatIsThat' +type StillItem = 'topBar' | 'whatIsThat' | 'padCursor' | 'padL1' | 'padR1' | 'padStick' | 'padLook' | 'padStart' | KeyStill +type DemoItem = SceneKind | StillItem +/** The still scenes (the pad's own, Q58, and the keyboard's, Q65): the sim scene whose room each plays on, without its turns. */ +const STILL_ROOMS: Record<StillItem, SceneKind> = { + topBar: 'tapMove', whatIsThat: 'enemies', padCursor: 'tapMove', padL1: 'tapMove', padR1: 'enemies', padStick: 'tapMove', padLook: 'enemies', padStart: 'tapMove', + keyCursor: 'tapMove', keyInspect: 'enemies', keyLook: 'tapMove', keyMenu: 'tapMove', +} +const isStill = (item: DemoItem): item is StillItem => item in STILL_ROOMS +const isKeyStill = (item: StillItem): item is KeyStill => (KEY_STILLS as readonly string[]).includes(item) /** * 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 @@ -1234,13 +1616,25 @@ const DEMO_ORDER: readonly DemoItem[] = [ // Developer mode's edge-case checks (sim/testScenes.ts), with `?dev`. ...(params.has('dev') ? TEST_KINDS : []), ] -/** Where the demo is, kept so it carries on from there next time (not for a test URL: from 1). */ -const DEMO_KEY = storageKey('demoAt') - -function savedDemoIndex(): number { +/** The gamepad's demo (Q58): the controller's own scenes first, then the touch demo's without the touch-only ones (ui/padDemo.ts `padDemoOrder`). */ +const PAD_DEMO_ORDER: readonly DemoItem[] = padDemoOrder(DEMO_ORDER) +/** The keyboard's demo (Q65): its own scenes (the Shift cursor, "What is that?", looking around, the menu) after the controls, then the touch demo's without the touch-only ones (ui/keyDemo.ts `keyDemoOrder`). */ +const KEY_DEMO_ORDER: readonly DemoItem[] = keyDemoOrder(DEMO_ORDER) +/** Which order of scenes an input plays: touch and mouse share one. */ +type OrderKind = 'touch' | 'pad' | 'keys' +const orderKind = (pad: boolean, keys: boolean): OrderKind => (pad ? 'pad' : keys ? 'keys' : 'touch') +const orderFor = (kind: OrderKind): readonly DemoItem[] => (kind === 'pad' ? PAD_DEMO_ORDER : kind === 'keys' ? KEY_DEMO_ORDER : DEMO_ORDER) +/** The kind of order the demo plays in now. */ +const demoKind = (): OrderKind => orderKind(!!demo?.pad, !!demo?.keys) +/** The order the demo plays in now. */ +const demoOrder = (): readonly DemoItem[] => orderFor(demoKind()) +/** Where the demo is, kept so it carries on from there next time (not for a test URL: from 1). The pad's and the keyboard's orders keep their own places. */ +const DEMO_KEYS: Record<OrderKind, string> = { touch: storageKey('demoAt'), pad: storageKey('demoAtPad'), keys: storageKey('demoAtKeys') } + +function savedDemoIndex(kind = demoKind()): number { try { - const n = Number(localStorage.getItem(DEMO_KEY)) - return !testing && Number.isInteger(n) && n >= 0 && n < DEMO_ORDER.length ? n : 0 + const n = Number(localStorage.getItem(DEMO_KEYS[kind])) + return !testing && Number.isInteger(n) && n >= 0 && n < orderFor(kind).length ? n : 0 } catch { return 0 } @@ -1249,7 +1643,7 @@ function savedDemoIndex(): number { function saveDemoIndex(i: number): void { if (testing) return try { - localStorage.setItem(DEMO_KEY, String(i)) + localStorage.setItem(DEMO_KEYS[demoKind()], String(i)) } catch { // No storage: the demo starts from the beginning next time. } @@ -1258,7 +1652,7 @@ function saveDemoIndex(i: number): void { /** * Shuffle (user request, 2026-10-31): the scenes in a random order instead of DEMO_ORDER's, the * choice kept across visits (also not for a test URL). `scenePlan` is the order they play in: the - * indices into DEMO_ORDER, in order or shuffled; ◀ ▶ and the automatic next scene follow it. + * indices into DEMO_ORDER (the pad's: PAD_DEMO_ORDER), in order or shuffled; ◀ ▶ and the automatic next scene follow it. */ const SHUFFLE_KEY = storageKey('demoShuffle') let demoShuffle = ((): boolean => { @@ -1272,7 +1666,7 @@ let scenePlan: number[] = [] /** `scenePlan` made again: in order, or shuffled with `first` (the scene playing) kept at the front. */ function makeScenePlan(first: number): void { - scenePlan = DEMO_ORDER.map((_, i) => i) + scenePlan = demoOrder().map((_, i) => i) if (!demoShuffle) return const rest = scenePlan.filter((i) => i !== first) for (let i = rest.length - 1; i > 0; i--) { @@ -1376,7 +1770,26 @@ function vacuum(): void { } crtScreen.classList.toggle('interference', on) } -finger.innerHTML = `<img src="${handIcon}" alt="" draggable="false">` +/** The finger, or for the mouse demo (Q61) the Cursor Pack's pointer (cursor_none) in its place (style.css `#demo-finger.mouse`). */ +function drawPointer(): void { + const mouse = demo?.input === 'mouse' + finger.classList.toggle('mouse', mouse) + finger.innerHTML = `<img src="${mouse ? mouseIcon : handIcon}" alt="" draggable="false">` +} +drawPointer() +/** The input button (Q61): names what the demo is shown for, and one click picks the next (ui/demoInput.ts). */ +const inputButton = document.getElementById('demo-input')! +function syncInputButton(): void { + const label = demoInputLabel(demoInput()) + if (inputButton.textContent !== label) inputButton.textContent = label +} +inputButton.addEventListener('click', () => { + 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) +}) /** * 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, @@ -1385,11 +1798,17 @@ finger.innerHTML = `<img src="${handIcon}" alt="" draggable="false">` const DEMO_GAP = 8 const DEMO_PANEL_GAP = 24 -/** Places the action bar under the board, for this frame's camera. */ -function placeDemoBar(): void { +/** Places the action bar under the board (a zoomed scene: under the view, the board runs past it), for this frame's camera. */ +function placeDemoBar(view: { x: number; y: number; width: number; height: number }): void { const r = mainCanvas.getBoundingClientRect() const topLeft = worldToScreen(vec2(0, state.height)) const bottomRight = worldToScreen(vec2(state.width, 0)) + if (demoZoom > 1) { + actionBar.style.left = `${r.left + view.x}px` + actionBar.style.width = `${view.width}px` + actionBar.style.top = `${r.top + view.y + view.height + DEMO_GAP}px` + return + } actionBar.style.left = `${r.left + topLeft.x}px` actionBar.style.width = `${bottomRight.x - topLeft.x}px` actionBar.style.top = `${r.top + bottomRight.y + DEMO_GAP}px` @@ -1427,12 +1846,13 @@ const SCENE_ICONS: Record<SceneKind, string> = { */ function prepareScene(index: number): void { if (!demo) return - const i = (index + DEMO_ORDER.length) % DEMO_ORDER.length + const order = demoOrder() + const i = (index + order.length) % order.length const upcoming: NonNullable<typeof demo.upcoming> = { index: i, scene: undefined } demo.upcoming = upcoming - const item = DEMO_ORDER[i]! + const item = 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) => { + void makeScene(isStill(item) ? STILL_ROOMS[item] : item, demoSeed++).then((scene) => { upcoming.scene = scene }) } @@ -1443,11 +1863,14 @@ function prepareScene(index: number): void { */ function nextScene(): void { if (!demo) return + // A pad took the demo, or gave it back: the other order of scenes (the pad's from its start, the touch one from its saved place). + const switched = syncDemoInput() + if (switched !== null) return prepareScene(switched) if (!demo.upcoming) prepareScene(adjacentScene(demo.index, 1)) const { index, scene } = demo.upcoming! if (scene === undefined) return if (scene === null) return prepareScene(adjacentScene(index, 1)) - const item = DEMO_ORDER[index]! + const item = demoOrder()[index]! demo.index = index saveDemoIndex(index) demoCount.textContent = `${scenePlan.indexOf(index) + 1} / ${scenePlan.length}` @@ -1459,17 +1882,23 @@ function nextScene(): void { demo.next = 0 demo.beats = [] demo.at = time + DEMO_LEAD_SECONDS + demo.cursor = null + demoPad.releaseAll() + demoKeys.releaseAll() state = scene.start - if (item === 'topBar' || item === 'whatIsThat') { + if (isStill(item)) { 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 }, charge: { kind: 'pierce', left: 3 } } - demo.beats = item === 'topBar' ? topBarTour() : whatIsThat() + demo.beats = item === 'topBar' ? topBarTour() : item === 'whatIsThat' ? whatIsThat() : isKeyStill(item) ? keyBeats(item) : padBeats(item) demoTime.set(0, 0) } else { demoTime.set(0, scene.actions.length, scene.moment) const icon = SCENE_ICONS[item] - setCaption(spriteUrl(icon), MONO_ICONS.has(icon), SCENES[item].caption) + const { caption, padCaption, keyCaption } = SCENES[item] + if (demo.pad && padCaption) setPadCaption(padCaption, spriteUrl(icon), MONO_ICONS.has(icon)) + else if (demo.keys && keyCaption) setCaption(spriteUrl(icon), MONO_ICONS.has(icon), keyCaptionHtml(keyCaption, spriteUrl)) + else setCaption(spriteUrl(icon), MONO_ICONS.has(icon), caption) } afterChange() // The finger comes back beside the player, where the eye should be (user request, 2026-10-24). @@ -1478,6 +1907,7 @@ function nextScene(): void { /** The demo's caption: an icon (an image URL; `mono` for a one-colour ink icon) and the words. */ function setCaption(icon: string, mono: boolean, text: string): void { + padCaptionNow = null demoCaption.innerHTML = `<img class="demo-icon${mono ? ' mono' : ''}" src="${icon}" alt="" draggable="false"><span>${text}</span>` } @@ -1547,19 +1977,276 @@ function whatIsThat(): Beat[] { ] } +/** + * The demo for a gamepad (Q58): the virtual controller (ui/padOverlay.ts) lights the buttons that + * play each turn, and a dashed cursor on the board shows where the d-pad has put it, instead of the + * finger. These beats call the game's own functions directly, never the pad's handlers (which + * would end the demo, as a real press does). + */ +// A press stays lit PAD_LIT_SECONDS (ui/padOverlay.ts) and lets go before the next one starts. + +/** The pad demo's cursor on `tile`, over the tiles it can rest on (null: not shown). */ +function demoCursorAt(tile: Vec | null): void { + if (demo) demo.cursor = tile ? { tile, tiles: actionableTiles(state), actions: tileActions(state, tile) } : null +} + +/** A press lit on the virtual controller, for `seconds` (default PAD_LIT_SECONDS). */ +function showPress(press: PadPress, seconds?: number): void { + const { part, stickDir } = pressPart(press) + demoPad.press(part, seconds, stickDir) +} + +/** A d-pad press as two beats: the button lit and let go, then the cursor moves (`move`) and rests PAD_AFTER_SECONDS (or `rest`) so it can be seen. */ +function padHopBeats(press: PadPress, move: () => void, rest = PAD_AFTER_SECONDS): Beat[] { + return [{ run: () => showPress(press), then: PAD_LIT_SECONDS }, { run: move, then: rest }] +} + +/** The pad version of a turn (padDemo.ts `padPlan`): the cursor on the player, d-pad hops to the tile, the face button, then the turn. Null: the pad cannot show it (a dialog's choice), so the turn is just played. */ +function padBeatsFor(g: Gesture, action: Action, last: boolean): Beat[] | null { + const plan = padPlan(state, action, g) + if (!plan) return null + const beats: Beat[] = [{ run: () => demoCursorAt(state.player), then: 0.4 }] + const play = playBeat(action, last) + for (const { press, cursor: at } of plan) { + if (press.kind !== 'face') { + beats.push(...padHopBeats(press, () => demoCursorAt(at))) + continue + } + const el = actionBar.querySelector<HTMLElement>(`[data-action=${press.button}]`) + beats.push({ run: () => (showPress(press), el?.classList.add('pressed')), then: PAD_LIT_SECONDS }) + beats.push({ ...play, run: () => (el?.classList.remove('pressed'), play.run()) }) + } + return beats +} + +/** The pad caption on show (a scene's text with `{...}` pictures), kept to draw again if the kind of pad becomes known mid-scene. */ +let padCaptionNow: { text: string; icon: string; mono: boolean } | null = null + +function setPadCaption(text: string, icon: string, mono = false): void { + setCaption(icon, mono, padCaptionHtml(text, demoFamily(), spriteUrl)) + padCaptionNow = { text, icon, mono } +} + +/** The real menu over the scene, with the run's own state in it (it names the level, turn and seed), not the scene's. */ +function menuOverScene(): void { + const scene = state + state = demo!.real + showMenu() + state = scene +} + +/** The looking-around scenes (stick, look): the room shown this many times larger than the view, and how long a stick push is lit while the view slides. */ +const LOOK_ZOOM = 2 +const LOOK_SECONDS = PAD_LIT_SECONDS + 0.4 + +/** The scripts of the pad's own scenes, on a still room. */ +function padBeats(item: Exclude<StillItem, 'topBar' | 'whatIsThat' | KeyStill>): Beat[] { + const move = spriteUrl('icon_move') + const hold: Beat = { run: () => demoCursorAt(null), then: DEMO_HOLD_SECONDS } + const start: Beat = { run: () => demoCursorAt(state.player), then: 0.6 } + const hops = (count: number): Beat[] => + padHops(state, count).flatMap(({ press, to }) => padHopBeats(press, () => demoCursorAt(to), 0.5)) + if (item === 'padCursor') { + setPadCaption(PAD_STILL_CAPTIONS.padCursor, move) + return [start, ...hops(5), hold] + } + if (item === 'padL1') { + setPadCaption(PAD_STILL_CAPTIONS.padL1, move) + return [start, ...hops(2), { run: () => showPress({ kind: 'shoulder', side: 'l' }), then: PAD_LIT_SECONDS }, { run: () => demoCursorAt(state.player), then: 1.2 }, hold] + } + if (item === 'padR1') { + setPadCaption(PAD_STILL_CAPTIONS.padR1, questionIcon, true) + const enemy = state.enemies[0]!.pos + // The d-pad direction toward the enemy: the aim jumps between the tiles that have something to explain. + const ex = enemy.x - state.player.x + const ey = enemy.y - state.player.y + const toward: PadPress = Math.abs(ex) >= Math.abs(ey) ? { kind: 'dir', dx: ex < 0 ? -1 : 1, dy: 0 } : { kind: 'dir', dx: 0, dy: ey < 0 ? -1 : 1 } + return [ + { run: () => (showPress({ kind: 'shoulder', side: 'r' }), inspect()), then: PAD_LIT_SECONDS }, + ...padHopBeats(toward, () => (demo!.cursor = { tile: enemy, tiles: explainableTiles(state), actions: [] }), 0.5), + { run: () => showPress({ kind: 'face', button: 'move' }), then: PAD_LIT_SECONDS }, + { run: () => (demoCursorAt(null), tapTile(enemy)), then: INFO_SECONDS }, + { run: () => (showPress({ kind: 'face', button: 'wait' }), closeDialog()), then: DEMO_HOLD_SECONDS }, + ] + } + if (item === 'padStick' || item === 'padLook') { + // The demo shows a room whole, so these two zoom in (updateCamera's `zoom`): the stick then slides the view like it does in play. + demoZoom = LOOK_ZOOM + const look = (dir: 'left' | 'right' | 'up' | 'down', dx: number, dy: number): Beat => ({ + run: () => (showPress({ kind: 'stick', dir }, LOOK_SECONDS), panView(dx, dy), endPan()), + then: LOOK_SECONDS + 0.3, + }) + // Pressing the stick in takes the view back to the player. + const back: Beat = { run: () => (showPress({ kind: 'stick', dir: 'press' }), resetPan()), then: DEMO_HOLD_SECONDS } + if (item === 'padStick') { + setPadCaption(PAD_STILL_CAPTIONS.padStick, move) + const n = Math.ceil(state.width / (2 * LOOK_ZOOM)) + return [{ ...start, run: () => demoCursorAt(null) }, look('left', -n, 0), look('right', 2 * n, 0), look('up', 0, n), look('down', 0, -2 * n), back] + } + // padLook: toward the enemy, a stick push for each way (the view stays where the room lets it). + setPadCaption(PAD_STILL_CAPTIONS.padLook, questionIcon, true) + const enemy = state.enemies[0]!.pos + const ex = enemy.x - state.player.x + const ey = enemy.y - state.player.y + return [ + { ...start, run: () => demoCursorAt(null) }, + ...(ex ? [look(ex < 0 ? 'left' : 'right', ex, 0)] : []), + ...(ey ? [look(ey < 0 ? 'up' : 'down', 0, -ey)] : []), + back, + ] + } + // padStart: the real menu opens over the scene (the run's own state in it, not the scene's) and closes again. + setPadCaption(PAD_STILL_CAPTIONS.padStart, spriteUrl('icon_wait')) + return [ + { run: () => (showPress({ kind: 'start' }), menuOverScene()), then: 2.4 }, + { run: () => (showPress({ kind: 'start' }), closeDialog()), then: DEMO_HOLD_SECONDS }, + ] +} + +/** A key lit on the key overlay, for `seconds` (default PAD_LIT_SECONDS): a Shift + arrow lights both. */ +function showKey(press: KeyPress, seconds?: number): void { + for (const part of pressParts(press)) demoKeys.press(part, seconds) +} + +/** The keyboard's cursor hop as two beats, like the pad's: the keys lit, then the cursor moves (null: put away) and rests so it can be seen. */ +function keyHopBeats(press: KeyPress, cursor: Vec | null, rest = PAD_AFTER_SECONDS): Beat[] { + return [{ run: () => showKey(press), then: PAD_LIT_SECONDS }, { run: () => demoCursorAt(cursor), then: rest }] +} + +/** + * The keyboard version of a turn (keyDemo.ts `keyPlan`): the fewest key presses that play it, each + * lit on the key overlay; the action bar's button is marked with its key, a picked key shows its + * tiles, and Shift + arrow shows the dashed cursor. Null: the keys cannot show it (a dialog's choice), so the turn is just played. + */ +function keyBeatsFor(g: Gesture, action: Action, last: boolean): Beat[] | null { + const plan = keyPlan(state, action, g) + if (!plan) return null + const play = playBeat(action, last) + const beats: Beat[] = [{ run: () => demoCursorAt(null), then: 0.3 }] + for (const [i, { press, cursor, picked }] of plan.entries()) { + const el = press.kind === 'key' ? actionBar.querySelector<HTMLElement>(`[data-action=${press.button}]`) : null + if (i === plan.length - 1) { + beats.push({ run: () => (showKey(press), el?.classList.add('pressed')), then: PAD_LIT_SECONDS }) + beats.push({ ...play, run: () => (el?.classList.remove('pressed'), play.run()) }) + } else if (press.kind === 'hop') { + beats.push(...keyHopBeats(press, cursor)) + } else { + // An action key picked: its button marked, then its tiles light up. + beats.push({ run: () => (showKey(press), el?.classList.add('pressed')), then: PAD_LIT_SECONDS }) + beats.push({ run: () => (el?.classList.remove('pressed'), (selected = picked), afterChange()), then: PICKED_SECONDS }) + } + } + return beats +} + +/** The scripts of the keyboard's own scenes, on a still room. */ +function keyBeats(item: KeyStill): Beat[] { + const move = spriteUrl('icon_move') + const text = KEY_STILL_CAPTIONS[item] + const hold: Beat = { run: () => demoCursorAt(null), then: DEMO_HOLD_SECONDS } + const caption = (icon: string, mono = false): void => setCaption(icon, mono, keyCaptionHtml(text, spriteUrl)) + if (item === 'keyCursor') { + caption(move) + const hops = padHops(state, 5).flatMap(({ press, to }) => (press.kind === 'dir' ? [{ press: { kind: 'hop', dx: press.dx, dy: press.dy } as KeyPress, to }] : [])) + // The first Shift + arrow only shows the cursor, on you; the next ones hop it. + const wake = hops[0] ? keyHopBeats(hops[0].press, state.player, 0.5) : [] + return [...wake, ...hops.flatMap(({ press, to }) => keyHopBeats(press, to, 0.5)), hold] + } + if (item === 'keyInspect') { + caption(questionIcon, true) + const enemy = state.enemies[0]!.pos + // The arrow toward the enemy: the aim jumps between the tiles that have something to explain. + const ex = enemy.x - state.player.x + const ey = enemy.y - state.player.y + const toward: KeyPress = Math.abs(ex) >= Math.abs(ey) ? { kind: 'arrow', dx: ex < 0 ? -1 : 1, dy: 0 } : { kind: 'arrow', dx: 0, dy: ey < 0 ? -1 : 1 } + return [ + { run: () => (showKey({ kind: 'inspect' }), inspect()), then: PAD_LIT_SECONDS }, + { run: () => showKey(toward), then: PAD_LIT_SECONDS }, + { run: () => (demo!.cursor = { tile: enemy, tiles: explainableTiles(state), actions: [] }), then: 0.5 }, + { run: () => showKey({ kind: 'enter' }), then: PAD_LIT_SECONDS }, + { run: () => (demoCursorAt(null), tapTile(enemy)), then: INFO_SECONDS }, + { run: () => (showKey({ kind: 'esc' }), closeDialog()), then: DEMO_HOLD_SECONDS }, + ] + } + if (item === 'keyLook') { + // Zoomed in, like the stick's scenes: Alt + arrow slides the view like it does in play. + demoZoom = LOOK_ZOOM + caption(move) + const n = Math.ceil(state.width / (2 * LOOK_ZOOM)) + const look = (dx: Step, dy: Step, tx: number, ty: number): Beat => ({ + run: () => (showKey({ kind: 'pan', dx, dy }, LOOK_SECONDS), panView(tx, ty), endPan()), + then: LOOK_SECONDS + 0.3, + }) + return [ + { run: () => demoCursorAt(null), then: 0.6 }, + look(-1, 0, -n, 0), look(1, 0, 2 * n, 0), look(0, -1, 0, n), look(0, 1, 0, -2 * n), + // Home takes the view back to the player. + { run: () => (showKey({ kind: 'home' }), resetPan()), then: DEMO_HOLD_SECONDS }, + ] + } + // keyMenu: the real menu opens over the scene (the run's own state in it, not the scene's) and closes again. + caption(spriteUrl('icon_wait')) + return [ + { run: () => (showKey({ kind: 'esc' }), menuOverScene()), then: 2.4 }, + { run: () => (showKey({ kind: 'esc' }), closeDialog()), then: DEMO_HOLD_SECONDS }, + ] +} + +/** + * The input changed under the demo (a pad or the keyboard took it, or a mouse, touch or key gave + * it back): the other order of scenes (the pad's and the keyboard's from their first, the touch one + * from where it was kept), the controller or key overlay shown, or the finger. Returns the scene to + * start with, or null if nothing changed. Only between scenes (nextScene, skipTo), never mid-script. + */ +function syncDemoInput(): number | null { + if (!demo) return null + const input = demoInput() + const pad = isPadInput(input) + const keys = isKeysInput(input) + // The same kind of scenes: the finger becomes the mouse's pointer (or back), or the controller takes another family. + if (demo.input !== input) { + demo.input = input + drawPointer() + syncDemoFamily() + } + if (demo.pad === pad && demo.keys === keys) return null + demo.pad = pad + demo.keys = keys + demo.cursor = null + demoPad.releaseAll() + demoPad.el.hidden = !pad + demoKeys.releaseAll() + demoKeys.el.hidden = !keys + finger.classList.add('away') + const at = pad || keys ? 0 : savedDemoIndex('touch') + makeScenePlan(at) + demo.index = at + demo.upcoming = null + demoCount.textContent = `${scenePlan.indexOf(at) + 1} / ${scenePlan.length}` + return at +} + /** * 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 { - const at = savedDemoIndex() + const input = demoInput() + const pad = isPadInput(input) + const keys = isKeysInput(input) + const at = savedDemoIndex(orderKind(pad, keys)) + demo = { scene: null, index: at, upcoming: null, next: 0, at: 0, real: state, beats: [], rest: false, pad, keys, input, cursor: null } + drawPointer() makeScenePlan(at) shuffleButton.setAttribute('aria-pressed', String(demoShuffle)) - 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 demoTitle.hidden = false + demoPad.el.hidden = !pad + demoKeys.el.hidden = !keys + syncDemoFamily() + syncInputButton() finger.hidden = false finger.classList.add('away') youTag.hidden = false @@ -1571,11 +2258,17 @@ function startDemo(): void { /** Called every frame: the script's next beat when it is due, the next turn's script, or the next scene. */ function demoTick(): void { + // The button's name follows the input in use. + if (demo) syncInputButton() if (!demo || time < demo.at) return if (!demo.beats.length) { 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) + const [g, action, last] = [demo.scene.gestures[i]!, demo.scene.actions[i]!, demo.next === demo.scene.actions.length] as const + // For a pad or the keyboard: its presses; a turn it cannot show (a dialog's choice) is just played. + demo.beats = demo.pad + ? (padBeatsFor(g, action, last) ?? [playBeat(action, last)]) + : demo.keys ? (keyBeatsFor(g, action, last) ?? [playBeat(action, last)]) : beatsFor(g, action, last) // The bar fills over the turn's beats, so it moves all the time rather than a turn at a time. demoTime.set(demo.next, demo.scene.actions.length, demo.scene.moment, demo.beats.reduce((sum, b) => sum + b.then, 0)) } @@ -1584,24 +2277,29 @@ function demoTick(): void { demo.at = time + beat.then } -/** - * The script for one turn: the finger plays its gesture, as the real controls take it (main.ts - * pointer handlers, `tapTile`, `pick`), and the turn is played when the finger lets go. - */ -function beatsFor(g: Gesture, action: Action, last: boolean): Beat[] { - const after = last ? DEMO_HOLD_SECONDS : DEMO_SETTLE_SECONDS - const play: Beat = { +/** The beat that plays a turn, after the finger (or the pad's cursor) has done its part. */ +function playBeat(action: Action, last: boolean): Beat { + return { run: () => { fingerUp() hover = null armed = null selected = null + if (demo) demo.cursor = null animateTurn(step(state, action), action) afterChange() if (last) finger.classList.add('away') }, - then: after, + then: last ? DEMO_HOLD_SECONDS : DEMO_SETTLE_SECONDS, } +} + +/** + * The script for one turn: the finger plays its gesture, as the real controls take it (main.ts + * pointer handlers, `tapTile`, `pick`), and the turn is played when the finger lets go. + */ +function beatsFor(g: Gesture, action: Action, last: boolean): Beat[] { + const play = playBeat(action, last) /** The finger to a tile and down on it, then (with `press`) whatever pressing it shows. */ const onTile = (tile: Vec, press = () => {}, hold = TAP_SECONDS): Beat[] => [ { run: () => placeFinger(tilePoint(tile)), then: FINGER_MOVE_SECONDS }, @@ -1684,6 +2382,8 @@ function placeDemoMarks(): void { youTag.style.transform = `translate(${r.left + tip.x}px, ${r.top + tip.y}px)` if (!demo.rest) return demo.rest = false + // The pad's and the keyboard's demos have no finger (the controller or keys and the cursor show the way). + if (demo.pad || demo.keys) return placeFinger(worldToScreen(toWorld(state, state.player).add(vec2(0.55, -0.55))).add(vec2(r.left, r.top)), false) finger.classList.remove('away') } @@ -1727,6 +2427,8 @@ function placeMark(className: string): HTMLElement { */ function skipTo(index: number): void { if (!demo) return + // A pad took the demo, or gave it back: the other order (the pad's from its start, the touch one from its saved place). + const start = syncDemoInput() ?? index demo.beats = [] demo.scene = null demo.at = 0 @@ -1736,13 +2438,29 @@ function skipTo(index: number): void { fingerUp() for (const b of actionBar.querySelectorAll('.pressed')) b.classList.remove('pressed') finger.classList.add('away') - prepareScene(index) + // The pad scenes' leftovers: a lit button, the cursor, "What is that?" left on. + demoPad.releaseAll() + demoKeys.releaseAll() + demo.cursor = null + inspecting = false + // The looking-around scenes' zoom and pan. + demoZoom = 1 + resetPan() + prepareScene(start) } function endDemo(): void { if (!demo) return state = demo.real demo = null + // A pick of the input to show lasts only as long as the demo. + demoForced = null + // The pad's R1 scene calls inspect(), and a press that ends the demo can come inside its window. + inspecting = false + demoPad.releaseAll() + demoPad.el.hidden = true + demoKeys.releaseAll() + demoKeys.el.hidden = true document.body.classList.remove('demo') demoPanel.hidden = true demoTitle.hidden = true @@ -1877,6 +2595,7 @@ await engineInit( // Sliding to another tile before letting go changes the choice. Holding still on a tile // next to the player, with nothing picked, arms a bomb there instead (placed on release). mainCanvas.addEventListener('pointerdown', (e) => { + mouseHover = null hover = tileUnder(e) drag = { x: e.clientX, y: e.clientY, panning: false } startPress(hover) @@ -1901,6 +2620,7 @@ await engineInit( const p = tileUnder(e) if (e.buttons && !(p && pressTile && eq(p, pressTile))) startPress(null) hover = p + mouseHover = e.pointerType === 'mouse' && !e.buttons ? p : null }) mainCanvas.addEventListener('pointerup', (e) => { const panned = drag?.panning @@ -1916,6 +2636,7 @@ await engineInit( }) mainCanvas.addEventListener('pointerleave', () => { hover = null + mouseHover = null drag = null endPan() startPress(null) @@ -1929,11 +2650,11 @@ await engineInit( // In the demo the board and the action bar under it fit above the caption panel, with room // between them and it. const panel = demoPanel.getBoundingClientRect().height - const view = updateCamera(state, demo ? { top: controls.insets().top, bottom: panel + DEMO_PANEL_GAP + DEMO_GAP + controls.insets().bottom } : controls.insets(), snapCamera, !!demo) - // The bars round the view (style.css #view-mask), in play only: the demo's rooms are seen whole. - viewMask.hidden = !!demo + const view = updateCamera(state, demo ? { top: controls.insets().top, bottom: panel + DEMO_PANEL_GAP + DEMO_GAP + controls.insets().bottom } : controls.insets(), snapCamera, !!demo, demoZoom) + // The bars round the view (style.css #view-mask), in play only, and in the demo's zoomed scenes: its other rooms are seen whole. + viewMask.hidden = !!demo && demoZoom === 1 viewMask.style.cssText = `left:${view.x}px;top:${view.y}px;width:${view.width}px;height:${view.height}px` - if (demo) placeDemoBar() + if (demo) placeDemoBar(view) // The caption panel's height, for what sits above it (style.css: the CRT screen, the dialog), // 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). @@ -1946,10 +2667,14 @@ await engineInit( if (demo) vacuum() // The controller's cursor is always on while the pad plays the board; its label sits over the action bar. ensureCursor() - const shownCursor = aimShown() ? cursor : null - const lines = shownCursor ? cursorLines(shownCursor.actions) : [] + const labelCursor = aimShown() ? cursor : null + // The pad demo's cursor marks the bar's buttons too, but has no label (the demo's panel sits where it would). + const shownCursor = labelCursor ?? (demo?.pad || demo?.keys ? demo.cursor : null) + const lines = labelCursor ? cursorLines(labelCursor.actions) : [] // Empty (so the box is hidden) when there are no lines to show. - const note = shownCursor ? (lines.length ? JSON.stringify([padKind, lines]) : '') : aimShown() ? aim!.label : '' + // The keyboard's cursor names its keys (key caps) where the pad's draws the buttons' pictures. + const keysUi = inputMode === 'keys' + const note = labelCursor ? (lines.length ? JSON.stringify([keysUi ? 'keys' : padKind, lines]) : '') : aimShown() ? (keysUi ? `Enter|${aim!.label}` : aim!.label) : '' // The action bar's buttons show what the cursor's tile offers: greyed, or marked when it costs a life. const barKey = shownCursor ? `${shownCursor.tile.x},${shownCursor.tile.y}:${shownCursor.actions.map((a) => (a.possible ? (a.deadly ? 'd' : 'o') : 'x')).join('')}` : '' if (barKey !== lastBarKey) { @@ -1959,30 +2684,34 @@ await engineInit( if (lastNote !== note) { lastNote = note padLabelHeight = -1 - // The cursor's lines each start with the button's own picture; the inspect aim's is plain text. - if (shownCursor) { + // The cursor's lines each start with the button's own picture (the keyboard's: its key cap); the inspect aim's is plain text (the keyboard's: after the Enter key cap). + if (labelCursor) { padLabel.replaceChildren( ...lines.map((l) => { const row = document.createElement('div') row.className = 'pad-line' - const img = document.createElement('img') - img.className = 'pad-glyph' - img.alt = '' - img.draggable = false - img.src = spriteUrl(glyphFor(padKind, l.button === 'charged' ? 'charge' : l.button)) const text = document.createElement('span') text.textContent = l.label - row.append(img, text) + row.append(keysUi ? keyCap(KEY_CAPS[l.button]) : buttonPicture(spriteUrl(glyphFor(padKind, l.button === 'charged' ? 'charge' : l.button))), text) return row }), ) + } else if (keysUi && note) { + const row = document.createElement('div') + row.className = 'pad-line' + const text = document.createElement('span') + text.textContent = aim!.label + row.append(keyCap('Enter'), text) + padLabel.replaceChildren(row) } else padLabel.textContent = note padLabel.hidden = !note } if (note) padLabel.style.bottom = `${controls.insets().bottom + 8}px` // The hint for L1 and R1, just above the label (or the bar, with no label): shown with the cursor. const l1 = l1Hint({ inspecting, picked: !!(selected || destination), onPlayer: !cursor || eq(cursor.tile, state.player) }) - const hintKey = aimShown() ? `${padKind}|${l1.text}|${l1.dim}` : '' + // The keyboard's Esc, on the player with nothing to undo, puts the cursor away (padTiles `l1Hint` says "Back to you" for the pad). + const escText = keysUi && l1.text === 'Back to you' && l1.dim ? 'Hide cursor' : l1.text + const hintKey = aimShown() ? `${keysUi ? 'keys' : padKind}|${escText}|${l1.dim && !keysUi}|${inspecting}` : '' if (hintKey !== lastHintKey) { lastHintKey = hintKey padHint.hidden = !hintKey @@ -1990,15 +2719,17 @@ await engineInit( const item = (side: 'l' | 'r', text: string, dim: boolean): HTMLElement => { const el = document.createElement('span') el.className = dim ? 'pad-hint-item dim' : 'pad-hint-item' - const img = document.createElement('img') - img.className = 'pad-glyph' - img.alt = '' - img.draggable = false - img.src = spriteUrl(shoulderGlyph(padKind, side)) - el.append(img, text) + el.append(buttonPicture(spriteUrl(shoulderGlyph(padKind, side))), text) + return el + } + const keyItem = (cap: string, text: string): HTMLElement => { + const el = document.createElement('span') + el.className = 'pad-hint-item' + el.append(keyCap(cap), text) return el } - padHint.replaceChildren(item('l', l1.text, l1.dim), item('r', 'What is that?', false)) + if (keysUi) padHint.replaceChildren(keyItem('Shift + arrows', inspecting ? 'Next tile' : 'Pick a tile'), keyItem('Esc', escText), keyItem('?', inspecting ? 'Explain it' : 'What is that?')) + else padHint.replaceChildren(item('l', l1.text, l1.dim), item('r', 'What is that?', false)) } } if (hintKey) { @@ -2008,17 +2739,20 @@ await engineInit( } snapCamera = false pruneAnimation(anim, time) - autoFinish() + autopilotTick() autoStep() })), () => {}, timed(guarded('draw', () => { // A dialog's clip first: it uses the canvas for a moment, then clears it (ui/clip.ts). drawClips() + // The controller's cursor: the pad's own, or the pad demo's. + const shown = aimShown() ? cursor : demo?.pad || demo?.keys ? demo.cursor : null drawBoard({ - state, danger, selected, hover: aimShown() && !cursor && aim?.target ? aim.target : hover, target: aimShown() ? (cursor?.tile ?? aim!.target ?? state.player) : null, destination, path, armed, ...anim, deadly: deadlyTargets, refused, + state, danger, selected, hover: aimShown() && !cursor && aim?.target ? aim.target : hover, target: aimShown() ? (cursor?.tile ?? aim!.target ?? state.player) : (shown?.tile ?? null), destination, path, armed, ...anim, deadly: deadlyTargets, refused, // The controller's cursor: every actionable tile blue, and the result of the first action there, faded. - lit: aimShown() && cursor ? cursor.tiles : undefined, deadlyCursor: aimShown() && !!cursor && cursorDeadly(cursor.actions), ghost: aimShown() && cursor ? ghostAction(cursor.actions, cursor.tile, state.player) : null, + preview: previewNow(), + lit: shown?.tiles, faded: shown?.far && !selected ? shown.far.map((f) => f.tile) : undefined, deadlyCursor: !!shown && cursorDeadly(shown.actions), ghost: shown ? ghostAction(shown.actions, shown.tile, state.player) : null, // A deadly next move shows as a blocked one: the first tile red. route: destination && !deadlyAutoMove ? autoRoute(state, destination, safeStep) : null, pathBlocked: (!!path?.[0] && !safeStep(path[0])) || deadlyAutoMove, diff --git a/src/render/board.ts b/src/render/board.ts index 454c029..e31d9e8 100644 --- a/src/render/board.ts +++ b/src/render/board.ts @@ -48,6 +48,10 @@ export interface View { selected: ActionId | null /** Tiles lit blue with nothing picked: the controller's cursor shows every actionable tile. */ lit?: Vec[] + /** Auto-move's way for a tile only looked at, nothing set: drawn as the real one is (ui/routePreview.ts). */ + preview?: { way: Vec[]; blocked: boolean } | null + /** Tiles tinted a fainter blue: where auto-move can walk to (ui/padTiles.ts `farTiles`), for the pad's and keyboard's cursor. */ + faded?: Vec[] /** The cursor's tile is red: everything possible there would cost a life. */ deadlyCursor?: boolean /** An action and tile to preview as if hovered (the faded bomb, blast or player), with nothing picked: the controller's cursor. */ @@ -100,6 +104,8 @@ const C = { dangerNext: rgb(1, 0.35, 0.2, 0.18), dangerLater: rgb(0.92, 0.25, 0.15, 0.65), target: rgb(0.25, 0.62, 0.87, 0.4), + /** Where auto-move can walk to, for the cursor: the blue, much fainter. */ + targetFar: rgb(0.25, 0.62, 0.87, 0.16), /** Auto-move's way, drawn as a line of arrows over its blue tiles. */ pathLine: rgb(0.13, 0.42, 0.72, 0.9), targetHover: rgb(0.25, 0.62, 0.87, 0.7), @@ -252,6 +258,7 @@ export function drawBoard(view: View): void { drawCross(toWorld(s, t), C.deadlyCross) } else drawRect(toWorld(s, t), vec2(0.94), on ? C.targetHover : C.target) } + for (const t of view.faded ?? []) drawRect(toWorld(s, t), vec2(0.94), C.targetFar) for (const t of view.lit ?? []) drawRect(toWorld(s, t), vec2(0.94), C.target) // The controller's cursor on a tile where everything possible costs a life: red with a cross, as a deadly target is. if (view.deadlyCursor && view.target) { @@ -268,9 +275,12 @@ export function drawBoard(view: View): void { // (user request, 2026-10-23). The next step is red when it isn't safe to take. // Since 2026-10-25 it shows where the player will land, move by move: with Run or Teleport, only // the tiles landed on (user report: every tile lit up though every other one is jumped). - if (view.destination && view.path) { - const way = view.route?.length ? view.route : view.path - const blocked = !view.route?.length && view.pathBlocked + // The same drawing, for a way only looked at (`view.preview`: the cursor on a faded tile, the mouse over one), with no destination set. + const shown = view.destination && view.path + ? { way: view.route?.length ? view.route : view.path, blocked: !view.route?.length && !!view.pathBlocked } + : view.preview + if (shown) { + const { way, blocked } = shown for (const [i, t] of way.entries()) drawRect(toWorld(s, t), vec2(0.94), i === 0 && blocked ? C.blocked : C.target) drawPath(s, way, blocked) } diff --git a/src/render/camera.ts b/src/render/camera.ts index edcca05..66482a5 100644 --- a/src/render/camera.ts +++ b/src/render/camera.ts @@ -91,13 +91,17 @@ export function viewRect(): ViewRect { * The view between the HUD and the action bar. `whole`: fit the whole board instead (the demo's * small rooms, which are made to be seen at once). `snap` jumps straight there, for the first * frame of a level. Returns the view's place on the screen, in CSS pixels, for the bars around it. + * `zoom` (above 1, with `whole`): the demo's controller scenes that look around, showing 1/zoom of + * the room's width and height, which the stick (or a drag) then moves like it does in play. */ -export function updateCamera(s: GameState, insets: Insets, snap = false, whole = false): { x: number; y: number; width: number; height: number } { +export function updateCamera(s: GameState, insets: Insets, snap = false, whole = false, zoom = 1): { x: number; y: number; width: number; height: number } { const width = mainCanvasSize.x const height = mainCanvasSize.y const available = Math.max(1, height - insets.top - insets.bottom) - const columns = whole ? s.width : VIEW_COLUMNS - const rows = whole ? s.height : VIEW_ROWS + const columns = whole ? s.width / zoom : VIEW_COLUMNS + const rows = whole ? s.height / zoom : VIEW_ROWS + // A whole room has nothing to scroll, unless it is zoomed. + const scrolls = !whole || zoom > 1 const scale = Math.min(available / rows, width / columns) setCameraScale(scale) @@ -108,10 +112,10 @@ export function updateCamera(s: GameState, insets: Insets, snap = false, whole = const base = { x: s.player.x + 0.5, y: s.height - s.player.y - 0.5 } if (!held) pan = { x: 0, y: 0 } const target = { - x: follow(base.x + (whole ? 0 : pan.x), s.width, columns), - y: follow(base.y + (whole ? 0 : pan.y), s.height, rows), + x: follow(base.x + (scrolls ? pan.x : 0), s.width, columns), + y: follow(base.y + (scrolls ? pan.y : 0), s.height, rows), } - if (!whole) pan = { x: target.x - base.x, y: target.y - base.y } + if (scrolls) pan = { x: target.x - base.x, y: target.y - base.y } aim = target center = !center || snap || panning ? target : { x: center.x + (target.x - center.x) * 0.2, y: center.y + (target.y - center.y) * 0.2 } rect = { left: center.x - columns / 2, right: center.x + columns / 2, bottom: center.y - rows / 2, top: center.y + rows / 2 } diff --git a/src/sim/autopilot.ts b/src/sim/autopilot.ts new file mode 100644 index 0000000..e9648da --- /dev/null +++ b/src/sim/autopilot.ts @@ -0,0 +1,142 @@ +import { isDeadly, type Danger } from './danger' +import { autoMoveAction, playerPath, safeStep } from './path' +import { cellAt, eq, idx } from './state' +import type { Action, GameState, Vec } from './types' + +/** Up to this many targets the cheapest order is searched exhaustively; beyond, nearest-first. */ +const SEARCH_MAX = 10 + +export interface AutopilotPlan { + /** What it is doing: gathering what lies about, waiting for crates to crumble or bombs to go off, or only the exit is left. */ + phase: 'gather' | 'crates' | 'bombs' | 'exit' + /** Where it is heading now (a coin, charge or gem; the exit), null while it waits for crates or bombs. */ + goal: Vec | null + /** The turn to take, null if there is none that is safe (boxed in, or no way). */ + action: Action | null +} + +/** + * What the autopilot gathers (Q73, user request, 2026-11-02): the place of every drop (coins, + * charges) and every gem lying on open floor. Not the exit: the walk ends there, and the last step + * picks up whatever lies on it. A gem still under a crate is not yet a target. + */ +export function autopilotTargets(s: GameState): Vec[] { + const out: Vec[] = [] + for (const p of [...s.drops.map((d) => d.pos), ...s.gems.filter((g) => cellAt(s, g.pos) === 'floor').map((g) => g.pos)]) { + if (!out.some((q) => eq(q, p))) out.push(p) + } + return out +} + +/** A walk from some tile: its length in turns, how often it turns a corner, and which targets lie on it. */ +interface Leg { + turns: number + corners: number + /** Bit mask of the targets stepped on along the way (the destination's included). */ + hits: number +} + +/** The cost of a leg as one number: turns first, corners only break ties between equal turns. */ +const legCost = (l: Leg): number => l.turns * 10000 + l.corners + +/** The walk from `from` to `to`, null if there is none (the open exit is only ever walked onto as the end). */ +function leg(s: GameState, from: Vec, to: Vec, targets: Vec[]): Leg | null { + const path = playerPath({ ...s, player: from }, to) + if (!path) return null + let corners = 0 + let hits = 0 + let at = from + let heading = '' + for (const p of path) { + const d = `${p.x - at.x},${p.y - at.y}` + if (heading && d !== heading) corners++ + heading = d + at = p + targets.forEach((t, i) => { + if (eq(t, p)) hits |= 1 << i + }) + } + return { turns: path.length, corners, hits } +} + +/** + * The next target: the first of the order that takes the fewest turns in all, ending at the exit + * (Pavel's choice, 2026-11-02: not plain nearest-first, which can zigzag). Distances are real walks + * (`playerPath`, as auto-move walks them); a target lying on the walk to another is collected on + * the way, free. Equal turns: fewer corners (counted inside each walk), then the smaller y, then + * the smaller x, so replays agree. Beyond `SEARCH_MAX` targets: the nearest, by the same tie-breaks. + * `targets` are all reachable from the player. + */ +function nextTarget(s: GameState, targets: Vec[]): Vec { + const n = targets.length + const first = targets.map((t) => leg(s, s.player, t, targets)!) + const byPlace = targets.map((_, i) => i).sort((a, b) => targets[a]!.y - targets[b]!.y || targets[a]!.x - targets[b]!.x) + if (n > SEARCH_MAX) { + let best = byPlace[0]! + for (const i of byPlace) if (legCost(first[i]!) < legCost(first[best]!)) best = i + return targets[best]! + } + const between = targets.map((a) => targets.map((b) => (a === b ? null : leg(s, a, b, targets)))) + const exits = targets.map((t) => leg(s, t, s.exit, targets)) + // With no way to the exit from anywhere (crates still crumbling), only the gathering is ordered. + const toExit = (i: number): number => (exits.every((e) => !e) ? 0 : exits[i] ? legCost(exits[i]!) : Infinity) + const full = (1 << n) - 1 + const memo = new Map<number, number>() + /** The cost of the cheapest finish from target `i` with `done` collected. */ + const rest = (done: number, i: number): number => { + if (done === full) return toExit(i) + const key = done * n + i + const known = memo.get(key) + if (known !== undefined) return known + let best = Infinity + for (let j = 0; j < n; j++) { + const l = between[i]![j] + if (done & (1 << j) || !l) continue + best = Math.min(best, legCost(l) + rest(done | l.hits | (1 << j), j)) + } + // Nothing left can be reached from here: go for the exit. + if (best === Infinity) best = toExit(i) + memo.set(key, best) + return best + } + let best = byPlace[0]! + let bestCost = Infinity + for (const i of byPlace) { + const cost = legCost(first[i]!) + rest(first[i]!.hits | (1 << i), i) + if (cost < bestCost) { + best = i + bestCost = cost + } + } + return targets[best]! +} + +/** + * The autopilot's turn (Q73): once the last enemy is gone, walk to every coin, charge and gem + * lying about, then to the exit, one ordinary turn at a time, planned afresh each turn from the + * state at hand (flame, bombs and blocked tiles change the next step). Targets with no way to them + * are skipped. While a gem is still under a crate that is crumbling, or the way to the exit is + * shut by crates that are, it waits; so it does while every target is cut off by a bomb still + * ticking or flame still burning (both stop the walk until they are gone). The step is auto-move's (`autoMoveAction`: a Run when owned, + * never onto the open exit early, only onto `safeStep` tiles). If there is none, it waits, unless + * waiting kills too: then `action` is null. + */ +export function autopilotPlan(s: GameState, danger: Danger): AutopilotPlan { + const reachable = autopilotTargets(s).filter((t) => playerPath(s, t)) + const wait: Action = { id: 'wait' } + const waitOrNull = (): Action | null => (isDeadly(s, wait) ? null : wait) + if (reachable.length === 0 && s.crumbling.length) { + const coveredGem = s.gems.some((g) => s.crumbling.some((c) => c.cell === idx(s, g.pos))) + // Nothing to fetch yet: a gem uncovered soon, a target still walled in, or no way to the exit. + if (coveredGem || autopilotTargets(s).length > 0 || !playerPath(s, s.exit)) return { phase: 'crates', goal: null, action: waitOrNull() } + } + // A bomb still ticking (or flame still burning) is a wall to the walk: what lies behind it is not lost, it waits. + if (reachable.length === 0 && (s.bombs.length > 0 || s.burning.length > 0) && (autopilotTargets(s).length > 0 || !playerPath(s, s.exit))) { + return { phase: 'bombs', goal: null, action: waitOrNull() } + } + const gathering = reachable.length > 0 + const goal = gathering ? nextTarget(s, reachable) : s.exit + const action = autoMoveAction(s, goal, (p) => safeStep(s, danger, p)) + const usable = action && !isDeadly(s, action) ? action : waitOrNull() + return { phase: gathering ? 'gather' : 'exit', goal, action: usable } +} diff --git a/src/sim/demo.ts b/src/sim/demo.ts index 89fcf63..b67fc1a 100644 --- a/src/sim/demo.ts +++ b/src/sim/demo.ts @@ -112,6 +112,10 @@ interface Turn { interface SceneDef { /** One line under the scene. */ caption: string + /** The caption when a gamepad is in use (Q58): `{dpad}`, `{move}` ... stand for the controller's pictures (ui/padGlyph.ts `padCaptionHtml`). Absent: `caption` is used. */ + padCaption?: string + /** The caption for the keyboard demo (Q65): `{arrows}`, `{M}`, `{shift}` ... stand for key pictures (ui/keyDemo.ts `keyCaptionHtml`). Absent: `caption` is used. */ + keyCaption?: string enemies: EnemyKind[] /** What the player starts with, or what lies near them. */ prepare?: (s: GameState, rng: Rng) => void @@ -135,6 +139,12 @@ interface SceneDef { choice?: boolean } +/** The scenes about the controls alone, which the gamepad's demo has its own version of (Q58). */ +export const PAD_CONTROL_KINDS: readonly SceneKind[] = ['tapMove', 'bombButton', 'wait'] + +/** The scenes the gamepad's demo leaves out for now: they ask a dialog's question (the sell-or-swap choice), which the pad demo cannot show yet. */ +export const PAD_LATER: readonly SceneKind[] = ['chargeSwap'] + const WIDTH = 9 const HEIGHT = 7 /** How many turns a random game may take, how many are tried per room, and how many rooms. */ @@ -178,6 +188,8 @@ export const SCENES: Record<SceneKind, SceneDef> = { // The controls (user request, 2026-10-24), shown first. tapMove: { caption: 'Tap a tile next to you to step onto it.', + padCaption: 'Move the cursor to a tile next to you with {dpad}, then press {move} to step there.', + keyCaption: 'Press {arrows} (or W A S D) to step onto the tile next to you that way.', enemies: ['blob'], steer: (s, rng) => playOf(SCENES.tapMove, s, randomTarget(s, rng, 'move')), shows: (t, seen) => t.action.id === 'move' && (seen.used.get('move') ?? 0) >= MIN_STEPS, @@ -189,12 +201,16 @@ export const SCENES: Record<SceneKind, SceneDef> = { }, bombButton: { caption: 'Or press Bomb, then tap where the bomb goes.', + padCaption: 'Put the cursor next to you and press {bomb}: a bomb goes there. Then get away!', + keyCaption: 'Press {B}, then {arrows}: a bomb goes on the tile next to you that way. Then get away!', enemies: ['blob'], bombButton: true, shows: (t) => explosions(t).length > 0, }, wait: { caption: 'Press Wait to stay put for a turn, and let an enemy walk into your blast.', + padCaption: 'The cursor starts on you each turn: press {wait} to stay put, and let an enemy walk into your blast.', + keyCaption: 'Press {space} to stay put for a turn, and let an enemy walk into your blast.', enemies: ['blob', 'seeker'], shows: (t, seen) => seen.used.has('wait') && kills(t).length > 0, }, @@ -288,36 +304,48 @@ export const SCENES: Record<SceneKind, SceneDef> = { }, run: { caption: 'Run: press Move, then tap two tiles away — out of trouble fast.', + padCaption: 'Run: put the cursor two tiles away and press {move} — out of trouble fast.', + keyCaption: 'Run: {M} and {arrows} reach a tile two away, or put the {shift} cursor there and press {M} — out of trouble fast.', enemies: ['seeker', 'blob'], prepare: (s) => (s.powers.run = true), shows: (t) => t.action.id === 'run' && escaped(t), }, kick: { caption: 'Kick: tap a bomb next to you and it slides away until something stops it.', + padCaption: 'Kick: put the cursor on a bomb next to you and press {move}: it slides away until something stops it.', + keyCaption: 'Kick: press {arrows} into a bomb next to you, or put the {shift} cursor on it and press {M}: it slides away until something stops it.', enemies: ['blob', 'drifter'], prepare: (s) => (s.powers.kick = true), shows: (t, seen) => seen.used.has('kick') && kills(t).length > 0, }, throw: { caption: 'Throw: press Bomb, then tap two tiles away — over a crate, a bomb or an enemy.', + padCaption: 'Throw: put the cursor two tiles away and press {bomb} — over a crate, a bomb or an enemy.', + keyCaption: 'Throw: {B} and {arrows} reach a tile two away, or put the {shift} cursor there and press {B} — over a crate, a bomb or an enemy.', enemies: ['blob', 'seeker'], prepare: (s) => (s.powers.throw = true), shows: (t, seen) => seen.used.has('throw') && kills(t).length > 0, }, teleport: { caption: 'Teleport: press the charge button, then tap any empty tile up to 3 steps away, over anything.', + padCaption: 'Teleport: put the cursor on any empty tile up to 3 steps away and press {charge}, over anything.', + keyCaption: 'Teleport: put the {shift} cursor on any empty tile up to 3 steps away and press {V}, over anything.', enemies: ['seeker', 'blob'], prepare: (s) => (s.charge = { kind: 'teleport', left: 3 }), shows: (t) => t.action.id === 'teleport' && escaped(t), }, diagonal: { caption: 'Diagonal: tap a corner tile to step diagonally, even between two blocked tiles.', + padCaption: 'Diagonal: put the cursor on a corner tile and press {move} to step diagonally, even between two blocked tiles.', + keyCaption: 'Diagonal: put the {shift} cursor on a corner tile and press {M} to step diagonally, even between two blocked tiles.', enemies: ['seeker', 'blob'], prepare: (s) => (s.powers.diagonal = true), shows: (t) => t.action.id === 'diagonal' && escaped(t), }, detonate: { caption: 'Detonator: tap your bomb to set it off right when an enemy walks in.', + padCaption: 'Detonator: put the cursor on your bomb and press {bomb} to set it off right when an enemy walks in.', + keyCaption: 'Detonator: put the {shift} cursor on your bomb and press {B} to set it off right when an enemy walks in.', enemies: ['blob', 'seeker'], // A long fuse, so the Detonator is what sets the bomb off. prepare: (s) => ((s.powers.detonate = true), (s.stats.fuse = 6)), @@ -326,6 +354,8 @@ export const SCENES: Record<SceneKind, SceneDef> = { // Added 2026-10-24 (user request), with their power-ups and charge. rpg: { caption: 'RPG: press Bomb, then tap where it lands — it flies as far as it can.', + padCaption: 'RPG: put the cursor where it lands and press {bomb} — it flies as far as it can.', + keyCaption: 'RPG: {B} and {arrows} aim it, or put the {shift} cursor where it lands and press {B} — it flies as far as it can.', enemies: ['blob', 'drifter'], prepare: (s) => (s.powers.rpg = true), shows: (t, seen) => seen.used.has('rpg') && kills(t).length > 0, @@ -338,12 +368,16 @@ export const SCENES: Record<SceneKind, SceneDef> = { }, slide: { caption: 'Slide: tap where you stop — you slide until a wall, crate or bomb.', + padCaption: 'Slide: put the cursor where you stop and press {move} — you slide until a wall, crate or bomb.', + keyCaption: 'Slide: {M} and {arrows} aim it, or put the {shift} cursor where you stop and press {M} — you slide until a wall, crate or bomb.', enemies: ['blob'], prepare: (s) => (s.powers.slide = true), shows: (t) => t.action.id === 'slide' && steps(t.prev.player, t.next.player) >= 3, }, icbm: { caption: 'ICBM: press the charge button, then tap where it lands — it goes off at once.', + padCaption: 'ICBM: put the cursor where it lands and press {charge} — it goes off at once.', + keyCaption: 'ICBM: put the {shift} cursor where it lands and press {V} — it goes off at once.', enemies: ['blob', 'drifter'], prepare: dropping('icbm', 1), shows: (t) => t.action.id === 'icbm' && kills(t).length > 0, @@ -419,12 +453,16 @@ export const SCENES: Record<SceneKind, SceneDef> = { }, freeze: { caption: 'Freeze: press the charge button and every enemy stops in its tracks. Then bomb them.', + padCaption: 'Freeze: press {charge} and every enemy stops in its tracks. Then bomb them.', + keyCaption: 'Freeze: press {V} twice (the second confirms) and every enemy stops in its tracks. Then bomb them.', enemies: ['seeker', 'blob', 'blob'], prepare: dropping('freeze'), shows: (t, seen) => seen.picked.has('freeze') && kills(t).length > 0 && t.prev.frozen > 0, }, decoy: { caption: 'Decoy: press the charge button and put down a copy of you that Seekers chase instead.', + padCaption: 'Decoy: press {charge} and put down a copy of you that Seekers chase instead.', + keyCaption: 'Decoy: {V} and {arrows} (or the {shift} cursor, then {V}) put down a copy of you that Seekers chase instead.', enemies: ['seeker', 'seeker'], prepare: dropping('decoy'), shows: (t, seen) => seen.picked.has('decoy') && kills(t).length > 0 && !!t.prev.decoy, diff --git a/src/sim/record.ts b/src/sim/record.ts index 8ce7857..6750fdd 100644 --- a/src/sim/record.ts +++ b/src/sim/record.ts @@ -6,7 +6,7 @@ import { DEFAULT_OPTIONS, type Action, type ChargeId, type GameEvent, type GameS * Bumped whenever a rule change would make an old record replay differently. Records from * another version can't be trusted to replay (PLAN.md §11). */ -export const SIM_VERSION = 42 +export const SIM_VERSION = 43 /** Everything needed to reproduce a run exactly: mid-run saves, replays, certificates. */ export interface RunRecord { diff --git a/src/sim/testScenes.ts b/src/sim/testScenes.ts index 57c7dd4..592135c 100644 --- a/src/sim/testScenes.ts +++ b/src/sim/testScenes.ts @@ -1,3 +1,5 @@ +import { autopilotPlan } from './autopilot' +import { predictDanger } from './danger' import type { Gesture, Scene } from './demo' import { board } from './picture' import { idx } from './state' @@ -16,7 +18,7 @@ import type { Action, GameState, Vec } from './types' */ export type TestKind = | 'testTwoGemsSlide' | 'testTwoGemsOil' | 'testGemsAndCharge' | 'testGemUnderRun' - | 'testGemDeath' | 'testOilBomb' | 'testOilEnemy' | 'testOilExit' + | 'testGemDeath' | 'testOilBomb' | 'testOilEnemy' | 'testOilExit' | 'testAutopilot' | 'testAutopilotCrates' /** A turn of the script: an action, or one worked out from the state it is played in (a choice from the offer). */ type Turn = Action | ((s: GameState) => Action) @@ -35,6 +37,8 @@ export interface TestScene { /** The first power-up on the gem's offer. */ const pick: Turn = (s) => ({ id: 'choose', kind: s.offer![0]! }) const sell: Turn = { id: 'sellCharge' } +/** The autopilot's next turn (sim/autopilot.ts). */ +const autopilot: Turn = (s) => autopilotPlan(s, predictDanger(s)).action! const at = (x: number, y: number): Vec => ({ x, y }) /** Oil under every gem, bomb and enemy of the picture, so a spill runs through them. */ const oilUnder = (s: GameState): void => { @@ -143,6 +147,36 @@ export const TEST_SCENES: Record<TestKind, TestScene> = { }, script: [{ id: 'move', target: at(2, 1) }], }, + testAutopilot: { + name: 'Autopilot gathers, then the exit', + caption: 'Test: with the last enemy gone, the autopilot walks to a coin, a charge (one is held), a gem, then the exit.', + expect: ['It steps on the coin, then the charge, and the charge’s sell-or-swap opens.', 'After the answer it walks on to the gem, whose choice opens.', 'After that it walks to the exit, and the level is cleared; every step is a turn.'], + icon: 'door_open_top', + picture: ` + ########### + #P.$...%.E# + ###########`, + setup: (s) => { + s.charge = { kind: 'freeze', left: 1 } + s.drops.push({ pos: at(5, 1), kind: 'nuke' }) + }, + script: [autopilot, autopilot, autopilot, autopilot, sell, autopilot, autopilot, pick, autopilot, autopilot], + }, + testAutopilotCrates: { + name: 'Autopilot waits for the crates', + caption: 'Test: a gem lies under a crate that is crumbling. The autopilot waits for it, then fetches the gem, then goes to the exit.', + expect: ['It waits while the crate is still there.', 'Once the crate is gone it walks to the gem, whose choice opens.', 'After the answer it walks to the exit.'], + icon: 'gem_green', + picture: ` + ########### + #P..%....E# + ###########`, + setup: (s) => { + s.cells[idx(s, at(4, 1))] = 'block' + s.crumbling.push({ cell: idx(s, at(4, 1)), turns: 2 }) + }, + script: [autopilot, autopilot, autopilot, autopilot, autopilot, pick, autopilot, autopilot, autopilot, autopilot, autopilot], + }, } export const TEST_KINDS = Object.keys(TEST_SCENES) as TestKind[] diff --git a/src/sim/turn.ts b/src/sim/turn.ts index bf18c1b..78f56d6 100644 --- a/src/sim/turn.ts +++ b/src/sim/turn.ts @@ -58,12 +58,6 @@ export function step(prev: GameState, action: Action): StepResult { if (s.offer && s.status !== 'dead') throw new Error(`${action.id} while a power-up is on offer`) if (s.chargeOffer && s.status !== 'dead') throw new Error(`${action.id} while a charge is on offer`) - // The hoover: what lies on the floor flies to the player. No turn passes. - if (action.id === 'hoover') { - if (s.status !== 'playing' || !canHoover(s)) throw new Error('hoover with nothing to gather') - hoover(s, events) - return { state: s, events } - } // The shop: buying and restocking take no turn; going on leaves it for the next level. if (action.id === 'buy' || action.id === 'reroll') { if (s.status !== 'cleared' || !s.shop) throw new Error(`${action.id} with no shop open`) @@ -406,26 +400,6 @@ function pickUp(s: GameState, p: Vec, events: GameEvent[]): void { } } -/** Whether there is something for the hoover to gather: the level is won bar the exit, no choice is pending. */ -export function canHoover(s: GameState): boolean { - if (s.enemies.length > 0 || s.offer || s.chargeOffer) return false - return s.drops.length > 0 || s.gems.some((g) => cellAt(s, g.pos) === 'floor') -} - -/** - * The magic that gathers every coin, charge and uncovered gem on the floor once the last enemy is - * gone (user request, 2026-11-01: with a controller, walking to each was a pain). Taken as if - * walked over, so gems queue their offers (`waitingGems`). A charge that has to be chosen about (one - * is already carried) stops it: the rest stays on the floor, and the UI hoovers again once answered. - */ -function hoover(s: GameState, events: GameEvent[]): void { - for (const d of [...s.drops]) { - pickUp(s, d.pos, events) - if (s.chargeOffer) return - } - for (const g of [...s.gems]) pickUp(s, g.pos, events) -} - /** Once the offer and any charge choice are answered, the next gem waiting (if any) makes its offer. */ function nextOffer(s: GameState, events: GameEvent[]): void { if (s.offer || s.chargeOffer) return diff --git a/src/sim/types.ts b/src/sim/types.ts index 3e4dca0..3807657 100644 --- a/src/sim/types.ts +++ b/src/sim/types.ts @@ -338,8 +338,6 @@ export type Action = /** In the shop (sim/shop.ts): buy one of its items, or restock it. */ | { id: 'buy'; kind: OfferKind } | { id: 'reroll' } - /** Once every enemy is gone: the magic that gathers what lies on the floor (`hoover`). No turn passes. */ - | { id: 'hoover' } /** The shop between levels (sim/shop.ts, PLAN.md §8): what it has, what is sold, how often restocked. */ export interface Shop { diff --git a/src/style.css b/src/style.css index 074f3f8..8a86cfb 100644 --- a/src/style.css +++ b/src/style.css @@ -120,6 +120,29 @@ img { } } +/* On a phone, with the fullscreen button there, three round buttons stacked would be taller than + the three rows beside them (and grow the bar), so all three shrink to fit, 31px. */ +@media (max-width: 600px) { + #hud-buttons:has(#fullscreen-button:not([hidden])) { + gap: 2px; + } + + #hud-buttons:has(#fullscreen-button:not([hidden])) button { + width: 31px; + height: 31px; + padding-bottom: 2px; + } + + #hud-buttons:has(#fullscreen-button:not([hidden])) #info-button img { + width: 16px; + height: 16px; + } + + #hud-buttons:has(#fullscreen-button:not([hidden])) #menu-button span { + width: 13px; + } +} + .hud-row > span { display: flex; flex: none; @@ -241,6 +264,81 @@ img { } } +/* The autopilot's note (main.ts `autopilotNote`): top centre below the top bar, whose height sets `top`. Not a popover, so dialogs cover it. */ +#autopilot { + position: fixed; + top: 56px; + left: 50%; + z-index: 3; + display: flex; + align-items: center; + gap: 10px; + max-width: calc(100vw - 32px); + transform: translateX(-50%); + padding: 6px 8px 6px 16px; + border: 2px solid var(--ink); + border-radius: 16px; + background: #ece3cc; + color: var(--ink); + font-weight: bold; + pointer-events: none; +} + +#autopilot[hidden] { + display: none; +} + +#autopilot .autopilot-hint { + display: flex; + align-items: center; +} + +#autopilot .pad-glyph { + width: 28px; + height: 28px; + filter: drop-shadow(0 0 1px rgb(0 0 0 / 0.55)); +} + +#autopilot .key-cap { + box-sizing: border-box; + min-width: 28px; + height: 26px; + padding: 0 7px; + border: 2px solid var(--ink); + border-bottom-width: 4px; + border-radius: 6px; + background: #fff; + color: var(--ink); + font: bold 12px/22px ui-monospace, monospace; + text-align: center; +} + +/* A touch screen has no Esc key. */ +@media (pointer: coarse) { + #autopilot .key-cap { + display: none; + } +} + +#autopilot button { + pointer-events: auto; + padding: 2px 12px; + border: 2px solid var(--ink); + border-radius: 10px; + background: var(--panel); + box-shadow: 0 2px 0 #1e1e27; + color: var(--ink); + font: inherit; + font-size: 13px; + font-weight: 800; +} + +/* While it walks the action bar does nothing (main.ts `boardLocked`). */ +body.autopilot #actions { + opacity: 0.4; + pointer-events: none; +} + /* The controller's target: what confirming does (main.ts `padLabel`), above the action bar; bottom is set from the bar's height. */ #pad-label { white-space: pre-line; @@ -626,6 +724,35 @@ body.pad #actions kbd { opacity: 0.45; } +/* Fullscreen (main.ts; hidden where the browser has none): a round button like the other two, + with Kenney's Game Icons "larger"; "smaller" while in fullscreen. */ +#fullscreen-button { + width: 40px; + height: 40px; + padding: 0 0 3px; + border: 0; + background: + url('../assets/kenney-icons/larger.png') center / 20px no-repeat, + url('../assets/kenney-ui/Grey/button_round_depth_flat.png') center / contain no-repeat; + cursor: pointer; +} + +#fullscreen-button[hidden] { + display: none; +} + +@media (max-width: 600px) { + #fullscreen-button { + background-size: 16px, contain; + } +} + +#fullscreen-button[aria-pressed='true'] { + background-image: + url('../assets/kenney-icons/smaller.png'), + url('../assets/kenney-ui/Grey/button_round_depth_flat.png'); +} + #menu-button { display: grid; place-content: center; @@ -787,7 +914,7 @@ body.pad #actions kbd { text-align: left; } -.almanac-entry img { +.almanac-entry > img { width: 44px; height: 44px; } @@ -1202,13 +1329,39 @@ body.demo #hud { opacity: 1; } -#demo .demo-hint { - margin: 10px 0 0; +/* 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; +} + +#demo #demo-input { + width: auto; + height: 32px; + padding: 0 14px; + border-radius: 16px; + color: inherit; + font: inherit; + font-size: 0.9rem; + font-weight: 700; +} + +/* The demo's "how to play" line, under the title (it is inside #demo-title, so the TV's room already counts it). */ +#demo-title .demo-hint { + display: block; + margin-top: 2px; color: #ffc83d; + font-size: 16px; + line-height: 24px; font-weight: 800; animation: demo-hint 1.6s ease-in-out infinite; } +#demo-title .pad-glyph { + width: 22px; + height: 22px; + vertical-align: middle; +} + @keyframes demo-hint { 50% { opacity: 0.45; @@ -1216,7 +1369,7 @@ body.demo #hud { } @media (prefers-reduced-motion: reduce) { - #demo .demo-hint { + #demo-title .demo-hint { animation: none; } } @@ -1271,6 +1424,197 @@ body.demo #actions { filter: drop-shadow(0 2px 3px rgb(0 0 0 / 0.35)); } +/* The demo's virtual controller (ui/padOverlay.ts): a line drawing of a pad (assets/controller-art/, + CC BY 4.0) with Kenney's button pictures on the drawn buttons. Every size comes from --pad-size, the pad's width; the + pictures' places are set inline by padOverlay.ts. The pictures lie under the drawing, so its + lines stay on top; a pressed button is lit (.on). */ +.pad-overlay { + --pad-size: 220px; + position: relative; + width: var(--pad-size); + flex: none; + pointer-events: none; +} + +/* The drawing is thin white-grey line art: a dark halo and a glow make it read on the dark demo. */ +.pad-overlay > .pad-body { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + opacity: 0.95; + filter: drop-shadow(0 0 1px #fff) drop-shadow(0 0 1.5px rgb(0 0 0 / 0.9)); +} + +.pad-overlay.compact { + --pad-size: 120px; +} + +.pad-overlay[hidden] { + display: none; +} + +/* In the demo's panel, under the words; in a clip, over the board's lower corner. */ +#demo > .pad-overlay { + margin: 4px auto 0; +} + +.clip-frame > .pad-overlay { + position: absolute; + right: 4px; + bottom: 4px; + z-index: 2; +} + +/* Hold to confirm (ui/padHold.ts, holdRing.ts, padOverlay.ts): a ring round a face button that fills + clockwise from 12 o'clock as the button is held (--p, 0..1) and turns green when full. The + progress is the point, so it stays under reduced motion; nothing here animates. */ +.hold-ring { + position: absolute; + aspect-ratio: 1; + border-radius: 50%; + pointer-events: none; + background: conic-gradient(#ffd23f calc(var(--p, 0) * 360deg), rgb(255 255 255 / 0.28) 0); + -webkit-mask: radial-gradient(farthest-side, transparent calc(100% - 5px), #000 calc(100% - 4px)); + mask: radial-gradient(farthest-side, transparent calc(100% - 5px), #000 calc(100% - 4px)); + filter: drop-shadow(0 0 2px rgb(0 0 0 / 0.6)); +} + +.hold-ring.full { + background: #5cff9b; +} + +.hold-ring[hidden] { + display: none; +} + +.hold-ring.floating { + position: fixed; + z-index: 1000; +} + +/* On the demo's controller: the same place as the button (padOverlay.ts sets it), a size bigger. */ +.pad-overlay .hold-ring { + transform: scale(1.4); +} + +/* The controller's pictures inside the demo's words. */ +#demo-caption .pad-glyph { + width: 28px; + height: 28px; + vertical-align: middle; +} + +/* The white pictures (d-pad, stick, shoulders, Start) are white at rest: the d-pad and the stick + show a press by the red arm in their own picture, the others by a scale and a glow. The face + buttons are grey at rest and take their colour only while pressed (Pavel, 2026-11-02: a lit one + was hard to tell from an idle one when both were coloured). */ +.pad-overlay .pad-part { + position: absolute; + height: auto; + aspect-ratio: 1; + transition: opacity 0.1s, transform 0.1s, filter 0.1s; +} + +.pad-overlay .pad-part.l1.on, +.pad-overlay .pad-part.r1.on, +.pad-overlay .pad-part.start.on { + transform: scale(1.15); + filter: drop-shadow(0 0 4px #ffd23f); +} + +/* A face button's picture is clipped to its drawn circle; lit, it is drawn bigger inside the same circle (the clip grows with the scale, so it is shrunk by as much). */ +.pad-overlay .pad-part.face { + opacity: 0.85; + filter: grayscale(1) brightness(1.7); + clip-path: circle(var(--clip, 50%)); +} + +.pad-overlay .pad-part.face.on { + opacity: 1; + filter: grayscale(0) brightness(1.15) saturate(1.2); + transform: scale(1.15); + clip-path: circle(calc(var(--clip, 50%) / 1.15)); +} + +@media (prefers-reduced-motion: reduce) { + .pad-overlay .pad-part { + transition: none; + } + + .pad-overlay .pad-part.on { + transform: none; + } + + .pad-overlay .pad-part.face.on { + clip-path: circle(var(--clip, 50%)); + } +} + +/* The keyboard demo's key overlay (ui/keyOverlay.ts): Kenney's key pictures on a grid, white at rest, a + pressed key lit (.on). Left: Esc ? Home over Alt Shift Enter; middle: the arrows as an inverted T; right: M B V over Space. */ +.key-overlay { + --key: min(34px, calc((100vw - 64px) / 10.5)); + display: grid; + grid-template-columns: repeat(10, var(--key)); + grid-template-rows: repeat(2, var(--key)); + gap: 4px; + justify-content: center; + flex: none; + pointer-events: none; +} + +.key-overlay.compact { + --key: 22px; +} + +.key-overlay[hidden] { + display: none; +} + +#demo > .key-overlay { + margin: 10px auto 0; +} + +.key-overlay .key-part { + width: 100%; + height: 100%; + object-fit: contain; + border-radius: 22%; + transition: transform 0.1s, background-color 0.1s, filter 0.1s; +} + +.key-overlay .key-part.esc { grid-area: 1 / 1; } +.key-overlay .key-part.inspect { grid-area: 1 / 2; } +.key-overlay .key-part.home { grid-area: 1 / 3; } +.key-overlay .key-part.alt { grid-area: 2 / 1; } +.key-overlay .key-part.shift { grid-area: 2 / 2; } +.key-overlay .key-part.enter { grid-area: 2 / 3; } +.key-overlay .key-part.up { grid-area: 1 / 5; } +.key-overlay .key-part.left { grid-area: 2 / 4; } +.key-overlay .key-part.down { grid-area: 2 / 5; } +.key-overlay .key-part.right { grid-area: 2 / 6; } +.key-overlay .key-part.move { grid-area: 1 / 8; } +.key-overlay .key-part.bomb { grid-area: 1 / 9; } +.key-overlay .key-part.charge { grid-area: 1 / 10; } +.key-overlay .key-part.wait { grid-area: 2 / 8 / 3 / 11; } + +.key-overlay .key-part.on { + background-color: #ffd23f; + transform: scale(1.12); + filter: drop-shadow(0 0 4px #ffd23f); +} + +@media (prefers-reduced-motion: reduce) { + .key-overlay .key-part { + transition: none; + } + + .key-overlay .key-part.on { + transform: none; + } +} + /* The demo's "You" tag over the player (main.ts places it on the top of their tile, following them as they move): a green pill pointing down at them. Above the finger, so a tap on the tile over the player doesn't hide it. */ @@ -1319,6 +1663,14 @@ body.demo #actions { transform: scale(0.86); } +/* The mouse demo's pointer (Kenney's RPG sword cursor, tip at the top left): the box sits on its tip. */ +#demo-finger.mouse img { + width: 44px; + height: 48px; + margin: -3px 0 0 -3px; + transform-origin: 3px 3px; +} + /* Where the finger taps, a ripple spreads; while it holds, a ring closes in (main.ts sets its time, --hold, to the long press's). Each sits on the point, drawn by its ::before; on the screen, so above its dialog and under the glass. */ @@ -1863,8 +2215,21 @@ canvas.clip[hidden] { opacity: 1; } -#demo .timeline { - margin-top: 10px; +/* The demo's bar sits on the TV's bottom edge, inside the glass, above the caption panel (user + request, 2026-11-02; it was under the controller in the panel). Over the CRT, under the panel. */ +#demo-time { + position: fixed; + left: calc(var(--tv-left, 0px) + 18px); + right: calc(var(--tv-left, 0px) + 18px); + bottom: calc(var(--demo-panel, 0px) + 12px); + z-index: 9; + max-width: none; + margin: 0; + pointer-events: none; +} + +body:not(.demo) #demo-time { + display: none; } /* The controller in dialogs and the banner (ui/padDialog.ts): the pictures of the face buttons on @@ -1889,6 +2254,20 @@ body.pad #banner .pad-glyph { margin-left: 8px; } +/* The selected item's confirm picture (selector mode; the hold ring goes round it), and the almanac's + LB / RB pictures on the tabs they go to. */ +#dialog .sel-glyph { + margin-left: 8px; +} + +#dialog .tab-glyph { + width: 16px; + height: 16px; + margin-left: 4px; + /* The shoulder pictures are white; the tabs are light. */ + filter: invert(1); +} + /* A shop item's Buy shows its Cross picture only on the selected item. */ .shop-item .buy .pad-glyph { display: none !important; @@ -2004,3 +2383,29 @@ body.pad #actions > button[data-pad='danger']::after { #pad-hint .pad-hint-item.dim { opacity: 0.5; } + +/* A key's cap in the cursor's label and hint when the keyboard plays the board (main.ts `keyCap`), + where the pad's shows the button's picture. */ +#pad-label .key-cap, +#pad-hint .key-cap { + flex: none; + box-sizing: border-box; + min-width: 28px; + height: 26px; + padding: 0 7px; + border: 2px solid var(--ink); + border-bottom-width: 4px; + border-radius: 6px; + background: #fff; + color: var(--ink); + font: bold 12px/22px ui-monospace, monospace; + text-align: center; + white-space: nowrap; +} + +#pad-hint .key-cap { + height: 22px; + min-width: 22px; + font-size: 11px; + line-height: 18px; +} diff --git a/src/ui/almanac.ts b/src/ui/almanac.ts index 0a0e33f..546e7ff 100644 --- a/src/ui/almanac.ts +++ b/src/ui/almanac.ts @@ -7,6 +7,8 @@ import { openDialog } from './dialog' import { startClip } from './clip' import { TEST_KINDS, TEST_SCENES } from '../sim/testScenes' import { chargeToast, showToast } from './toast' +import { createPadOverlay, type PadOverlay, type PadPart } from './padOverlay' +import type { PadFamily } from './padMap' import { BOARD_TEXT, CHARGE_TEXT, ENEMY_TEXT, POWER_UP_CLIP, POWER_UP_TEXT } from './info' /** @@ -38,6 +40,26 @@ const TOAST_SAMPLES = [ { kind: 'shield' as const, name: 'Toast: a single charge', about: 'One Shield picked up, none held: just its name.', n: 1, total: 1 }, ] +/** The Tests tab's sample of the demo's virtual controller (padOverlay.ts): each tap presses the next part, each family in turn. */ +const PAD_SAMPLE_FAMILIES: [PadFamily, string][] = [['xbox', 'Xbox'], ['playstation', 'PlayStation'], ['nintendo', 'Switch']] +const PAD_SAMPLE_PARTS: PadPart[] = ['up', 'right', 'down', 'left', 'move', 'bomb', 'charge', 'wait', 'l1', 'r1', 'stick', 'start'] +const padSamples = new WeakMap<HTMLElement, { overlay: PadOverlay; presses: number }>() + +function padSample(button: HTMLElement): void { + let sample = padSamples.get(button) + if (!sample) { + const overlay = createPadOverlay('compact') + button.querySelector('div')!.append(overlay.el) + padSamples.set(button, (sample = { overlay, presses: 0 })) + } + const [family, name] = PAD_SAMPLE_FAMILIES[Math.floor(sample.presses / PAD_SAMPLE_PARTS.length) % PAD_SAMPLE_FAMILIES.length]! + const part = PAD_SAMPLE_PARTS[sample.presses % PAD_SAMPLE_PARTS.length]! + sample.presses++ + sample.overlay.setFamily(family) + sample.overlay.press(part, 0.8, part === 'stick' ? 'left' : undefined) + button.querySelector('.pad-sample-note')!.textContent = `${name}: ${part}` +} + /** The first level an enemy type can appear on. */ function firstLevel(kind: EnemyKind): number { let level = 1 @@ -79,7 +101,8 @@ function body(tab: AlmanacTab): string { '<p class="almanac-intro">Developer mode: fixed situations that check edge cases. Each clip replays the same turns; the gem and charge choices play unseen here (they open in the demo with ?dev). What to expect is under each.</p>' + TEST_KINDS.map((k) => entry(TEST_SCENES[k].icon, TEST_SCENES[k].name, [TEST_SCENES[k].caption, ...TEST_SCENES[k].expect.map((e) => `• ${e}`)], k)).join('') + '<h3 class="release-head">Interface pieces <small>tap to show</small></h3>' + - TOAST_SAMPLES.map((t, i) => `<button type="button" class="almanac-entry" data-toast="${i}"><img src="${spriteUrl(CHARGE_ICONS[t.kind])}" alt="" draggable="false"><div><strong>${t.name}</strong><p>${t.about}</p></div></button>`).join('') + TOAST_SAMPLES.map((t, i) => `<button type="button" class="almanac-entry" data-toast="${i}"><img src="${spriteUrl(CHARGE_ICONS[t.kind])}" alt="" draggable="false"><div><strong>${t.name}</strong><p>${t.about}</p></div></button>`).join('') + + `<button type="button" class="almanac-entry" data-pad-sample><img src="${spriteUrl('xbox_button_color_a')}" alt="" draggable="false"><div><strong>Demo controller</strong><p>The pad the gamepad demo and clips show. Each tap presses the next part, on Xbox, then PlayStation, then Switch.</p><p class="pad-sample-note">Tap to start.</p></div></button>` } else { list = '<p class="almanac-intro">Every enemy shows its next 3 moves as arrows. Flame kills them; touching one costs you a life.</p>' + @@ -135,6 +158,8 @@ dialogBody.addEventListener('click', (e) => { dialogBody.scrollTop = 0 return } + const padButton = target.closest<HTMLElement>('[data-pad-sample]') + if (padButton) return padSample(padButton) const toast = target.closest<HTMLElement>('[data-toast]') if (toast) { const t = TOAST_SAMPLES[Number(toast.dataset.toast)]! diff --git a/src/ui/clip.ts b/src/ui/clip.ts index 0fa763b..92151df 100644 --- a/src/ui/clip.ts +++ b/src/ui/clip.ts @@ -11,6 +11,10 @@ import { isCharge } from '../sim/types' import { CHARGE_TEXT, POWER_UP_TEXT } from './info' import { makeScene } from './scenes' import { timelineAfter, type Timeline } from './timeline' +import { padPlan, pressPart } from './padDemo' +import { createPadOverlay, PAD_AFTER_SECONDS, PAD_LIT_SECONDS, type PadOverlay } from './padOverlay' +import type { PadFamily } from './padMap' +import { actionableTiles, cursorDeadly, ghostAction, tileActions, type TileAction } from './padTiles' /** * A dialog's clip (PLAN.md §13c, user request, 2026-10-24): a small looping scene of what the @@ -42,6 +46,9 @@ interface Clip { anim: Animation /** The finger over the clip, where it shows how a turn is played (FINGER_KINDS); its script. */ finger: HTMLElement | null + /** With a gamepad in use (`setClipPad`), a small controller in its place (ui/padOverlay.ts), and the cursor it moves on the board. */ + pad: PadOverlay | null + cursor: { tile: Vec; tiles: Vec[]; actions: TileAction[] } | null beats: { run: () => void; then: number }[] /** What the finger's press shows on the board, as in play: a picked button's tiles, a long press's bomb. */ selected: ActionId | null @@ -75,6 +82,14 @@ const LEAD_SECONDS = 0.5 /** The clips playing: one per canvas (a dialog's, the "Ouch!" banner's). */ let clips: Clip[] = [] +/** The kind of gamepad in use (null: none), set from main.ts; clips started then show the controller, not the finger. */ +let clipPad: PadFamily | null = null + +export function setClipPad(family: PadFamily | null): void { + clipPad = family + if (family) for (const c of clips) c.pad?.setFamily(family) +} + /** * Plays a clip in `canvas` while it is shown: of `source`, a kind of scene made new each loop, or a * fixed scene replayed as it is (the player's own last turns). A canvas already playing switches. @@ -91,26 +106,45 @@ export function startClip(canvas: HTMLCanvasElement, source: SceneKind | Scene, canvas.replaceWith(frame) frame.append(canvas) } - for (const old of frame.querySelectorAll('.clip-finger, .demo-ripple, .demo-ring')) old.remove() + for (const old of frame.querySelectorAll('.clip-finger, .demo-ripple, .demo-ring, .pad-overlay')) old.remove() // A canvas started again (the shop's and the offer's cards) keeps one bar, not one more each time. if (frame.nextElementSibling?.classList.contains('timeline')) frame.nextElementSibling.remove() - let finger: HTMLElement | null = null - if (kind && FINGER_KINDS.has(kind)) { - finger = document.createElement('div') - finger.className = 'clip-finger away' - finger.innerHTML = `<img src="${handIcon}" alt="" draggable="false">` - frame.append(finger) - } + const { finger, pad } = kind && FINGER_KINDS.has(kind) ? addPointer(frame) : { finger: null, pad: null } const c: Clip = { canvas, kind, fixed: kind ? null : (source as Scene), focus, seed: Math.floor(Math.random() * 2 ** 31), scene: null, upcoming: null, state: null, timeline: kind ? timelineAfter(frame) : null, - next: 0, at: 0, anim: newAnimation(), finger, beats: [], selected: null, hover: null, armed: null, + next: 0, at: 0, anim: newAnimation(), finger, pad, cursor: null, beats: [], selected: null, hover: null, armed: null, } clips = [...clips.filter((o) => o.canvas !== canvas), c] if (c.fixed) c.upcoming = c.fixed else prepare(c) } +/** The finger, or the small controller while a gamepad is in use, added to a clip's frame. */ +function addPointer(frame: Element): { finger: HTMLElement | null; pad: PadOverlay | null } { + if (clipPad) { + const pad = createPadOverlay('compact') + pad.setFamily(clipPad) + frame.append(pad.el) + return { finger: null, pad } + } + const finger = document.createElement('div') + finger.className = 'clip-finger away' + finger.innerHTML = `<img src="${handIcon}" alt="" draggable="false">` + frame.append(finger) + return { finger, pad: null } +} + +/** A new loop after the input changed (a pad took over, or a touch gave it back): the finger or the controller, whichever is in use now. */ +function syncPointer(c: Clip): void { + const frame = c.canvas.parentElement + if (!frame || !c.kind || !FINGER_KINDS.has(c.kind) || !!c.pad === !!clipPad) return + c.finger?.remove() + c.pad?.el.remove() + c.cursor = null + ;({ finger: c.finger, pad: c.pad } = addPointer(frame)) +} + /** Stops the clip in `canvas`, if one plays there. */ export function stopClip(canvas: HTMLCanvasElement): void { canvas.parentElement?.querySelector('.clip-choice')?.remove() @@ -145,6 +179,9 @@ function tick(c: Clip): void { c.timeline?.set(0, c.scene.actions.length, c.scene.moment) c.at = time + LEAD_SECONDS c.anim = newAnimation() + c.cursor = null + syncPointer(c) + c.pad?.releaseAll() if (matchMedia('(prefers-reduced-motion: reduce)').matches) return still(c) if (c.finger) { // The finger appears beside the player, where the eye should start (as in the demo; user @@ -171,6 +208,7 @@ function tick(c: Clip): void { const last = c.next === c.scene.actions.length const action = c.scene.actions[i]! if (c.finger) c.beats = gestureBeats(c, c.scene.gestures[i]!, action, last) + else if (c.pad) c.beats = padGestureBeats(c, c.scene.gestures[i]!, action, last) else { play(c, action) // A choice the turn left open (test scenes): after a pause, so the turn is seen to come to @@ -183,7 +221,7 @@ function tick(c: Clip): void { } // The bar fills over the turn: as long as its beats, or until the next turn. const beatsSpan = c.beats.reduce((sum, b) => sum + b.then, 0) - const span = c.finger ? beatsSpan : c.beats.length ? (isAnswer(action) ? 0.2 : CHOICE_GAP_SECONDS) + beatsSpan : last ? HOLD_SECONDS : STEP_SECONDS + const span = c.finger || c.pad ? beatsSpan : c.beats.length ? (isAnswer(action) ? 0.2 : CHOICE_GAP_SECONDS) + beatsSpan : last ? HOLD_SECONDS : STEP_SECONDS c.timeline?.set(c.next, c.scene.actions.length, c.scene.moment, span) } @@ -198,6 +236,7 @@ function still(c: Clip): void { c.timeline?.set(c.next, c.next, c.scene!.moment, 0) c.at = Infinity c.finger?.classList.add('away') + if (c.pad) c.pad.el.hidden = true } const isAnswer = (a: Action): boolean => a.id === 'choose' || a.id === 'sellCharge' || a.id === 'swapCharge' || a.id === 'fireCharge' @@ -288,6 +327,34 @@ function gestureBeats(c: Clip, g: Gesture, action: Action, last: boolean): Clip[ ] } +/** + * A turn as the controller plays it (the demo's, main.ts `padBeatsFor`, on the clip's own board): + * the cursor on the player, the d-pad's hops to the tile, the face button, then the turn. A turn the + * pad cannot show (padDemo.ts `padPlan`: a dialog's choice) is just played. A hold-for-bomb clip + * shows the Bomb button here. + */ +function padGestureBeats(c: Clip, g: Gesture, action: Action, last: boolean): Clip['beats'] { + const plan = padPlan(c.state!, action, g) + const after = last ? HOLD_SECONDS : STEP_SECONDS + const done = (): void => { + c.cursor = null + play(c, action) + } + if (!plan) return [{ run: done, then: after }] + const cursorAt = (tile: Vec | null): void => { + c.cursor = tile ? { tile, tiles: actionableTiles(c.state!), actions: tileActions(c.state!, tile) } : null + } + const beats: Clip['beats'] = [{ run: () => cursorAt(c.state!.player), then: TAP_SECONDS }] + // Each press stays lit PAD_LIT_SECONDS and lets go; the cursor then moves, and rests a moment before the next. + for (const { press, cursor } of plan) { + const { part, stickDir } = pressPart(press) + beats.push({ run: () => c.pad!.press(part, PAD_LIT_SECONDS, stickDir), then: PAD_LIT_SECONDS }) + beats.push({ run: () => cursorAt(cursor), then: press.kind === 'face' ? 0 : PAD_AFTER_SECONDS }) + } + beats.push({ run: done, then: last ? HOLD_SECONDS : SETTLE_SECONDS }) + return beats +} + /** A demo room's size: a window that size is shown of a bigger room, round the clip's focus. */ const WINDOW = { w: 9, h: 7 } @@ -336,6 +403,9 @@ function drawClip(c: Clip): void { try { drawBoard({ state: s, danger: predictDanger(s), selected: c.selected, hover: c.hover, destination: null, path: null, pathBlocked: false, armed: c.armed, + // The controller's cursor, as on the board in play (a pad's clip only). + target: c.cursor?.tile ?? null, lit: c.cursor?.tiles, deadlyCursor: !!c.cursor && cursorDeadly(c.cursor.actions), + ghost: c.cursor ? ghostAction(c.cursor.actions, c.cursor.tile, s.player) : null, ...c.anim, }) glFlush() diff --git a/src/ui/controls.ts b/src/ui/controls.ts index 5f40b82..12f41c8 100644 --- a/src/ui/controls.ts +++ b/src/ui/controls.ts @@ -4,7 +4,7 @@ import { CHARGE_IDS, type ActionId, type GameState, type PowerUpKind, type Power import { CHARGE_ICONS, MONO_ICONS, POWER_UP_ICONS, spriteUrl } from '../render/sprites' import { CHARGE_TEXT, chargeLeft, deathText, type HudItem } from './info' import { glyphFor, type GlyphButton } from './padGlyph' -import type { PadFamily } from './padMap' +import { faceOf, type PadFamily } from './padMap' import { openScores } from './scores' import questionIcon from '../../assets/kenney-icons/question.png' @@ -111,7 +111,8 @@ export function createControls(handlers: Handlers) { // The controller's picture for each button, shown only while the pad is in use (style.css `.pad-glyph`). let family: PadFamily = 'generic' const glyphButton = (def: ButtonDef): GlyphButton => (def.id === 'charged' ? 'charge' : (def.id as GlyphButton)) - const glyph = (def: ButtonDef): string => `<img class="pad-glyph" src="${spriteUrl(glyphFor(family, glyphButton(def)))}" alt="" draggable="false">` + // data-face (0 bottom, 1 right, 2 left, 3 top: the raw button, so it depends on the kind of pad) is where the hold-to-confirm ring finds it (holdRing.ts). + const glyph = (def: ButtonDef): string => `<img class="pad-glyph" data-face="${faceOf(family, glyphButton(def))}" src="${spriteUrl(glyphFor(family, glyphButton(def)))}" alt="" draggable="false">` const buttons = new Map<ButtonDef, HTMLButtonElement>() for (const def of BUTTONS) { const b = document.createElement('button') @@ -289,7 +290,11 @@ export function createControls(handlers: Handlers) { /** The kind of pad in use, for the pictures on the buttons (padMap.ts `padFamily`). */ setPadFamily(next: PadFamily): void { family = next - for (const [def, b] of buttons) b.querySelector<HTMLImageElement>('.pad-glyph')?.setAttribute('src', spriteUrl(glyphFor(family, glyphButton(def)))) + for (const [def, b] of buttons) { + const img = b.querySelector<HTMLImageElement>('.pad-glyph') + img?.setAttribute('src', spriteUrl(glyphFor(family, glyphButton(def)))) + img?.setAttribute('data-face', String(faceOf(family, glyphButton(def)))) + } }, /** The controller's cursor tile: what each bar button does there, as 'ok', 'off' (not possible) or 'danger' (costs a life); null when no cursor shows. */ setPadCursor(states: Partial<Record<ActionId, 'ok' | 'off' | 'danger'>> | null): void { diff --git a/src/ui/demoInput.ts b/src/ui/demoInput.ts new file mode 100644 index 0000000..6484a7d --- /dev/null +++ b/src/ui/demoInput.ts @@ -0,0 +1,54 @@ +import type { PadFamily } from './padMap' + +/** + * The input the demo is shown for (Q61): a button on the demo screen cycles through them, so + * anyone can see how the game plays by touch, by mouse, by keyboard (Q65) or by each kind of + * controller. The pick overrides what the page detected (a pad in use or not) for the demo only; + * nothing is saved. Pure: main.ts keeps the pick and does the drawing. + */ +export type DemoInput = 'touch' | 'mouse' | 'keyboard' | 'ps5' | 'xbox' | 'switch' | 'generic' + +/** The cycle, in order, with each entry's button label and, for a controller, its picture set (ui/padMap.ts). */ +const INPUTS: readonly { id: DemoInput; label: string; family: PadFamily | null }[] = [ + { id: 'touch', label: 'Input: touch', family: null }, + { id: 'mouse', label: 'Input: mouse', family: null }, + { id: 'keyboard', label: 'Input: keyboard', family: null }, + { id: 'ps5', label: 'Input: controller: ps5', family: 'playstation' }, + { id: 'xbox', label: 'Input: controller: xbox', family: 'xbox' }, + { id: 'switch', label: 'Input: controller: switch', family: 'nintendo' }, + { id: 'generic', label: 'Input: controller: generic', family: 'generic' }, +] +export const DEMO_INPUTS: readonly DemoInput[] = INPUTS.map((i) => i.id) + +const entry = (id: DemoInput) => INPUTS.find((i) => i.id === id)! + +export const demoInputLabel = (id: DemoInput): string => entry(id).label + +/** The controller family an input shows (null for touch and mouse). */ +export const demoInputFamily = (id: DemoInput): PadFamily | null => entry(id).family + +export const isPadInput = (id: DemoInput): boolean => entry(id).family !== null + +/** The keyboard demo: its own order of scenes and the key overlay (ui/keyDemo.ts, keyOverlay.ts). */ +export const isKeysInput = (id: DemoInput): boolean => id === 'keyboard' + +/** What one click on the button picks next: the entry after `id`, wrapping round. */ +export const nextDemoInput = (id: DemoInput): DemoInput => DEMO_INPUTS[(DEMO_INPUTS.indexOf(id) + 1) % DEMO_INPUTS.length]! + +/** The entry for the pad family in use (several entries never share one). */ +export const inputForFamily = (family: PadFamily): DemoInput => INPUTS.find((i) => i.family === family)!.id + +/** + * What the demo is shown for now: the pick if there is one, else what was detected (a controller + * in use, with its family, or otherwise the touch demo, as before Q61). + */ +export function effectiveDemoInput(forced: DemoInput | null, padActive: boolean, padKind: PadFamily): DemoInput { + return forced ?? (padActive ? inputForFamily(padKind) : 'touch') +} + +/** The demo's last line for an input: a controller holds, a keyboard presses Enter or Esc, a mouse clicks, a finger taps. `{move}` is the confirm face button's picture (bottom; a Nintendo pad's right-hand A) (ui/padCaption). */ +export function demoHintText(id: DemoInput): string { + if (isPadInput(id)) return 'Hold {move} to play' + if (isKeysInput(id)) return 'Press Enter or Esc to play' + return id === 'mouse' ? 'Click anywhere to play' : 'Tap anywhere to play' +} diff --git a/src/ui/dialog.ts b/src/ui/dialog.ts index 52f64f4..7edf107 100644 --- a/src/ui/dialog.ts +++ b/src/ui/dialog.ts @@ -56,6 +56,7 @@ export function closeDialog(then?: () => void): void { if (!dialog.open || closing) return if (matchMedia('(prefers-reduced-motion: reduce)').matches) { dialog.close() + releaseFocus() then?.() return } @@ -75,9 +76,15 @@ function finishClosing(): void { clearTimeout(timer) dialog.classList.remove('closing') dialog.close() + releaseFocus() then?.() } +/** The browser hands the focus back to what had it (an action button, the menu button), which would then take Space and Enter from the game: the keyboard plays the board again (Q65). */ +function releaseFocus(): void { + if (document.activeElement instanceof HTMLButtonElement) document.activeElement.blur() +} + const closeButton = document.getElementById('dialog-close')! closeButton.addEventListener('click', () => closeDialog()) // A tap on the dimmed backdrop (the dialog element itself, outside its panel) closes it. @@ -93,6 +100,20 @@ dialog.addEventListener('wheel', (e) => e.stopPropagation(), { passive: true }) for (const type of ['touchstart', 'touchmove', 'touchend', 'touchcancel'] as const) { dialog.addEventListener(type, (e) => e.stopPropagation(), { passive: true }) } +// Enter with the focus on the panel itself (where a dialog opens, so nothing looks pre-selected) +// presses its green button (or its only one), so a keyboard player need not Tab first (Q65). A +// button that has the focus is pressed by the browser as ever; a dialog with several buttons and +// no green one needs a choice. +panel.addEventListener('keydown', (e) => { + if (e.key !== 'Enter' || e.target !== panel) return + const all = buttons.querySelectorAll<HTMLButtonElement>('button') + const primary = buttons.querySelector<HTMLButtonElement>('button.primary') ?? (all.length === 1 ? all[0] : undefined) + if (!primary) return + // The game's own Enter handler (main.ts) must not also see this key once the dialog has closed. + e.preventDefault() + e.stopPropagation() + primary.click() +}) // Esc closes it too, animated like the rest. dialog.addEventListener('cancel', (e) => { e.preventDefault() diff --git a/src/ui/gamepad.ts b/src/ui/gamepad.ts index f97fe64..a8a0558 100644 --- a/src/ui/gamepad.ts +++ b/src/ui/gamepad.ts @@ -1,3 +1,4 @@ +import { cancelHold, newHold, startHold, stepHold, type HoldEvent } from './padHold' import { mapPad, newPadMemory, padFamily, type PadButton, type PadFamily, type PadMemory, type PadSnapshot } from './padMap' /** @@ -20,16 +21,28 @@ export interface PadLike { export interface PadTargets { /** The d-pad, one step (a held one repeats); the left stick too when it is enabled (padMap.ts). */ dir(dx: -1 | 0 | 1, dy: -1 | 0 | 1): void - /** `face`: the raw face-button index (0 Cross, 1 Circle, 2 Square, 3 Triangle) when it is one. */ + /** + * `face`: the raw face-button index (0 Cross, 1 Circle, 2 Square, 3 Triangle) when it is one. A + * face button is delivered once, the moment it has been held for HOLD_SECONDS (padHold.ts), and + * that button then does nothing more until it is let go; the others at once. + */ press(button: PadButton, face?: 0 | 1 | 2 | 3): void /** The right stick, while pushed: -1..1, y positive down. Called every poll. */ look(dx: number, dy: number): void lookEnd(): void + /** A face button is being held: how full its ring is, 0..1, every poll. */ + hold?(face: 0 | 1 | 2 | 3, progress: number): void + /** The ring goes: the button was let go (a completed hold's press came earlier, when the ring filled), the hold was cancelled, or the buttons now act somewhere else. */ + holdEnd?(): void + /** A face button was let go before its ring filled (nothing happened): for a hint that it must be held. */ + tap?(face: 0 | 1 | 2 | 3): void /** Any pad input at all (the pad is now what the player is using). */ active(): void toast(title: string, text: string): void /** A standard pad connected: which kind, for the pictures on the buttons. */ family?(family: PadFamily): void + /** The pad was unplugged. */ + gone?(): void /** Reading the pad threw: polling stops (reported here, once). */ error?(error: unknown): void } @@ -52,17 +65,53 @@ export function createPadInput( targets: PadTargets, getPads: () => ArrayLike<PadLike | null> = () => navigator.getGamepads?.() ?? [], hasFocus: () => boolean = () => document.hasFocus(), + /** Where the pad's buttons act now (a dialog, the banner, the board...): a hold does not carry over to another. */ + context: () => string = () => '', ) { let current: { index: number; id: string } | null = null let memory: PadMemory = newPadMemory() let failed = false + const hold = newHold() + /** What the held face button means (padMap.ts), for when its hold completes. */ + let holdButton: PadButton | null = null + /** `context()` when the hold began. */ + let holdContext = '' /** A fresh memory (a pad that came, left or was swapped; the window lost focus), ending a look that was under way. */ function forget(): void { if (memory.looking) targets.lookEnd() + holdEvents(cancelHold(hold), null) memory = newPadMemory() } + /** The ring is up (a hold began and `holdEnd` has not been called since). */ + let ringUp = false + + function endRing(): void { + if (!ringUp) return + ringUp = false + targets.holdEnd?.() + } + + /** Passes the hold's events on; `button` is what the held face button means, for a completed one. */ + function holdEvents(events: HoldEvent[], button: PadButton | null): void { + for (const e of events) { + const face = e.face as 0 | 1 | 2 | 3 + if (e.kind === 'progress') targets.hold?.(face, e.progress) + // The ring just filled: the action happens now, and the ring stays full until the button is let go. + else if (e.kind === 'fire') { + if (button) targets.press(button, face) + } else endRing() + } + } + + /** The buttons now act somewhere else: a hold not yet complete is cancelled; a used-up button stays used up, only its ring goes. */ + function checkContext(): void { + if (hold.face === null || context() === holdContext) return + if (hold.done) endRing() + else holdEvents(cancelHold(hold), null) + } + function read(nowMs: number): void { // A pad pressed while another window has focus is not for this game. Memory restarts, so a // button held across the switch is not a press when the window comes back. @@ -70,7 +119,10 @@ export function createPadInput( const pad = choose(getPads()) if (!pad) { // Unplugged: forget it, so the next one (or this one again) is announced and starts fresh. - if (current) forget() + if (current) { + forget() + targets.gone?.() + } current = null return } @@ -91,13 +143,31 @@ export function createPadInput( axes: [...pad.axes], standard: true, } - for (const intent of mapPad(snap, memory, nowMs)) { + for (const intent of mapPad(snap, memory, nowMs, undefined, padFamily(pad.id))) { targets.active() if (intent.kind === 'dir') targets.dir(intent.dx, intent.dy) - else if (intent.kind === 'press') targets.press(intent.button, intent.face) + else if (intent.kind === 'press') { + // A face button starts a hold, and acts the moment the ring fills; the rest act at once. + // An instant button (Start, a bumper) ends a hold under way, so it cannot act on whatever that opened. + // A button already used up stays used up (until it is let go), whatever else is pressed. + if (intent.face === undefined) { + if (!hold.done) holdEvents(cancelHold(hold), null) + targets.press(intent.button) + } else if (startHold(hold, intent.face, nowMs)) { + holdButton = intent.button + holdContext = context() + ringUp = true + } + } else if (intent.kind === 'look') targets.look(intent.dx, intent.dy) else targets.lookEnd() } + checkContext() + const events = stepHold(hold, (face) => snap.buttons[face] === true, nowMs) + holdEvents(events, holdButton) + for (const e of events) if (e.kind === 'cancel') targets.tap?.(e.face as 0 | 1 | 2 | 3) + // The action may have opened a dialog, ended the demo or changed the level: the full ring does not follow it there. + checkContext() } return { @@ -108,6 +178,12 @@ export function createPadInput( } catch (error) { // Never into the game's frame: the pad is given up on, and the keyboard and touch go on. failed = true + // The ring must not stay up, nor a hold fire later. + try { + holdEvents(cancelHold(hold), null) + } catch { + // Giving up already. + } targets.error?.(error) } }, diff --git a/src/ui/holdRing.ts b/src/ui/holdRing.ts new file mode 100644 index 0000000..e40ce96 --- /dev/null +++ b/src/ui/holdRing.ts @@ -0,0 +1,95 @@ +/** + * The ring that fills while a face button is held (Q59, padHold.ts): one element on the page, + * moved over the face button's picture wherever that is (action bar, dialog, banner). Shape and + * look are CSS (style.css .hold-ring); the progress is its `--p` (0..1). + * + * Q72: the ring belongs to the picture that began it (its originator). It follows that picture + * every animation frame, not only when the pad is polled, and is gone the same frame the picture + * is: removed, hidden, zero-sized, or inside a dialog that is closing or closed. + */ +export interface HoldRing { + /** The ring around the visible picture of face button `face` inside the element `scope` (a selector), filled `progress`. Nothing visible there: no ring. */ + show(face: number, progress: number, scope: string): void + hide(): void +} + +/** What is known about a ring's originator in one frame. */ +export interface OriginState { + /** Still in the page. */ + connected: boolean + /** Drawn: it and every ancestor are displayed, with some size. */ + rendered: boolean + /** Inside a dialog that is closing (its pop-out animation) or no longer open. */ + inClosingDialog: boolean +} + +/** Whether the ring may show: only while the picture that began it is there to be seen. */ +export function ringVisible(o: OriginState): boolean { + return o.connected && o.rendered && !o.inClosingDialog +} + +/** `ringVisible`'s input, read from the page. */ +function originState(el: HTMLElement): OriginState { + const dialog = el.closest('dialog') + const r = el.getBoundingClientRect() + return { + connected: el.isConnected, + // `checkVisibility` is Chrome 105+ / Safari 17.4+; older browsers (older iPhones) fall back to the computed style. + rendered: r.width > 0 && r.height > 0 && (el.checkVisibility ? el.checkVisibility({ checkVisibilityCSS: true }) : getComputedStyle(el).visibility !== 'hidden'), + inClosingDialog: !!dialog && (!dialog.open || dialog.classList.contains('closing')), + } +} + +export function createHoldRing(): HoldRing { + const ring = document.createElement('div') + ring.className = 'hold-ring floating' + ring.hidden = true + ring.setAttribute('aria-hidden', 'true') + document.body.append(ring) + /** The picture the ring is on; null when the ring is down. */ + let origin: HTMLElement | null = null + let frame = 0 + + /** Puts the ring on its originator, or takes it down if that is gone. Every frame while it is up. */ + function follow(): void { + frame = 0 + if (!origin) return + if (!ringVisible(originState(origin))) return hide() + const r = origin.getBoundingClientRect() + const size = Math.max(r.width, r.height) * 1.5 + ring.style.left = `${r.left + r.width / 2 - size / 2}px` + ring.style.top = `${r.top + r.height / 2 - size / 2}px` + ring.style.width = ring.style.height = `${size}px` + frame = requestAnimationFrame(follow) + } + + function hide(): void { + origin = null + ring.hidden = true + if (frame) cancelAnimationFrame(frame) + frame = 0 + } + + return { + show(face, progress, scope) { + let target: HTMLElement | null = null + for (const img of document.querySelectorAll<HTMLElement>(`${scope} .pad-glyph[data-face="${face}"]`)) { + if (ringVisible(originState(img))) { + target = img + break + } + } + if (!target) return hide() + origin = target + // A modal dialog is in the top layer, above anything on the page: the ring goes inside it then. + const parent = target.closest('dialog') ?? document.body + if (ring.parentElement !== parent) parent.append(ring) + ring.style.setProperty('--p', String(progress)) + ring.classList.toggle('full', progress >= 1) + ring.hidden = false + if (frame) cancelAnimationFrame(frame) + follow() + }, + hide, + } +} diff --git a/src/ui/keyCursor.ts b/src/ui/keyCursor.ts new file mode 100644 index 0000000..f799820 --- /dev/null +++ b/src/ui/keyCursor.ts @@ -0,0 +1,137 @@ +import type { ActionId, Vec } from '../sim/types' +import { eq } from '../sim/state' +import { BAR_BUTTONS, ghostAction, type BarButton, type TileAction } from './padTiles' +import type { Step } from './spatial' + +/** + * The keyboard's tile-first cursor (decisions.md 2026-11-02, Q65): the controller's cursor + * (padTiles.ts, padTarget.ts) woken by Shift + an arrow (or W A S D), so a player with only a + * keyboard can reach every tile, not just the four straight lines the plain arrows look along. + * Pure: main.ts hands in what a key event says, and the state. + */ + +/** Who has the board: the controller, the keyboard's cursor (and aim), or the mouse / touch / plain keys (null). */ +export type InputMode = 'pad' | 'keys' | null + +/** The parts of a key event the rules need (a `KeyboardEvent` fits). */ +export interface KeyInfo { + code: string + key: string + shiftKey: boolean + altKey: boolean + ctrlKey: boolean + metaKey: boolean +} + +/** What a key event is to the cursor. */ +export type KeyRole = + /** Shift, Alt, Control, Meta on their own: pressed on the way to a combination. */ + | 'modifier' + /** Shift + an arrow or W A S D: hops the cursor (and wakes it). */ + | 'hop' + /** Alt + an arrow or W A S D: pans the view. */ + | 'pan' + /** Home: the view follows the player again. */ + | 'recenter' + /** "What is that?" (`?`): aims, so it wakes the cursor's mode too. */ + | 'inspect' + /** A plain arrow, W A S D: today's "nearest valid tile that way". */ + | 'direction' + /** Q / E / Z / C: a diagonal step. */ + | 'diagonal' + | 'other' + +const DIRECTION_CODES: Record<string, { dx: Step; dy: Step }> = { + ArrowUp: { dx: 0, dy: -1 }, KeyW: { dx: 0, dy: -1 }, + ArrowDown: { dx: 0, dy: 1 }, KeyS: { dx: 0, dy: 1 }, + ArrowLeft: { dx: -1, dy: 0 }, KeyA: { dx: -1, dy: 0 }, + ArrowRight: { dx: 1, dy: 0 }, KeyD: { dx: 1, dy: 0 }, +} +const DIAGONAL_CODES = ['KeyQ', 'KeyE', 'KeyZ', 'KeyC'] +const MODIFIER_CODES = ['ShiftLeft', 'ShiftRight', 'AltLeft', 'AltRight', 'ControlLeft', 'ControlRight', 'MetaLeft', 'MetaRight', 'OS'] + +/** The board direction (y grows down, as the screen's) an arrow or W A S D key points, or null. */ +export function keyDirection(code: string): { dx: Step; dy: Step } | null { + return DIRECTION_CODES[code] ?? null +} + +export function keyRole(k: KeyInfo): KeyRole { + if (MODIFIER_CODES.includes(k.code)) return 'modifier' + // Control and Meta combinations belong to the browser (Ctrl+R, Cmd+L, Cmd+/ ...): never the cursor's. + if (k.ctrlKey || k.metaKey) return 'other' + // `?` is what the key types, not where it sits: other layouts have it elsewhere, and their Slash key types something else. + if (k.key === '?') return 'inspect' + if (k.code === 'Home') return 'recenter' + const dir = keyDirection(k.code) + if (dir && k.altKey) return 'pan' + if (dir && k.shiftKey) return 'hop' + if (dir) return 'direction' + if (DIAGONAL_CODES.includes(k.code)) return 'diagonal' + return 'other' +} + +/** + * Who has the board after a key: Shift + arrow and `?` give the keyboard's cursor; a pan, a recentre + * and the modifier keys change nothing for the keyboard (they only hand a controller's board back, + * as every key does); a plain arrow or diagonal step ends the cursor (the board is + * the old one again), except while "What is that?" is on, when the arrows aim; any other key + * (M, B, Space, Esc, Enter ...) is used by the cursor while it shows. + * `locked` (a dialog is open, or the demo plays): the keys belong to it, so none wakes the keyboard's + * cursor (Shift + arrow in the seed field, `?` typed in a dialog); a controller's board is still handed back. + */ +export function modeAfterKey(mode: InputMode, k: KeyInfo, inspecting: boolean, locked = false): InputMode { + const role = keyRole(k) + if (locked) return mode === 'keys' ? 'keys' : null + if (role === 'hop' || role === 'inspect') return 'keys' + // While "What is that?" is on, the plain arrows aim it, whoever had the board. + if ((role === 'direction' || role === 'diagonal') && inspecting) return 'keys' + if (mode !== 'keys') return null + return role === 'direction' || role === 'diagonal' ? null : 'keys' +} + +/** Alt + arrow: how far the view moves for one key press (or repeat), in tiles, y up (camera.ts `panView`). */ +export const PAN_TILES_PER_KEY = 0.5 + +export function panStep(code: string): { x: number; y: number } | null { + const dir = keyDirection(code) + return dir ? { x: dir.dx * PAN_TILES_PER_KEY, y: -dir.dy * PAN_TILES_PER_KEY } : null +} + +/** An element a key press already means something to: Enter or Space on it presses it, so the game stays out. */ +export function targetTakesKey(target: { tagName?: string; isContentEditable?: boolean } | null): boolean { + if (!target) return false + if (target.isContentEditable) return true + return ['BUTTON', 'A', 'INPUT', 'TEXTAREA', 'SELECT', 'SUMMARY'].includes((target.tagName ?? '').toUpperCase()) +} + +/** The key cap shown for each bar button in the cursor's label. */ +export const KEY_CAPS: Record<BarButton, string> = { move: 'M', bomb: 'B', charged: 'V', wait: 'Space' } + +/** + * Which bar button an action key means on the cursor's tile: its own button (M, B, V, Space) when + * that is possible there, else the button whose action there is the key's (R is Move's run, K its + * kick, T the throw ...). Null when the tile offers nothing for the key: the key then picks the + * action as it always did (then Enter, or a plain arrow, aims it). + */ +export function keyButton(actions: readonly TileAction[], id: ActionId): BarButton | null { + const own = (BAR_BUTTONS as readonly string[]).includes(id) ? actions.find((a) => a.possible && a.button === id) : undefined + if (own) return own.button + const byAction = actions.find((a) => a.possible && a.id === id) + return byAction ? byAction.button : null +} + +/** The button Enter presses on the cursor's tile: the first action the ghost previews (never Wait; nothing on the player's own tile, as a tap there does nothing). */ +export function defaultButton(actions: readonly TileAction[], tile: Vec, player: Vec): BarButton | null { + if (eq(tile, player)) return null + // A faded tile (auto-move's walk): Enter walks, as Move does. + const walk = actions.find((a) => a.far) + if (walk) return walk.button + const first = ghostAction(actions, tile, player) + return first ? (actions.find((a) => a.possible && a.id === first.id)?.button ?? null) : null +} + +/** What Esc does with the cursor showing, nearest first: put away "What is that?", a picked action or the flag; else bring the cursor home; else put the cursor away. */ +export function escStep(c: { inspecting: boolean; picked: boolean; onPlayer: boolean }): 'cancel' | 'home' | 'hide' { + if (c.inspecting || c.picked) return 'cancel' + return c.onPlayer ? 'hide' : 'home' +} diff --git a/src/ui/keyDemo.ts b/src/ui/keyDemo.ts new file mode 100644 index 0000000..157e421 --- /dev/null +++ b/src/ui/keyDemo.ts @@ -0,0 +1,216 @@ +import { eq } from '../sim/state' +import { isDeadly } from '../sim/danger' +import { ACTIONS, isAvailable } from '../sim/actions' +import { canPick, membersOf, pickChoices, resolvePick, tapChoices } from '../sim/picks' +import { CONTROL_KINDS, PAD_CONTROL_KINDS, PAD_LATER, type Gesture, type SceneKind } from '../sim/demo' +import type { Action, ActionId, GameState, Vec } from '../sim/types' +import { keyButton } from './keyCursor' +import { cursorPath } from './padDemo' +import { actionableTiles, moveCursor, tileActions, type BarButton } from './padTiles' +import type { Step } from './spatial' + +/** + * The keyboard's version of a demo turn (Q65, part B): the key presses that play a turn's action + * the shortest way the game accepts (a plain arrow, an action key then an arrow, or Shift + arrows + * for the cursor and an action key), so the demo can show a key overlay lighting up instead of a + * finger. Pure, like padDemo.ts: it works from the turn's concrete action and the state, and + * `runKeys` replays the keys through the same rules the game applies (keyCursor.ts, picks.ts, + * padTiles.ts), which is how a plan is checked. + */ +export type KeyPress = + /** A plain arrow (or W A S D): a step, or with an action key picked, that action at the nearest tile that way. */ + | { kind: 'arrow'; dx: Step; dy: Step } + /** Shift + arrow: the cursor hops (the first press only shows it, on the player). */ + | { kind: 'hop'; dx: Step; dy: Step } + /** Alt + arrow: the view pans. */ + | { kind: 'pan'; dx: Step; dy: Step } + /** An action key: M, B, V (the charge) or Space (Wait). */ + | { kind: 'key'; button: BarButton } + | { kind: 'inspect' } + | { kind: 'enter' } + | { kind: 'esc' } + | { kind: 'home' } + +/** One press, and what the board shows after it: the cursor (null: not shown) and the picked action key (null: none). */ +export interface KeyStep { + press: KeyPress + cursor: Vec | null + picked: BarButton | null +} + +/** What a run of keys did: the action played. */ +export interface KeyResult { + id: ActionId + target?: Vec +} + +/** + * Presses the keys on `s`, as the game takes them: the cursor and the picked key after each press, + * and the action the last press played (null when the keys play nothing, or play it before the + * last press, or ask a question the demo cannot answer: a bomb two actions could deal with, a + * step that costs a life). + */ +export function runKeys(s: GameState, presses: readonly KeyPress[]): { states: { cursor: Vec | null; picked: BarButton | null }[]; result: KeyResult | null } { + const tiles = actionableTiles(s) + let cursor: Vec | null = null + let picked: BarButton | null = null + const states: { cursor: Vec | null; picked: BarButton | null }[] = [] + for (const [i, p] of presses.entries()) { + let result: KeyResult | null | undefined + if (p.kind === 'hop') { + // The press that wakes the cursor only shows it, on the player (main.ts `keyHop`). + cursor = cursor ? moveCursor(tiles, cursor, p.dx, p.dy) : { ...s.player } + } else if (p.kind === 'arrow') { + cursor = null + result = directionResult(s, picked, p.dx, p.dy) + } else if (p.kind === 'key') { + if (cursor) { + const button = keyButton(tileActions(s, cursor), p.button) + const action = button ? tileActions(s, cursor).find((a) => a.button === button) : undefined + // Space away from the player does not wait (main.ts `keyAction`). + if (p.button === 'wait' && !button) result = null + else if (action?.possible && action.id) result = action.id === 'wait' ? { id: 'wait' } : { id: action.id, target: { ...cursor } } + } + if (result === undefined) { + // No cursor (or nothing there for the key): the key picks the action, as ever (main.ts `pick`). + const now = membersOf(p.button).find((a) => isAvailable(s, a) && !ACTIONS[a].needsTarget) + if (!canPick(s, p.button)) result = null + else if (now) result = { id: now } + else if (picked === p.button && resolvePick(s, p.button, s.player) === 'freeze') result = { id: 'freeze', target: { ...s.player } } + else picked
Diff truncated. Clone the repository to see all of it.