explodorama / commit
Next copy, choices on taps, Freeze before a swap, demo shuffle (0.2.0 work)
CHANGELOG.md | 8 ++ PLAN.md | 10 +- README.md | 23 +++- assets/kenney-board-icons/dice_3D_detailed.png | Bin 0 -> 1568 bytes decisions.md | 27 +++++ index.html | 1 + package-lock.json | 4 +- package.json | 3 +- src/changelog.ts | 40 ++++++- src/channel.ts | 46 ++++++++ src/main.ts | 139 ++++++++++++++++++++----- src/sim/actions.ts | 16 +++ src/sim/picks.ts | 64 ++++++++++-- src/sim/scores.ts | 3 + src/sim/turn.ts | 13 ++- src/sim/types.ts | 2 + src/style.css | 15 ++- src/ui/chargeOffer.ts | 16 ++- src/ui/feedback.ts | 3 +- src/ui/scores.ts | 7 +- src/ui/tapChoice.ts | 26 +++++ src/ui/whatsnew.ts | 50 ++++++++- test/channel.test.ts | 9 ++ test/charges.test.ts | 25 +++++ test/picks.test.ts | 100 +++++++++++++++++- tsconfig.json | 1 + vite.config.ts | 21 +++- 27 files changed, 610 insertions(+), 62 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e7d9903..df15f22 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,14 @@ saved before the change is dropped and the next visit starts a new run. ## [Unreleased] +### Changed + +- **Two copies of the game** (user request, 2026-10-31): the released one (built from a git tag, `/explodorama/`) and a **next** copy built from `main` (`/explodorama-next/`, deployed with `fw deploy --as explodorama-next`; tab title "EXPLODORAMA! (next)"). Each has its own saves, options, scores and last-played version (`explodorama-next.*` keys), so a rules change on `main` never drops a released run. The released game's menu has a **Try it early** link and its Changes dialog a **Coming next** teaser (read from the next copy's `next.json`); the next copy's Changes dialog lists **Not released yet**. A run finished on the next copy is marked **Early adopter** on its board and counted in one shared key (`explodorama.earlyRuns`) the released copy shows in its menu. Developer mode is separate and works on either. +- **A bomb two of your actions could handle asks which** (user request, 2026-10-31): with Kick and Bomb Pass, a tap or arrow key on a bomb next to you offers Kick or Walk onto it (the Detonator's Set it off too). Bomb Pass alone now walks onto the bomb on a tap, which was refused before. +- **A tap on a crate walks to the tile beside it** (2026-10-31); with Run, a crate two tiles away did nothing. +- **A charge pickup while carrying a Freeze** offers a third choice: use the Freeze, then take the new charge (new action `fireCharge`; a charge that needs aiming can't be used on the spot, so only Freeze). +- **Only a click or tap ends the demo**; keys no longer do (Ctrl or Shift started the game). New shuffle button, kept across visits (`explodorama.demoShuffle`). + ### Added - **Semver releases and an in-game list of changes:** the game's version is 0.1.0 (the base); the menu shows it with the rules version, and tapping it lists every release. A returning player is shown what came after the version they last played, when the opening demo ends. diff --git a/PLAN.md b/PLAN.md index 6502b2b..da5b2b4 100644 --- a/PLAN.md +++ b/PLAN.md @@ -184,7 +184,7 @@ with the **charge button** beside Bomb (key V): a charged bomb (`charged`) for P Nuke, a launch (`icbm`), a Freeze (`freeze`, at once), a Decoy (`decoy`, on the player's tile or one next to them). Bomb always places a plain bomb. A Shield is passive. The same kind picked up adds to it; another kind asks (`chargeOffer`, the `sellCharge` / `swapCharge` actions, no turn): -keep yours and sell the new one, or take it and sell yours. A charge sells for half its shop +keep yours and sell the new one, or take it and sell yours (or, from 2026-10-31, for a Freeze: use it first, `fireCharge`, then take the new one). A charge sells for half its shop price, rounded up, for what is left (`salePrice`). *Since 2026-10-25 (user report)* one chosen from a gem or bought in the shop asks the same (it went straight in, selling the one held), and the charge button is always there: an empty dashed slot with none, greyed out while it can't act, @@ -1039,6 +1039,14 @@ tools/ compose-icons.sh (composed sprites and the app icons), sc ### Backlog +- **Released and next copies (user request, 2026-10-31; built, not yet deployed):** see decisions.md and + README "Two copies". To go live: commit, tag and push fuzzyworld-tools with `--as`, update + explodorama's `fuzzyworld-tools` dependency, then `npm run deploy -- --as explodorama-next`. +- **Art provenance audit (user request, 2026-10-31):** check every file under `assets/` (and any + art drawn in code, such as the oil highlight's ellipses in `tools/compose-icons.sh`) is either + Kenney's own or produced by a script in the repo from Kenney's files; list everything made by + hand or composed by Claude, with how, so the user can keep, redo or replace it. Also confirm the + menu and README credits cover every pack used. Queue item Q24. - **A worn dungeon (user request, 2026-10-25; built, then switched off the same day):** *the user: the splats looked too alike, and not the look wanted* — `render/decor.ts` stays, off (`ENABLED`), until a new approach is chosen. The first idea: use the Splat Pack (approved) to make diff --git a/README.md b/README.md index 506cb77..bab4242 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,16 @@ browser's localStorage) when the opening demo ends; a first visit, and a test UR them, finished changes collect in `UNRELEASED` in [src/changelog.ts](src/changelog.ts), and tagging moves them into a new release there and bumps `version` in package.json. +**Two copies:** the released game (`/explodorama/`, built from the latest git tag) and the **next** +copy (`/explodorama-next/`, built from `main`: tab title "EXPLODORAMA! (next)"). Deploy the +released one with `npm run deploy` (the latest git tag) and the next one with +`npm run deploy:next` (needs fuzzyworld-tools with `--as`). A released copy only has the +**Try it early** link and teaser once it is built from a tag that includes them (0.2.0 or later). They share an +origin, so every localStorage key is per copy (`storageKey` in [src/channel.ts](src/channel.ts)); +only `explodorama.earlyRuns` is shared. The released menu offers **Try it early** and 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: **40** (`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, @@ -69,9 +79,9 @@ own bomb, one bomb setting off another, touching an enemy, and a couple of funny Detonator next to you, a Run into an enemy). The scenes play **in a fixed order** and loop — the controls first, then the rest, with a way to die after every four — each made new every time. The panel shows which one it is (**12 / 47**); **◀ ▶** (or the **← →** keys) go to the -one before or after, and **↺** back to the first. The demo carries on where it was the next time +one before or after, and **↺** back to the first; **the dice button** plays them in a random order instead (lit while on; kept for next time). The demo carries on where it was the next time it plays, after a reload too. It is -view-only; **tap anywhere else (or press any other key) to play**, and that tap does nothing +view-only; **click or tap anywhere else to play** (a key does nothing: Ctrl or Shift used to start the game), and that tap does nothing 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. @@ -148,8 +158,10 @@ to you is always a step. Otherwise it's a **Run** (2 tiles straight along the wa Run and the way goes straight, or a step — then it works it out again next turn. It only lands on safe tiles. Auto-move never spends a charge: a Teleport is yours to use, from the charge button. -Tapping an enemy, a bomb, burning flame, a wall or a crate does nothing (apart from ending -auto-move, for a wall or crate; and with Kick, tapping a bomb next to you kicks it) — a tap +Tapping an enemy, a bomb, burning flame or a wall does nothing (apart from ending +auto-move; with Kick, tapping a bomb next to you kicks it, with Bomb Pass it walks onto it, and +with both — or with the Detonator too — a dialog asks which, as it does for an arrow key into +such a bomb). Tapping a crate walks you to the tile beside it. A tap never walks into an enemy. **What is that?** Tap the round **?** button at the top right, below the menu on a phone and beside it on a wider screen (or press **?**), @@ -194,7 +206,8 @@ piled up). The top bar shows it too. - Picking up **the same kind** adds to it (3 more Pierce bombs). - Picking up **another kind** asks: **keep** yours and sell the new one, or **take** the new one and sell yours (for what is left of it). A charge sells for half its shop price, rounded - up. The choice takes no turn. + up. A **Freeze** held can also be **used first**, then the new charge taken (a third button; + charges that need aiming can't be). The choice takes no turn. - The same goes for a charge chosen from a gem or bought in the shop: with another kind held, it asks (since 2026-10-25, user report: it used to replace the one held without asking). - What you carry goes on to the next level and through a death. The first time in a run you diff --git a/assets/kenney-board-icons/dice_3D_detailed.png b/assets/kenney-board-icons/dice_3D_detailed.png new file mode 100644 index 0000000..3ede95e Binary files /dev/null and b/assets/kenney-board-icons/dice_3D_detailed.png differ diff --git a/decisions.md b/decisions.md index 93840da..4209c14 100644 --- a/decisions.md +++ b/decisions.md @@ -2265,3 +2265,30 @@ visit only records the version and shows nothing; the dialog opens when the demo version in the menu lists every release. A change reaching players goes into `UNRELEASED` as it is finished; tagging moves them into a new release and bumps package.json (checked by test/changelog.test.ts). + +### 2026-10-31 — Choices on a tap, crate approach, fireCharge, demo keys and shuffle (0.2.0 work) + +User requests, built together: (1) a tap on a crate walks to the tile beside it (`approachTile`; +with Run it did nothing); (2) a tap or arrow key on a bomb that two actions could answer asks which +(`tapChoices`, `tapMeaning` 'choose'): kick, walk onto it (Bomb Pass; a tap on a bomb with Bomb Pass +alone now walks too, it was refused), set it off. Walking onto an oil barrel stays a button-only +move. (3) The charge-swap dialog's third answer, `fireCharge`: use the charge held, then take the +new one (what remains of the held one is sold). Only a charge with nothing to aim can be used there, +which today is Freeze (aimed at the player's own tile); bomb charges, Decoy, Teleport and ICBM need a +target, so the third button is not shown for them (open: aim first, then swap?). No `SIM_VERSION` +bump: only a new action, which no old run holds. (4) Keys no longer end the demo, only a click or +tap; Tab/Enter/Space still work its buttons. A shuffle button (the dice, `explodorama.demoShuffle`) plays the +scenes in a random order (`scenePlan`), the choice kept across visits. + +### 2026-10-31 — Released and next copies; early adopters + +User questions, 2026-10-31: how to try `main` while everyone else stays on the tagged release, and +an incentive to do so. Decided: two deployed copies. Released: `fw deploy --ref <tag>` to +`/explodorama/`. Next: `fw deploy --as explodorama-next` from `main` (fuzzyworld-tools gained `--as` +and the vite plugin reads `FW_APP`, uncommitted at the time of writing). The copies share an origin, +so each has its own localStorage keys (`src/channel.ts`); one key, `explodorama.earlyRuns`, is +shared. The incentive is a teaser and a link, not a gameplay reward: the released menu shows "Try it +early", the Changes dialog "Coming next" (from the next copy's `next.json`), scores finished on the +next copy are marked "Early adopter" and counted. Rejected: giving the Developer rainbow gem on the +next copy (scores would stop being comparable; Developer stays a separate option for either copy). +Scores are per copy, as storage is. diff --git a/index.html b/index.html index f7331eb..3f607a5 100644 --- a/index.html +++ b/index.html @@ -63,6 +63,7 @@ <button id="demo-prev" type="button" aria-label="Previous scene"></button> <span id="demo-count"></span> <button id="demo-next" type="button" aria-label="Next scene"></button> + <button id="demo-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> diff --git a/package-lock.json b/package-lock.json index e9174c6..d9323c7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -825,8 +825,8 @@ } }, "node_modules/fuzzyworld-tools": { - "version": "1.0.1", - "resolved": "git+https://code.fuzzyworld.net/fuzzyworld-tools.git#45b1fe3805ad7ec86b864e4604b7ad89a0e25a78", + "version": "1.3.0", + "resolved": "git+https://code.fuzzyworld.net/fuzzyworld-tools.git#bc98a4857fcda1801fd593d5d67d11b6d615b67b", "dev": true, "license": "MIT", "bin": { diff --git a/package.json b/package.json index e56d746..ba84df3 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,8 @@ "test:watch": "vitest", "screenshots": "npm run build && node tools/screenshots.mjs", "balance": "node tools/balance/run.mjs", - "deploy": "fw deploy" + "deploy": "fw deploy --ref $(git describe --tags --abbrev=0)", + "deploy:next": "fw deploy --as explodorama-next" }, "repository": { "type": "git", diff --git a/src/changelog.ts b/src/changelog.ts index 44fe341..b05795a 100644 --- a/src/changelog.ts +++ b/src/changelog.ts @@ -1,4 +1,4 @@ -import type { SceneKind } from './sim/demo' +import type { SceneKind } from './sim/demo.ts' /** * What changed in each release, for the player (user request, 2026-10-31). Shown in the game as @@ -47,7 +47,43 @@ 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[] = [] +export const UNRELEASED: Change[] = [ + { + kind: 'added', + icon: 'bomb_active', + title: 'Try the next version early', + text: ['The menu now has a "Try it early" link to a copy of the game built from the newest work, with its own saves and scores. Runs finished there are marked "Early adopter" on its board, and the released game counts them in the menu.'], + date: '2026-10-31', + }, + { + kind: 'changed', + icon: 'bomb', + title: 'Kick or walk: you choose', + text: ['With both Kick and Bomb Pass, a tap (or arrow key) on a bomb next to you asks whether to kick it or walk onto it. With the Detonator as well, setting it off is a third choice.'], + date: '2026-10-31', + }, + { + kind: 'changed', + icon: 'block_metal', + title: 'A tap on a crate walks to it', + text: ['Tapping a crate (even from two tiles away, with Run) now walks you to the tile beside it.'], + date: '2026-10-31', + }, + { + kind: 'changed', + icon: 'charge_freeze', + title: 'Use a Freeze before swapping charges', + text: ['When you pick up a charge while carrying a Freeze, you can now use the Freeze first and then take the new charge, instead of losing one of them.'], + date: '2026-10-31', + }, + { + kind: 'changed', + icon: 'bomb', + title: 'Only a click ends the demo', + text: ['Pressing a key (Ctrl, Shift...) no longer starts the game from the opening demo; click or tap to play. A new shuffle button plays the demo scenes in a random order, and remembers your choice.'], + date: '2026-10-31', + }, +] /** The version being played. */ export const VERSION = RELEASES[0]!.version diff --git a/src/channel.ts b/src/channel.ts new file mode 100644 index 0000000..d89050c --- /dev/null +++ b/src/channel.ts @@ -0,0 +1,46 @@ +/** + * Which copy of the game this is (user request, 2026-10-31). `stable` is the released one, built + * from a git tag, at /explodorama/; `next` is built from `main` at /explodorama-next/ for anyone + * who wants to try what is coming (vite.config.ts sets VITE_CHANNEL from the deploy's app name); + * `local` is `npm run dev`. The two deployed copies share an origin, so they share localStorage: + * `storageKey` keeps their saves, options and scores apart. + */ +export type Channel = 'stable' | 'next' | 'local' + +export const CHANNEL: Channel = import.meta.env.DEV ? 'local' : import.meta.env.VITE_CHANNEL === 'next' ? 'next' : 'stable' + +/** The page title, which says which copy it is. */ +export const TITLE = CHANNEL === 'local' ? 'EXPLODORAMA! (local)' : CHANNEL === 'next' ? 'EXPLODORAMA! (next)' : 'EXPLODORAMA!' + +/** A localStorage key of this copy's own: `explodorama.run`, or `explodorama-next.run` on the next copy. */ +export function storageKey(name: string): string { + return `${CHANNEL === 'next' ? 'explodorama-next' : 'explodorama'}.${name}` +} + +/** + * The one key both copies share: how many runs have been finished on the next copy, so the + * released one can say so (early adopters). + */ +export const EARLY_RUNS_KEY = 'explodorama.earlyRuns' + +/** Where the other copy lives, relative to the site. */ +export const STABLE_PATH = '/explodorama/' +export const NEXT_PATH = '/explodorama-next/' + +/** Runs finished on the next copy, from either copy. */ +export function earlyRuns(): number { + try { + const n = Number(localStorage.getItem(EARLY_RUNS_KEY)) + return Number.isInteger(n) && n > 0 ? n : 0 + } catch { + return 0 + } +} + +export function countEarlyRun(): void { + try { + localStorage.setItem(EARLY_RUNS_KEY, String(earlyRuns() + 1)) + } catch { + // No storage: not counted. + } +} diff --git a/src/main.ts b/src/main.ts index 7984925..ef49c1f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -24,7 +24,7 @@ import { animateTurn as animateBoard, clearAnimation, newAnimation, pruneAnimati import { endPan, panView, resetPan, updateCamera, viewRect } from './render/camera' import { CHARGE_ICONS, MONO_ICONS, POWER_UP_ICONS, TEXTURES } from './render/sprites' import { isAvailable, isValidTarget, ACTIONS } from './sim/actions' -import { canPick, membersOf, pickTargets, resolvePick, tapMeaning } from './sim/picks' +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 { isDeadly, predictDanger, type Danger } from './sim/danger' @@ -35,11 +35,13 @@ 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 { VERSION } from './changelog' -import { newSinceLastVisit, openChanges } from './ui/whatsnew' +import { channelLine, loadNextInfo, newSinceLastVisit, openChanges } from './ui/whatsnew' +import { CHANNEL, storageKey, TITLE } from './channel' import { createControls } from './ui/controls' import { closeDialog, dialogOpen, openDialog, setDialogInDemo, type DialogButton } from './ui/dialog' import { actionInfo, chargeInfo, hudInfo, tileInfo, type Info } from './ui/info' import { openChargeOffer } from './ui/chargeOffer' +import { openTapChoice } from './ui/tapChoice' import { openOffer } from './ui/offer' import { showShop, updateShop } from './ui/shop' import { shopAfter } from './sim/shop' @@ -135,7 +137,7 @@ function startRun(seed: number, options: RunOptions): void { } /** The modes picked last time, the default for the next new run. */ -const OPTIONS_KEY = 'explodorama.newRunOptions' +const OPTIONS_KEY = storageKey('newRunOptions') function preferredOptions(): RunOptions { try { @@ -161,7 +163,7 @@ function rememberOptions(options: RunOptions): void { * neither loads nor saves, so trying something never replaces the real run. Storage can be * missing or full; then the game simply doesn't save. */ -const SAVE_KEY = 'explodorama.run' +const SAVE_KEY = storageKey('run') /** A test URL's options: `?dev`, `?immortal` (for screenshots and bug reports). */ function urlOptions(): RunOptions { @@ -211,7 +213,7 @@ const MOVES: ReadonlySet<Action['id']> = new Set(['move', 'run', 'diagonal', 'te 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') return + if (state.chargeOffer && action.id !== 'sellCharge' && action.id !== 'swapCharge' && action.id !== 'fireCharge') return lastActionAt = time 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. @@ -231,7 +233,7 @@ function act(action: Action, auto = false): void { // A run that has just ended goes on the high-score board (not from a test URL); its banner // says if it made it. if (wasGoing && !testing && countsForScores(state)) { - const rank = recordScore(scoreEntry(state, record, new Date())) + const rank = recordScore({ ...scoreEntry(state, record, new Date()), channel: CHANNEL === 'local' ? 'local' : CHANNEL, commit: import.meta.env.VITE_COMMIT || undefined }) controls.setBannerNote(rank ? `New high score: #${rank}!` : '') } for (const e of result.events) { @@ -254,7 +256,7 @@ function act(action: Action, auto = false): void { if (action.id === 'continue' && state.shop) openShopDialog() else if (action.id === 'buy' || action.id === 'reroll') updateShop(state) // A charge bought while carrying another asked first; the shop comes back once it is answered. - else if ((action.id === 'sellCharge' || action.id === 'swapCharge') && state.shop) openShopDialog() + else if ((action.id === 'sellCharge' || action.id === 'swapCharge' || action.id === 'fireCharge') && state.shop) openShopDialog() } /** The shop's dialog (ui/shop.ts): buying and restocking are turns of the run record, taking no time. */ @@ -306,7 +308,7 @@ function showOffer(): void { /** The choice a charge picked up while carrying another asks for (one charge at a time). */ function showChargeOffer(): void { - if (state.chargeOffer && !state.offer) openChargeOffer(state, (swap) => act({ id: swap ? 'swapCharge' : 'sellCharge' })) + if (state.chargeOffer && !state.offer) openChargeOffer(state, (choice) => act({ id: choice === 'fire' ? 'fireCharge' : choice === 'swap' ? 'swapCharge' : 'sellCharge' })) } function afterChange(): void { @@ -342,7 +344,9 @@ function afterChange(): void { * - with Teleport, a tile you can't walk to but can teleport to: teleport there; * - a wall or crate, or any floor you can't walk to: clears the flag if there is one; * - one of the player's bombs, with the Detonator: set it off (user request, 2026-10-22); - * - else a bomb next to the player, with Kick: kick it (walking into it); + * - else a bomb next to the player, with Kick: kick it (walking into it); with Bomb Pass too, or the + * Detonator, a dialog asks which (2026-10-31); + * - a crate: walk to the tile beside it (2026-10-31); * - an enemy, any other bomb, or flame still burning: nothing, never a move into it — unless * flame can't hurt the player (Flame Pass, a Star), then it is walked into like floor. */ @@ -355,7 +359,9 @@ function tapTile(p: Vec): void { } else if (selected) { // The picked button stands for a group (Move also runs, teleports...): the tile decides which. const id = resolvePick(state, selected, p) - if (id) act({ id, target: p }) + const options = pickChoices(state, selected, p) + if (options.length) openTapChoice(options, (choice) => act({ id: choice, target: p }), (c) => isDeadly(state, { id: c, target: p })) + else if (id) act({ id, target: p }) else { selected = null afterChange() @@ -366,7 +372,12 @@ function tapTile(p: Vec): void { if (means === 'deadly') refuse(p) else if (means === 'detonate' || means === 'kick') act({ id: means, target: p }) else if (means === 'blocked') return - else if (destination && eq(destination, p)) stepTowardDestination() + else if (means === 'choose') openTapChoice(tapChoices(state, p), (id) => act({ id, target: p })) + else if (means === 'approach') { + const to = approachTile(state, p)! + if (destination && eq(destination, to)) stepTowardDestination() + else setDestination(to) + } else if (destination && eq(destination, p)) stepTowardDestination() else if (means === 'walk') setDestination(p) else if (means) act({ id: means, target: p }) else if (destination) { @@ -492,7 +503,8 @@ function showMenu(): void { // The version is the release (semver, tagged by hand); the rules version, in brackets, changes // exactly when old saves stop resuming. The version opens the list of changes. `<p class="credit">Version <a href="#" id="changes-link">${VERSION}</a> (rules ${SIM_VERSION})${commitLine()}. ` + - `<a href="${REPO_URL}/" target="_blank" rel="noopener">Source code</a></p>`, + `<a href="${REPO_URL}/" target="_blank" rel="noopener">Source code</a></p>` + + (channelLine() ? `<p class="credit">${channelLine()}</p>` : ''), buttons: [ { label: 'Resume', kind: 'primary', icon: playIcon }, { label: 'New', kind: 'danger', icon: newRunIcon, onClick: newRunDialog }, @@ -639,6 +651,11 @@ function direction(d: Dir): void { const p = { x: state.player.x + reach * DIRS[d].x, y: state.player.y + reach * DIRS[d].y } const id = resolvePick(state, picked, p) if (!id) continue + // A bomb two actions could deal with (kick or walk onto it) asks which, picked or not. + const options = selected ? pickChoices(state, picked, p) : tapChoices(state, p) + if (options.length > 1) return openTapChoice(options, (choice) => act({ id: choice, target: p }), (c) => isDeadly(state, { id: c, target: p })) + // Nothing picked and one safe option on a bomb: that one, as a tap takes it (even if a deadlier one comes first). + if (!selected && options.length === 1) return act({ id: options[0]!, target: p }) // Nothing picked, a key is a shortcut, as a tap is: never one that costs a life. if (!selected && isDeadly(state, { id, target: p })) return refuse(p) return act({ id, target: p }) @@ -801,7 +818,7 @@ const DEMO_ORDER: readonly DemoItem[] = [ ...DYING_KINDS.slice(Math.floor(LIVING_KINDS.length / 4)), ] /** Where the demo is, kept so it carries on from there next time (not for a test URL: from 1). */ -const DEMO_KEY = 'explodorama.demoAt' +const DEMO_KEY = storageKey('demoAt') function savedDemoIndex(): number { try { @@ -820,6 +837,59 @@ function saveDemoIndex(i: number): void { // No storage: the demo starts from the beginning next time. } } + +/** + * 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. + */ +const SHUFFLE_KEY = storageKey('demoShuffle') +let demoShuffle = ((): boolean => { + try { + return !testing && localStorage.getItem(SHUFFLE_KEY) === '1' + } catch { + return false + } +})() +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) + if (!demoShuffle) return + const rest = scenePlan.filter((i) => i !== first) + for (let i = rest.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)) + ;[rest[i], rest[j]] = [rest[j]!, rest[i]!] + } + scenePlan = [first, ...rest] +} + +/** The scene `delta` places from scene `index` in the plan, wrapping round. */ +function adjacentScene(index: number, delta: number): number { + const n = scenePlan.length + return scenePlan[(scenePlan.indexOf(index) + delta + n) % n]! +} + +function setDemoShuffle(on: boolean): void { + demoShuffle = on + if (!testing) { + try { + localStorage.setItem(SHUFFLE_KEY, on ? '1' : '0') + } catch { + // No storage: it is off again next time. + } + } + const playing = demo?.index ?? 0 + makeScenePlan(playing) + shuffleButton.setAttribute('aria-pressed', String(on)) + // The scene made ready for next was chosen under the old order. + if (demo) { + demo.upcoming = null + prepareScene(adjacentScene(playing, 1)) + demoCount.textContent = `${scenePlan.indexOf(playing) + 1} / ${scenePlan.length}` + } +} const demoPanel = document.getElementById('demo')! /** The bars round the view, so every screen sees the same 9 × 13 tiles (render/camera.ts). */ const viewMask = document.getElementById('view-mask')! @@ -827,9 +897,15 @@ const viewMask = document.getElementById('view-mask')! const demoTitle = document.getElementById('demo-title')! const demoCount = document.getElementById('demo-count')! // The demo's buttons: the scene before, the scene after, and back to the first. -document.getElementById('demo-prev')!.addEventListener('click', () => demo && skipTo(demo.index - 1)) -document.getElementById('demo-next')!.addEventListener('click', () => demo && skipTo(demo.index + 1)) -document.getElementById('demo-restart')!.addEventListener('click', () => skipTo(0)) +document.getElementById('demo-prev')!.addEventListener('click', () => demo && skipTo(adjacentScene(demo.index, -1))) +document.getElementById('demo-next')!.addEventListener('click', () => demo && skipTo(adjacentScene(demo.index, 1))) +document.getElementById('demo-restart')!.addEventListener('click', () => { + // Back to the first scene; shuffled, the order is made again from there. + makeScenePlan(0) + skipTo(0) +}) +const shuffleButton = document.getElementById('demo-shuffle')! +shuffleButton.addEventListener('click', () => demo && setDemoShuffle(!demoShuffle)) const demoCaption = document.getElementById('demo-caption')! const actionBar = document.getElementById('actions')! const finger = document.getElementById('demo-finger')! @@ -946,15 +1022,15 @@ function prepareScene(index: number): void { */ function nextScene(): void { if (!demo) return - if (!demo.upcoming) prepareScene(demo.index + 1) + if (!demo.upcoming) prepareScene(adjacentScene(demo.index, 1)) const { index, scene } = demo.upcoming! if (scene === undefined) return - if (scene === null) return prepareScene(index + 1) + if (scene === null) return prepareScene(adjacentScene(index, 1)) const item = DEMO_ORDER[index]! demo.index = index saveDemoIndex(index) - demoCount.textContent = `${index + 1} / ${DEMO_ORDER.length}` - prepareScene(index + 1) + demoCount.textContent = `${scenePlan.indexOf(index) + 1} / ${scenePlan.length}` + prepareScene(adjacentScene(index, 1)) resetView() hover = null armed = null @@ -1054,6 +1130,8 @@ function whatIsThat(): Beat[] { */ function startDemo(): void { const at = savedDemoIndex() + 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') @@ -1294,16 +1372,17 @@ addEventListener( 'keydown', (e) => { if (!demo) return + // The game never sees a key while the demo plays. Only a click or tap ends it (user request, + // 2026-10-31: Ctrl or Shift started the game); the keys that work the demo's own buttons + // (Tab, Enter, Space on one) keep their default. e.stopPropagation() - e.preventDefault() - // ← and → go through the scenes; any other key ends the demo. - if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') { - if (!e.repeat) skipTo(demo.index + (e.key === 'ArrowLeft' ? -1 : 1)) - return + if (!(['Tab', 'Enter', 'Space'].includes(e.code) && (e.target as Element).closest?.('.demo-nav'))) e.preventDefault() + // Escape, or Enter not on one of the demo's buttons, ends it, so a player with no pointer can play. + if (e.code === 'Escape' || (e.code === 'Enter' && !(e.target as Element).closest?.('.demo-nav'))) return endDemo() + // ← and → go through the scenes. + if ((e.key === 'ArrowLeft' || e.key === 'ArrowRight') && !e.repeat) { + skipTo(adjacentScene(demo.index, e.key === 'ArrowLeft' ? -1 : 1)) } - endDemo() - e.stopPropagation() - e.preventDefault() }, true, ) @@ -1450,6 +1529,10 @@ await engineInit( TEXTURES, ) +// The tab of a local or next copy says so (user requests, 2026-10-31), so it isn't mistaken for the released one. +document.title = TITLE +loadNextInfo() + // Installable and playable offline (static/sw.js). Only in the built game: in development it // would serve stale copies over Vite's live reloading. if (import.meta.env.PROD && 'serviceWorker' in navigator) { diff --git a/src/sim/actions.ts b/src/sim/actions.ts index 476b847..a12c906 100644 --- a/src/sim/actions.ts +++ b/src/sim/actions.ts @@ -396,6 +396,22 @@ export function isValidTarget(s: GameState, id: ActionId, target: Vec): boolean return ACTIONS[id].validTargets(s).some((p) => eq(p, target)) } +/** + * The action that uses the held charge on the spot, with a charge on offer (turn.ts `fireCharge`; + * user request, 2026-10-31): one with nothing to aim, which is Freeze (aimed at the player's own + * tile). A charge that needs aiming can't be fired in the middle of picking one up. Null if none. + */ +export function fireableHeld(s: GameState): ActionId | null { + if (!s.charge || s.status !== 'playing') return null + return ( + (['freeze'] as const).find((id) => { + const def = ACTIONS[id] + const targets = def.validTargets(s) + return def.available!(s) && targets.length === 1 && eq(targets[0]!, s.player) + }) ?? null + ) +} + /** Whether the action can be picked right now. */ export function isAvailable(s: GameState, id: ActionId): boolean { if (s.status !== 'playing' || s.offer || s.chargeOffer) return false diff --git a/src/sim/picks.ts b/src/sim/picks.ts index 969444a..1c91bcf 100644 --- a/src/sim/picks.ts +++ b/src/sim/picks.ts @@ -2,8 +2,8 @@ import { ACTIONS, isAvailable, isValidTarget } from './actions' import { isDeadly } from './danger' import { enemyAt } from './enemies' import { playerPath } from './path' -import { bombAt, cellAt, eq, flameSafe, isBurning } from './state' -import type { ActionId, GameState, Vec } from './types' +import { add, bombAt, cellAt, eq, flameSafe, isBurning } from './state' +import { DIRS, DIR_LIST, type ActionId, type GameState, type Vec } from './types' /** * The action buttons stand for groups of actions (user request, 2026-10-22): Move also kicks, @@ -50,7 +50,7 @@ export function canPick(s: GameState, picked: ActionId): boolean { } /** What a tap on a tile means with no button picked (`tapMeaning`). */ -export type TapMeaning = 'deadly' | 'detonate' | 'kick' | 'blocked' | 'move' | 'diagonal' | 'slide' | 'walk' | 'teleport' +export type TapMeaning = 'deadly' | 'detonate' | 'kick' | 'blocked' | 'move' | 'diagonal' | 'slide' | 'walk' | 'teleport' | 'approach' | 'choose' /** The meanings that are an action on the tapped tile, played at once. */ const TAP_ACTIONS: ReadonlySet<TapMeaning> = new Set(['detonate', 'kick', 'move', 'diagonal', 'slide', 'teleport']) @@ -62,7 +62,10 @@ const TAP_ACTIONS: ReadonlySet<TapMeaning> = new Set(['detonate', 'kick', 'move' * bomb next to you, with Kick, is kicked; an enemy, any other bomb, or flame that can hurt you is * 'blocked' (never a move into it); then a step, a diagonal step, the tile where a slide stops * (with Slide: user report, 2026-10-24, it walked there instead), a far tile you can walk to - * ('walk': it becomes auto-move's destination), or one you can only teleport to. Null: it means + * ('walk': it becomes auto-move's destination), or one you can only teleport to. + * A bomb two of your actions could deal with (detonate it, kick it, walk onto it with Bomb Pass) + * is 'choose': the UI asks which (`tapChoices`; user request, 2026-10-31). A crate away from the + * player is 'approach': walk to the tile next to it (`approachTile`; same request). Null: it means * nothing. An action that would cost a life this turn is 'deadly' instead (`isDeadly`, user * request, 2026-10-26): a tap never takes it; its button still can. * Auto-move's own destination is the UI's to check: a tap on it is its next step. @@ -75,14 +78,61 @@ export function tapMeaning(s: GameState, p: Vec): TapMeaning | null { /** `tapMeaning`, before asking whether it is deadly. */ function plainMeaning(s: GameState, p: Vec): TapMeaning | null { if (eq(p, s.player)) return 'blocked' - if (isAvailable(s, 'detonate') && isValidTarget(s, 'detonate', p)) return 'detonate' - if (isAvailable(s, 'kick') && isValidTarget(s, 'kick', p)) return 'kick' - if (enemyAt(s, p) || bombAt(s, p) || (isBurning(s, p) && !flameSafe(s))) return 'blocked' + if (bombAt(s, p)) { + if (!bombActions(s, p).length) return 'blocked' + const safe = tapChoices(s, p) + if (!safe.length) return 'deadly' + return safe.length > 1 ? 'choose' : (safe[0] as TapMeaning) + } + if (enemyAt(s, p) || (isBurning(s, p) && !flameSafe(s))) return 'blocked' if (isValidTarget(s, 'move', p)) return 'move' if (isAvailable(s, 'diagonal') && isValidTarget(s, 'diagonal', p)) return 'diagonal' if (isAvailable(s, 'slide') && isValidTarget(s, 'slide', p)) return 'slide' if (cellAt(s, p) === 'floor' && playerPath(s, p)) return 'walk' + if (approachTile(s, p)) return 'approach' // (A tile only Teleport could reach was a teleport there until 2026-10-25; Teleport is a charge // now, used only from the charge button, so a tap never spends it.) return null } + +/** What a tap on the bomb at `p` could do: set it off, kick it, or (with Bomb Pass, not onto a barrel) walk onto it. */ +function bombActions(s: GameState, p: Vec): ActionId[] { + const barrel = bombAt(s, p)?.barrel + return (['detonate', 'kick', 'move'] as const).filter( + (id) => !(id === 'move' && barrel) && isAvailable(s, id) && isValidTarget(s, id, p), + ) +} + +/** `bombActions` that don't cost a life: the choice a tap on a bomb offers (one is taken at once). */ +export function tapChoices(s: GameState, p: Vec): ActionId[] { + return bombActions(s, p).filter((id) => !isDeadly(s, { id, target: p })) +} + +/** + * What the picked button could do on the bomb at `p`, when that is more than one thing (Move with + * Kick and Bomb Pass: kick it or walk onto it): the UI asks which. Not filtered for danger, as a + * picked button's tile never is (user report, 2026-10-31: Move then the bomb just kicked it). + */ +export function pickChoices(s: GameState, picked: ActionId, p: Vec): ActionId[] { + const ids = bombAt(s, p) ? bombActions(s, p).filter((id) => membersOf(picked).includes(id)) : [] + return ids.length > 1 ? ids : [] +} + +/** + * Where a tap on a crate (soft or metal) walks to: the tile next to it with the shortest way + * there, so the player ends up beside it. Null if the player is already beside it, or there is no + * way (user request, 2026-10-31: with Run, a tap on a crate two tiles away did nothing). + */ +export function approachTile(s: GameState, p: Vec): Vec | null { + const cell = cellAt(s, p) + if (cell !== 'block' && cell !== 'metal') return null + let best: { tile: Vec; steps: number } | null = null + for (const d of DIR_LIST) { + const q = add(p, DIRS[d]) + if (eq(q, s.player)) return null + if (cellAt(s, q) !== 'floor' || enemyAt(s, q) || (isBurning(s, q) && !flameSafe(s))) continue + const steps = playerPath(s, q)?.length + if (steps !== undefined && (!best || steps < best.steps)) best = { tile: q, steps } + } + return best?.tile ?? null +} diff --git a/src/sim/scores.ts b/src/sim/scores.ts index a72ab9c..8f6033f 100644 --- a/src/sim/scores.ts +++ b/src/sim/scores.ts @@ -28,6 +28,9 @@ export interface ScoreEntry { /** The abilities and passes held at the end, and the stats. */ powers: (keyof Powers)[] stats: Stats + /** Which copy of the game it was played on (channel.ts), and the commit it was built from. Absent on older entries. */ + channel?: 'stable' | 'next' | 'local' + commit?: string /** Everything needed to replay the run and check it. */ record: RunRecord } diff --git a/src/sim/turn.ts b/src/sim/turn.ts index 9ed251a..b66f7af 100644 --- a/src/sim/turn.ts +++ b/src/sim/turn.ts @@ -1,4 +1,4 @@ -import { ACTIONS, boxedIn, isAvailable, isValidTarget, slidePassed } from './actions' +import { ACTIONS, boxedIn, fireableHeld, isAvailable, isValidTarget, slidePassed } from './actions' import { holds, sellOffered, spendCharge, swapOffered, takeCharge } from './charges' import { enemyAt, moveEnemies, planIntents, rollDrops } from './enemies' import { enemyKinds, LAST_LEVEL } from './levelgen' @@ -39,10 +39,17 @@ export function step(prev: GameState, action: Action): StepResult { return { state: s, events } } // A charge picked up while carrying another: sell it, or swap it in. No turn passes. - if (action.id === 'sellCharge' || action.id === 'swapCharge') { + if (action.id === 'sellCharge' || action.id === 'swapCharge' || action.id === 'fireCharge') { if (!s.chargeOffer) throw new Error(`${action.id} with no charge on offer`) if (action.id === 'sellCharge') sellOffered(s, events) - else swapOffered(s, events) + else if (action.id === 'swapCharge') swapOffered(s, events) + else { + // Use the charge held, then swap the new one in (what is left of the old one is sold). + const use = fireableHeld(s) + if (!use) throw new Error('fireCharge: the charge held can\'t be used on the spot') + ACTIONS[use].apply(s, s.player, events) + swapOffered(s, events) + } return { state: s, events } } // An offer must be answered first, even with the level cleared; a death drops it. diff --git a/src/sim/types.ts b/src/sim/types.ts index b1c5284..723fddc 100644 --- a/src/sim/types.ts +++ b/src/sim/types.ts @@ -331,6 +331,8 @@ export type Action = /** A charge picked up while carrying another: sell the new one, or swap it in (the old one sold). No turn. */ | { id: 'sellCharge' } | { id: 'swapCharge' } + /** The same choice, a third way: use the charge held now (`fireableHeld`), then take the new one. No turn. */ + | { id: 'fireCharge' } /** In the shop (sim/shop.ts): buy one of its items, or restock it. */ | { id: 'buy'; kind: OfferKind } | { id: 'reroll' } diff --git a/src/style.css b/src/style.css index 9a9f8d5..aea8589 100644 --- a/src/style.css +++ b/src/style.css @@ -1038,11 +1038,24 @@ body.demo #hud { } #demo #demo-restart { - margin-left: 14px; + margin-left: 0; background-image: url('../assets/kenney-ui/Extra/icon_repeat_light.png'); background-size: 22px; } +/* Shuffle (main.ts): lit while on. */ +#demo #demo-shuffle { + margin-left: 14px; + background-image: url('../assets/kenney-board-icons/dice_3D_detailed.png'); + background-size: 24px; + opacity: 0.55; +} + +#demo #demo-shuffle[aria-pressed='true'] { + box-shadow: 0 0 0 3px #ffc83d; + opacity: 1; +} + #demo .demo-hint { margin: 10px 0 0; color: #ffc83d; diff --git a/src/ui/chargeOffer.ts b/src/ui/chargeOffer.ts index 301cbfc..078a1fc 100644 --- a/src/ui/chargeOffer.ts +++ b/src/ui/chargeOffer.ts @@ -1,3 +1,4 @@ +import { fireableHeld } from '../sim/actions' import { salePrice } from '../sim/charges' import type { GameState } from '../sim/types' import { CHARGE_ICONS, spriteUrl } from '../render/sprites' @@ -7,9 +8,11 @@ import { CHARGE_TEXT } from './info' /** * A charge picked up while carrying another (PLAN.md §19.7; user request, 2026-10-24): the player * carries one at a time. Keep the one held and sell the new one, or swap: take the new one, and - * the one held is sold for what is left of it. It must be answered; it takes no turn. + * the one held is sold for what is left of it; or, for a charge that needs no aiming (Freeze), + * use the one held now and then take the new one (user request, 2026-10-31). It must be answered; + * it takes no turn. */ -export function openChargeOffer(s: GameState, answer: (swap: boolean) => void): void { +export function openChargeOffer(s: GameState, answer: (choice: 'keep' | 'swap' | 'fire') => void): void { const offered = s.chargeOffer?.kind const offeredLeft = s.chargeOffer?.left ?? 0 const held = s.charge @@ -19,6 +22,8 @@ export function openChargeOffer(s: GameState, answer: (swap: boolean) => void): `<div><strong>${CHARGE_TEXT[kind].name}</strong> <small>${note}</small><p>${CHARGE_TEXT[kind].text}</p></div></div>` const newPrice = salePrice(offered, offeredLeft) const oldPrice = salePrice(held.kind, held.left) + // Fired first, a charge with uses left sells what remains. + const fireLeft = held.left > 1 ? salePrice(held.kind, held.left - 1) : 0 openDialog({ icon: spriteUrl(CHARGE_ICONS[offered]), title: `${CHARGE_TEXT[offered].name}!`, @@ -27,8 +32,11 @@ export function openChargeOffer(s: GameState, answer: (swap: boolean) => void): card(offered, `new · ${offeredLeft === 1 ? '1 use' : `${offeredLeft} uses`}`) + card(held.kind, `yours · ${held.left} ${held.left === 1 ? 'use' : 'uses'} left`), buttons: [ - { label: `Keep ${CHARGE_TEXT[held.kind].name} · +${newPrice}`, kind: 'plain', onClick: () => answer(false) }, - { label: `Take ${CHARGE_TEXT[offered].name} · +${oldPrice}`, kind: 'primary', onClick: () => answer(true) }, + { label: `Keep ${CHARGE_TEXT[held.kind].name} · +${newPrice}`, kind: 'plain', onClick: () => answer('keep') }, + { label: `Take ${CHARGE_TEXT[offered].name} · +${oldPrice}`, kind: 'primary', onClick: () => answer('swap') }, + ...(fireableHeld(s) + ? [{ label: `Use ${CHARGE_TEXT[held.kind].name}, then take ${CHARGE_TEXT[offered].name}${fireLeft ? ` · +${fireLeft}` : ''}`, kind: 'primary' as const, onClick: () => answer('fire') }] + : []), ], required: true, }) diff --git a/src/ui/feedback.ts b/src/ui/feedback.ts index ec60c2c..51964ff 100644 --- a/src/ui/feedback.ts +++ b/src/ui/feedback.ts @@ -1,6 +1,7 @@ import { Sound, time } from 'littlejsengine' import type { Animation } from '../render/animate' import { WALK_SECONDS } from '../render/effects' +import { storageKey } from '../channel' import type { Action, GameEvent } from '../sim/types' /** @@ -45,7 +46,7 @@ const SOUND_SOURCES = { launch: () => [0.7, 0.05, 220, 0.05, 0.25, 0.3, 4, 1, 12], } satisfies Record<string, () => string | number[]> -const SETTINGS_KEY = 'explodorama.feedback' +const SETTINGS_KEY = storageKey('feedback') export interface FeedbackSettings { sound: boolean diff --git a/src/ui/scores.ts b/src/ui/scores.ts index 1eeca26..d79f24f 100644 --- a/src/ui/scores.ts +++ b/src/ui/scores.ts @@ -1,3 +1,4 @@ +import { storageKey, countEarlyRun } from '../channel' import { addEntry, BOARD_SIZE, type ScoreEntry } from '../sim/scores' import { POWER_UP_ICONS, spriteUrl } from '../render/sprites' import { openDialog } from './dialog' @@ -8,7 +9,7 @@ import { LAST_LEVEL } from '../sim/levelgen' * The high-score board in this browser (user request, 2026-10-24): stored in `localStorage`, * shown from the menu. The entries (sim/scores.ts) are built for a global board later. */ -const SCORES_KEY = 'explodorama.scores' +const SCORES_KEY = storageKey('scores') export function loadScores(): ScoreEntry[] { try { @@ -21,6 +22,8 @@ export function loadScores(): ScoreEntry[] { /** Put a finished run on the board; its rank (1-based), or 0 if it didn't make it. */ export function recordScore(entry: ScoreEntry): number { + // A run finished on the next copy is counted where the released one can see it too (channel.ts). + if (entry.channel === 'next') countEarlyRun() const { board, rank } = addEntry(loadScores(), entry) try { localStorage.setItem(SCORES_KEY, JSON.stringify(board)) @@ -44,7 +47,7 @@ function entryHtml(e: ScoreEntry, rank: number): string { `<p>${e.kills} ${e.kills === 1 ? 'enemy' : 'enemies'} · ${e.turns} ${e.turns === 1 ? 'turn' : 'turns'} · ${e.livesLost} ${e.livesLost === 1 ? 'life' : 'lives'} lost</p>` + `<p>${how}</p>` + `<p class="score-powers">${powers}<span>Bombs ${e.stats.capacity} · Blast ${e.stats.fire} · Fuse ${e.stats.fuse}</span></p>` + - `<p class="score-meta">${date} · Seed ${e.seed}${e.options.dev ? ' · <b>Developer</b>' : ''}</p>` + + `<p class="score-meta">${date} · Seed ${e.seed}${e.options.dev ? ' · <b>Developer</b>' : ''}${e.channel === 'next' ? ' · <b>Early adopter</b>' : ''}</p>` + `</li>` ) } diff --git a/src/ui/tapChoice.ts b/src/ui/tapChoice.ts new file mode 100644 index 0000000..d49adce --- /dev/null +++ b/src/ui/tapChoice.ts @@ -0,0 +1,26 @@ +import type { ActionId } from '../sim/types' +import { openDialog } from './dialog' + +/** + * A tap or key that two of the player's actions could answer (picks.ts `tapChoices`; user + * request, 2026-10-31: a bomb next to a player with Kick and Bomb Pass): asks which, and takes + * no turn until one is chosen. Dismissed, it does nothing. An option that would cost a life is + * marked and red (it is offered only after a button was picked, as a picked button's tile is + * never refused; a plain tap leaves such an option out, and takes the other one at once). + */ +const LABELS: Partial<Record<ActionId, { label: string; text: string }>> = { + kick: { label: 'Kick it', text: 'The bomb slides away from you, and you stay where you are.' }, + move: { label: 'Walk onto it', text: 'You step onto the bomb’s tile (Bomb Pass).' }, + detonate: { label: 'Set it off', text: 'The bomb explodes this turn (Detonator).' }, +} + +export function openTapChoice(ids: readonly ActionId[], choose: (id: ActionId) => void, deadly: (id: ActionId) => boolean = () => false): void { + openDialog({ + title: 'Which one?', + body: ids.map((id) => `<p><strong>${LABELS[id]?.label ?? id}.</strong> ${LABELS[id]?.text ?? ''}${deadly(id) ? ' <b>It costs you a life this turn.</b>' : ''}</p>`).join(''), + buttons: [ + ...ids.map((id) => ({ label: LABELS[id]?.label ?? id, kind: deadly(id) ? ('danger' as const) : ('primary' as const), onClick: () => choose(id) })), + { label: 'Cancel', kind: 'plain' as const }, + ], + }) +} diff --git a/src/ui/whatsnew.ts b/src/ui/whatsnew.ts index 5822546..cb5acbc 100644 --- a/src/ui/whatsnew.ts +++ b/src/ui/whatsnew.ts @@ -1,4 +1,5 @@ -import { newsFor, RELEASES, VERSION, type Change, type Release } from '../changelog' +import { CHANNEL, earlyRuns, NEXT_PATH, STABLE_PATH, storageKey } from '../channel' +import { newsFor, RELEASES, UNRELEASED, VERSION, type Change, type Release } from '../changelog' import { spriteUrl } from '../render/sprites' import type { SceneKind } from '../sim/demo' import { startClip } from './clip' @@ -9,7 +10,7 @@ import { openDialog } from './dialog' * newer version than the player last played, listing what came after it; or from the version in * the menu, listing every release. The last version played is kept in this browser. */ -const LAST_KEY = 'explodorama.lastVersion' +const LAST_KEY = storageKey('lastVersion') function lastPlayed(): string | null { try { @@ -34,6 +35,49 @@ export function newSinceLastVisit(): Release[] { return news } +/** What the next copy says about itself (vite.config.ts writes next.json into that build). */ +interface NextInfo { + version: string + commit: string + unreleased: Change[] +} +let nextInfo: NextInfo | null = null + +/** + * The released copy asks the next one what it has that isn't released yet (once, when the game + * opens), to tell the player: a teaser in the Changes dialog and a "Try it early" link in the + * menu. Silent if there is no next copy, or none reachable. Its entries' clips are dropped: the + * released game can't make scenes it doesn't know. + */ +export function loadNextInfo(): void { + if (CHANNEL !== 'stable') return + fetch(NEXT_PATH + 'next.json', { cache: 'no-store' }) + .then((r) => (r.ok ? r.json() : null)) + .then((json: NextInfo | null) => { + if (json && Array.isArray(json.unreleased)) nextInfo = { ...json, unreleased: json.unreleased.map((c) => ({ ...c, clip: undefined })) } + }) + .catch(() => {}) +} + +/** The menu's line about the other copy of the game, and how many runs the player has finished on the next one. */ +export function channelLine(): string { + if (CHANNEL === 'next') return `<a href="${STABLE_PATH}">Back to the released version</a>` + if (CHANNEL !== 'stable') return '' + const early = earlyRuns() + return ( + (nextInfo ? `<a href="${NEXT_PATH}">Try it early</a>` : '') + + (early ? `${nextInfo ? ' · ' : ''}Early adopter: ${early} ${early === 1 ? 'run' : 'runs'} finished on the next version` : '') + ) +} + +/** What is finished and not yet released: the next copy's own list, or from the released copy the next one's teaser. */ +function unreleased(): string { + const list = CHANNEL === 'stable' ? nextInfo?.unreleased ?? [] : UNRELEASED + if (!list.length) return '' + const link = CHANNEL === 'stable' ? `<p><a href="${NEXT_PATH}">Try it early</a>, in a copy of the game of its own.</p>` : '' + return `<h3 class="release-head">${CHANNEL === 'stable' ? 'Coming next' : 'Not released yet'} <small>${list.length} ${list.length === 1 ? 'change' : 'changes'}</small></h3>${link}${list.map(change).join('')}` +} + const KIND_LABEL: Record<Change['kind'], string> = { added: 'Added', changed: 'Changed', removed: 'Removed' } function change(c: Change): string { @@ -57,7 +101,7 @@ export function openChanges(releases: Release[] = RELEASES, intro = false): void if (intro) markPlayed() openDialog({ title: intro ? `What's new in ${VERSION}` : 'Changes', - body: (intro ? '<p class="almanac-intro">Since you last played:</p>' : '') + releases.map(release).join(''), + body: (intro ? '<p class="almanac-intro">Since you last played:</p>' : unreleased()) + releases.map(release).join(''), buttons: [{ label: intro ? 'Got it' : 'Close', kind: 'plain' }], }) shown = { releases, intro } diff --git a/test/channel.test.ts b/test/channel.test.ts new file mode 100644 index 0000000..141031e --- /dev/null +++ b/test/channel.test.ts @@ -0,0 +1,9 @@ +import { describe, expect, it } from 'vitest' +import { CHANNEL, storageKey } from '../src/channel' + +describe('channels (user request, 2026-10-31)', () => { + it('tests run as the local copy, with the released copy’s key names', () => { + expect(CHANNEL).toBe('local') + expect(storageKey('run')).toBe('explodorama.run') + }) +}) diff --git a/test/charges.test.ts b/test/charges.test.ts index 923b8f9..3897005 100644 --- a/test/charges.test.ts +++ b/test/charges.test.ts @@ -59,6 +59,31 @@ describe('one charge at a time (PLAN.md §19.7)', () => { expect(state.coins).toBe(s.coins + salePrice('pierce', BOMB_BOOST - 1)) }) + it('firing the charge held, then taking the new one (user request, 2026-10-31)', () => { + const s = pickUp(pickUp(corridor(), 'freeze'), 'nuke') + const { state, events } = step(s, { id: 'fireCharge' }) + expect(state.frozen).toBe(FREEZE_TURNS) + expect(events).toContainEqual({ type: 'frozen', turns: FREEZE_TURNS }) + expect(state.charge).toEqual({ kind: 'nuke', left: CHARGE_AMOUNT.nuke }) + expect(state.chargeOffer).toBeNull() + expect(state.coins).toBe(s.coins) + expect(state.turn).toBe(s.turn) + }) + + it('firing a Freeze with uses left sells what remains', () => { + let s = pickUp(corridor(), 'freeze') + s.charge = { kind: 'freeze', left: 3 } + s = pickUp(s, 'nuke') + const { state } = step(s, { id: 'fireCharge' }) + expect(state.charge).toEqual({ kind: 'nuke', left: CHARGE_AMOUNT.nuke }) + expect(state.coins).toBe(s.coins + salePrice('freeze', 2)) + }) + + it('a charge that needs aiming can not be fired in the middle of a pickup', () => { + const s = pickUp(pickUp(corridor(), 'decoy'), 'nuke') + expect(() => step(s, { id: 'fireCharge' })).toThrow() + }) + it('the sale price is half the shop price, rounded up, for what is left', () => { expect(salePrice('decoy')).toBe(Math.ceil(PRICES.decoy / 2)) expect(salePrice('nuke', 1)).toBe(Math.ceil(PRICES.nuke / 2 / BOMB_BOOST)) diff --git a/test/picks.test.ts b/test/picks.test.ts index db1110e..347e616 100644 --- a/test/picks.test.ts +++ b/test/picks.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { isDeadly } from '../src/sim/danger' -import { canPick, pickTargets, resolvePick, tapMeaning } from '../src/sim/picks' +import { approachTile, canPick, pickChoices, pickTargets, resolvePick, tapChoices, tapMeaning } from '../src/sim/picks' import { board, v } from './helpers' describe('Move stands for every way of moving', () => { @@ -129,3 +129,101 @@ describe('a tap never costs a life (user request, 2026-10-26)', () => { expect(isDeadly({ ...s, options: { ...s.options, immortal: true } }, { id: 'wait' })).toBe(false) }) }) + +describe('a tap two actions could answer asks which (user request, 2026-10-31)', () => { + const bombNext = () => + board(` + ###### + #P3..# + ######`) + + it('a bomb next to you, with Kick: kicked at once', () => { + const s = bombNext() + s.powers.kick = true + expect(tapMeaning(s, v(2, 1))).toBe('kick') + }) + + it('a bomb next to you, with Bomb Pass alone: walked onto', () => { + const s = bombNext() + s.powers.bombPass = true + expect(tapMeaning(s, v(2, 1))).toBe('move') + }) + + it('with Kick and Bomb Pass: a choice of both', () => { + const s = bombNext() + s.powers.kick = true + s.powers.bombPass = true + expect(tapMeaning(s, v(2, 1))).toBe('choose') + expect(tapChoices(s, v(2, 1))).toEqual(['kick', 'move']) + }) + + it('Move picked, then the bomb: the same choice, dangerous or not', () => { + const s = bombNext() + s.powers.kick = true + s.powers.bombPass = true + expect(pickChoices(s, 'move', v(2, 1))).toEqual(['kick', 'move']) + s.powers.bombPass = false + expect(pickChoices(s, 'move', v(2, 1))).toEqual([]) + }) + + it('a bomb about to go off: walking onto it is deadly, so a tap just kicks; Move then the tile still offers both', () => { + const s = board(` + ###### + #P1..# + ######`) + s.powers.kick = true + s.powers.bombPass = true + expect(isDeadly(s, { id: 'move', target: v(2, 1) })).toBe(true) + expect(isDeadly(s, { id: 'kick', target: v(2, 1) })).toBe(false) + expect(tapChoices(s, v(2, 1))).toEqual(['kick']) + expect(tapMeaning(s, v(2, 1))).toBe('kick') + expect(pickChoices(s, 'move', v(2, 1))).toEqual(['kick', 'move']) + }) + + it('a bomb with neither is not moved into', () => { + expect(tapMeaning(bombNext(), v(2, 1))).toBe('blocked') + }) +}) + +describe('a tap on a crate walks to the tile beside it (user request, 2026-10-31)', () => { + const lane = () => + board(` + ####### + #P.+..# + #######`) + + it('with Run, a crate 2 tiles away is approached, not ignored', () => { + const s = lane() + s.powers.run = true + expect(tapMeaning(s, v(3, 1))).toBe('approach') + expect(approachTile(s, v(3, 1))).toEqual(v(2, 1)) + }) + + it('takes the side with the shorter way', () => { + const s = board(` + ####### + #P....# + #.#+#.# + #.....# + #######`) + expect(approachTile(s, v(3, 2))).toEqual(v(3, 1)) + // A crate diagonal from the player: the two tiles beside it that touch it. + const diag = board(` + ##### + #P..# + #.+.# + #####`) + expect([v(2, 1), v(1, 2)]).toContainEqual(approachTile(diag, v(2, 2))) + }) + + it('beside it already, or with no way there: nothing', () => { + const s = lane() + s.player = v(2, 1) + expect(tapMeaning(s, v(3, 1))).toBeNull() + const sealed = board(` + ####### + #P#+..# + #######`) + expect(tapMeaning(sealed, v(3, 1))).toBeNull() + }) +}) diff --git a/tsconfig.json b/tsconfig.json index 0ed6fc9..ddf6b83 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,6 +9,7 @@ "noUnusedLocals": true, "noUnusedParameters": true, "noEmit": true, + "allowImportingTsExtensions": true, "isolatedModules": true, "skipLibCheck": true, "types": ["vite/client", "node"] diff --git a/vite.config.ts b/vite.config.ts index caf9203..4c4e18f 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -2,6 +2,7 @@ import { defineConfig } from 'vite' import { qrcode } from 'vite-plugin-qrcode' import { execSync } from 'node:child_process' import fuzzyworld from 'fuzzyworld-tools/vite' +import { UNRELEASED, VERSION } from './src/changelog.ts' /** * Relative asset paths by default, so the same build works wherever Pages puts it: @@ -24,11 +25,29 @@ function git(args: string): string { process.env.VITE_COMMIT = git('rev-parse --short HEAD') process.env.VITE_COMMIT_DIRTY = git('status --porcelain') ? '1' : '' +// Which copy is being built (src/channel.ts): `fw deploy --as explodorama-next` builds the next +// one, from `main`, for anyone who wants to try what isn't released. Anything else is the released copy. +const next = (process.env.FW_APP ?? '').endsWith('-next') +process.env.VITE_CHANNEL = next ? 'next' : 'stable' + export default defineConfig({ base, // `npm run dev` and `npm run preview` bind to every interface (--host) so a phone on the same // Wi-Fi can open the game; this prints a scannable QR of that network URL alongside it. - plugins: [fuzzyworld(), qrcode()], + plugins: [ + fuzzyworld(), + qrcode(), + // The next copy tells the released one what is coming, for its "Try it early" teaser + // (src/ui/whatsnew.ts): next.json beside the game. + { + name: 'next-info', + generateBundle() { + if (!next) return + const info = { version: VERSION, commit: process.env.VITE_COMMIT, unreleased: UNRELEASED } + this.emitFile({ type: 'asset', fileName: 'next.json', source: JSON.stringify(info) }) + }, + }, + ], // In `npm run dev`, the game's console output and uncaught errors, from any device (a phone on // the Wi-Fi too), are printed in this terminal (user request, 2026-10-23: a crash on the phone // showed nothing anywhere). Caught errors are logged with their stack by src/devlog.ts.