explodorama / commit
Version box in the menu, charge toast, almanac and clip fixes (0.2.0 work)
README.md | 12 +++-- decisions.md | 38 ++++++++++++++ package.json | 1 + src/changelog.ts | 14 +++++ src/channel.ts | 11 ++-- src/main.ts | 24 +++++++-- src/style.css | 141 +++++++++++++++++++++++++++++++++++++++++++++++---- src/ui/almanac.ts | 33 +++++++++++- src/ui/clip.ts | 59 ++++++++++++++++++++- src/ui/toast.ts | 40 +++++++++++++++ src/ui/whatsnew.ts | 32 +++++++++--- test/channel.test.ts | 6 ++- test/toast.test.ts | 14 +++++ vite.config.ts | 6 +++ 14 files changed, 399 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index f0e770a..29e3547 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # EXPLODORAMA! -**[▶ Play now!](https://apps.fuzzyworld.net/explodorama/)** — in the browser, on a phone or a computer. +**[▶ Play now!](https://apps.fuzzyworld.net/explodorama/)** — in the browser, on a phone or a computer.\ +**[⏩ Try the next version](https://apps.fuzzyworld.net/explodorama-next/)** — what is coming, built from `main`; may have rough edges. A turn-based, mobile-first, rogue-like take on the 1985 NES bomb-laying classic. Every bomb counts down with *your* moves, so nothing happens until you act — and you can see exactly @@ -24,10 +25,13 @@ 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 +`npm run deploy:next` (needs fuzzyworld-tools with `--as`). The menu opens with a box saying which +version this is (**Official** or **Early adopter**) and a button to switch to the other, with both +addresses (`homepage` and `nextHomepage` in package.json, through vite.config.ts into +[src/channel.ts](src/channel.ts)). A released copy only has the button, **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 +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. diff --git a/decisions.md b/decisions.md index e575bc5..393cd2c 100644 --- a/decisions.md +++ b/decisions.md @@ -2349,3 +2349,41 @@ with `?dev`. In the demo the gem and charge choices open as real dialogs (new ge an almanac clip they are answered unseen. `test/test-scenes.test.ts` asserts each scene's outcome, so the same cases are checked without looking. To add a case: a name in `TestKind` and a `TEST_SCENES` entry; the demo table and icons follow from the list. Not in the 0.2.0 change list (a developer aid). + +### 2026-11-01 — Which version is this: a box in the menu + +User request: make it obvious in the menu which version is playing and how to switch. The menu now +opens with a box: **Official version** / **Early adopter version**, a button to the other and both +addresses. Addresses come from package.json (`homepage`, new `nextHomepage`) through vite.config.ts +(`VITE_HOME_URL`, `VITE_NEXT_URL`) into `src/channel.ts`, one source for the app and the README. The +old "Try it early" / "Back to the released version" links in the menu's bottom line are gone (the +Changes dialog's teaser keeps its link). The released copy shows the button only once next.json +answered. Not shown in `npm run dev` (channel `local`). Kenney's credit stays out of the menu +(since 0.2.0 work, 2026-10-31); CC0 needs none. + +### 2026-11-01 — A toast for a charge picked up again + +User request: a charge picked up for the second time in a run is simply taken (no dialog, as the +player knows the idea), but the icons are small, so say what it was. New `ui/toast.ts`: a note with +the charge's picture and name above the action bar, fading after ~2 s, one at a time. Shown for any +charge pickup that opened no dialog (not the first of its kind, not a sell-or-swap); not in the +demo. The toast is the general mechanism for other "you got X" notes later. + +Follow-up, same day: the toast says how many were picked up and the total held ("Picked up 3 × Nuke", +"5 charges in all"; the second line only when more is held than was picked up). It is a popover, so +it also shows over dialogs. **Rule: each new kind of UI piece like this gets a sample on the +almanac's Tests tab** ("Interface pieces", tap to show; `TOAST_SAMPLES` in `ui/almanac.ts`), so +Developer mode can show it without reproducing the situation. + +### 2026-11-01 — Clips show the choices; almanac tabs wrap + +User report (screenshot): five almanac tabs ran over each other (the Tests tab made five); the tabs are +now a grid of as many equal columns as fit, none under 104 px. And a clip of "slide over two gems" +only showed the slide, not the two dialogs. A clip now draws the open choice over itself +(`showChoice` in `ui/clip.ts`): the gem's cards with the scripted pick lit and "another gem is +waiting", or a charge's keep/take; held 2 s, then the pick plays. (The real dialog isn't opened in a +clip: it would cover the clip.) Only test scenes have such choices. + +Timing, same day: the choice comes after the turn that led to it has come to rest (a ~1.1 s pause in a +clip, 1.2 s in the `?dev` demo), shows ~2 s, goes, and the second follows after a pause (the clip's +own gap after the first goes, then 0.2 s), rather than at once over the slide. diff --git a/package.json b/package.json index ba84df3..f1234a5 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "url": "https://code.fuzzyworld.net/explodorama.git" }, "homepage": "https://apps.fuzzyworld.net/explodorama/", + "nextHomepage": "https://apps.fuzzyworld.net/explodorama-next/", "license": "MIT", "dependencies": { "littlejsengine": "1.28.0" diff --git a/src/changelog.ts b/src/changelog.ts index 18fbd4c..fc5ffff 100644 --- a/src/changelog.ts +++ b/src/changelog.ts @@ -48,6 +48,20 @@ 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: 'added', + icon: 'star', + title: 'Which version is this?', + text: ['The menu now opens with a box saying whether you are playing the official or the early adopter version, with a button to switch to the other.'], + date: '2026-11-01', + }, + { + kind: 'added', + icon: 'bomb', + title: 'A note when you pick up a charge', + text: ['The first charge of each kind in a run still explains itself. After that, picking one up shows a small note with its picture, name, how many you got and how many you hold, since the icons in the top bar are small.'], + date: '2026-11-01', + }, { kind: 'changed', icon: 'gem_green', diff --git a/src/channel.ts b/src/channel.ts index d89050c..4bc53e5 100644 --- a/src/channel.ts +++ b/src/channel.ts @@ -23,9 +23,14 @@ export function storageKey(name: string): string { */ 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/' +/** + * Where each copy lives: the full addresses come from package.json (`homepage`, `nextHomepage`, + * through vite.config.ts), the menu shows them; the paths are for the links and for next.json. + */ +export const HOME_URL: string = import.meta.env.VITE_HOME_URL || 'https://apps.fuzzyworld.net/explodorama/' +export const NEXT_URL: string = import.meta.env.VITE_NEXT_URL || 'https://apps.fuzzyworld.net/explodorama-next/' +export const STABLE_PATH = new URL(HOME_URL).pathname +export const NEXT_PATH = new URL(NEXT_URL).pathname /** Runs finished on the next copy, from either copy. */ export function earlyRuns(): number { diff --git a/src/main.ts b/src/main.ts index 252478b..ae15066 100644 --- a/src/main.ts +++ b/src/main.ts @@ -34,12 +34,14 @@ import { LAST_LEVEL } from './sim/levelgen' 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' +import { CHARGE_AMOUNT } from './sim/charges' import { VERSION } from './changelog' -import { channelLine, loadNextInfo, newSinceLastVisit, openChanges } from './ui/whatsnew' +import { channelBox, 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 { actionInfo, CHARGE_TEXT, chargeInfo, hudInfo, tileInfo, type Info } from './ui/info' import { openChargeOffer } from './ui/chargeOffer' import { openTapChoice } from './ui/tapChoice' import { bindTimeline } from './ui/timeline' @@ -248,6 +250,15 @@ function act(action: Action, auto = false): void { // The first charge of each kind this run says what it does, so a new button is never a // mystery; unless a gem was taken on the same turn (a run over both), whose choice comes first. const charge = firstCharge(e) + // Any later pickup of a charge just goes into the bag, so a toast says what it was (a swap asks instead). + if (!charge && e.type === 'pickedUp' && e.drop.kind !== 'coins' && !result.events.some((x) => x.type === 'chargeOffered')) { + const kind = e.drop.kind + const n = e.drop.left ?? CHARGE_AMOUNT[kind] + const total = result.state.charge?.kind === kind ? result.state.charge.left : n + const { title, text } = chargeToast(CHARGE_TEXT[kind].name, n, total) + // Not over a choice that is open (a gem's, a charge's): its dialog says enough. + window.setTimeout(() => state.offer || state.chargeOffer || showToast(spriteUrl(CHARGE_ICONS[kind]), title, text), PICKUP_DIALOG_MS) + } if (charge && !result.events.some((x) => x.type === 'chargeOffered')) window.setTimeout(() => state.offer || state.chargeOffer || showInfo(chargeInfo(charge)), PICKUP_DIALOG_MS) } selected = null @@ -501,6 +512,7 @@ function showMenu(): void { body: `<p>${where}Seed ${state.seed}. The game saves after every move, so you can close it and come back.</p>` + (modes.length ? `<p class="mode">${modes.length > 1 ? 'Modes' : 'Mode'}: ${modes.join(', ')}.</p>` : '') + + channelBox() + feedbackChoices() + // 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. @@ -517,6 +529,9 @@ function showMenu(): void { ], className: 'menu', }) + document.querySelector<HTMLElement>('#dialog-body .channel-switch')?.addEventListener('click', (e) => { + location.href = (e.currentTarget as HTMLElement).dataset.go! + }) document.getElementById('changes-link')!.addEventListener('click', (e) => { e.preventDefault() closeDialog(() => openChanges()) @@ -1205,8 +1220,11 @@ function beatsFor(g: Gesture, action: Action, last: boolean): Beat[] { } if (g.kind === 'pick') { // A test scene's answer to the dialog that opens (a gem's choice, or a charge's): shown, then taken. + // After a pause, so the turn before (a slide) is seen to come to rest; between two it is the + // settle after the one closing. return [ - { run: () => (state.offer ? openOffer(state, state.offer, () => {}) : openChargeOffer(state, () => {})), then: INFO_SECONDS }, + { run: () => {}, then: 1.2 }, + { run: () => (state.offer ? openOffer(state, state.offer, () => {}) : openChargeOffer(state, () => {})), then: 2.4 }, { ...play, run: () => (closeDialog(), play.run()) }, ] } diff --git a/src/style.css b/src/style.css index 20d0146..94022e8 100644 --- a/src/style.css +++ b/src/style.css @@ -188,6 +188,59 @@ img { text-transform: uppercase; } +#toast { + position: fixed; + /* A popover (top layer, over dialogs): undo its centred default. */ + inset: auto; + margin: 0; + overflow: visible; + left: 50%; + bottom: calc(env(safe-area-inset-bottom) + 96px); + z-index: 3; + align-items: center; + gap: 10px; + padding: 8px 16px 8px 10px; + border: 2px solid var(--ink); + border-radius: 16px; + background: #ece3cc; + color: var(--ink); + pointer-events: none; + opacity: 0; + transform: translate(-50%, 8px); + transition: opacity 0.25s, transform 0.25s; +} + +#toast:popover-open { + display: flex; +} + +#toast.show { + opacity: 1; + transform: translate(-50%, 0); +} + +#toast img { + width: 44px; + height: 44px; + image-rendering: auto; +} + +#toast strong, +#toast span { + display: block; +} + +#toast span { + font-size: 12px; + color: var(--muted); +} + +@media (prefers-reduced-motion: reduce) { + #toast { + transition: none; + } +} + #actions { position: fixed; bottom: 0; @@ -624,8 +677,9 @@ img { top: 0; /* Over the entries scrolling under it (user report, 2026-10-25: their text showed through). */ z-index: 2; - display: flex; - justify-content: center; + /* As many equal tabs per row as fit, none narrower than its label (user report, 2026-11-01: five tabs overran). */ + display: grid; + grid-template-columns: repeat(auto-fit, minmax(104px, 1fr)); gap: 6px; padding-bottom: 8px; background: #ece3cc; @@ -633,13 +687,6 @@ img { /* Equal tabs that always fit the row (user report, 2026-10-25: on a phone they ran off both ends, cut off). Four since the Board tab: two rows of two on a phone. */ -@media (max-width: 440px) { - .almanac-tabs { - display: grid; - grid-template-columns: 1fr 1fr; - } -} - .almanac-tabs button { flex: 1 1 0; min-width: 0; @@ -896,6 +943,39 @@ button.almanac-entry.has-clip:active { font-weight: 700; } +#dialog-body .channel-box { + margin: 0 0 10px; + padding: 8px 10px; + border: 2px solid var(--ink); + border-radius: 12px; + text-align: center; +} + +#dialog-body .channel-box p { + margin: 0 0 6px; +} + +#dialog-body .channel-box p:last-child { + margin-bottom: 0; +} + +.channel-sub { + display: block; + font-size: 12px; + color: var(--muted); +} + +.channel-switch { + margin: 2px 0 6px; + padding: 6px 12px; + border: 2px solid var(--accent-dark); + border-radius: 14px; + background: var(--accent); + color: #fff; + font: 11px 'Kenney Future', ui-rounded, system-ui, sans-serif; + cursor: pointer; +} + #dialog-body .mode { font-size: 13px; text-align: left; @@ -1486,6 +1566,49 @@ canvas.clip[hidden] { } /* The demo's ripple and ring, inside the clip's frame instead of on the page. */ +.clip-frame .clip-choice { + position: absolute; + left: 6%; + right: 6%; + bottom: 6%; + padding: 6px 8px; + border: 2px solid var(--ink); + border-radius: 12px; + background: #ece3cc; + color: var(--ink); + font-size: 11px; + text-align: center; + pointer-events: none; +} + +.clip-frame .clip-choice div { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 4px; + margin-top: 4px; +} + +.clip-frame .clip-choice span { + display: inline-flex; + align-items: center; + gap: 3px; + padding: 2px 6px; + border: 2px solid transparent; + border-radius: 10px; +} + +.clip-frame .clip-choice span.picked { + border-color: var(--accent-dark); + background: var(--accent); + color: #fff; +} + +.clip-frame .clip-choice img { + width: 18px; + height: 18px; +} + .clip-frame .demo-ripple, .clip-frame .demo-ring { position: absolute; diff --git a/src/ui/almanac.ts b/src/ui/almanac.ts index 34a3c1b..0a0e33f 100644 --- a/src/ui/almanac.ts +++ b/src/ui/almanac.ts @@ -6,6 +6,7 @@ import type { SceneKind } from '../sim/demo' import { openDialog } from './dialog' import { startClip } from './clip' import { TEST_KINDS, TEST_SCENES } from '../sim/testScenes' +import { chargeToast, showToast } from './toast' import { BOARD_TEXT, CHARGE_TEXT, ENEMY_TEXT, POWER_UP_CLIP, POWER_UP_TEXT } from './info' /** @@ -31,6 +32,12 @@ const POWER_UPS: PowerUpKind[] = [ const ENEMIES = ENEMY_ORDER +/** The Tests tab's samples of the toast: tapping one shows it, as in play (add one for each new kind of toast). */ +const TOAST_SAMPLES = [ + { kind: 'nuke' as const, name: 'Toast: a charge picked up again', about: 'Two Nuke charges held, three picked up: says what was picked up and the total.', n: 3, total: 5 }, + { kind: 'shield' as const, name: 'Toast: a single charge', about: 'One Shield picked up, none held: just its name.', n: 1, total: 1 }, +] + /** The first level an enemy type can appear on. */ function firstLevel(kind: EnemyKind): number { let level = 1 @@ -70,7 +77,9 @@ function body(tab: AlmanacTab): string { } else if (tab === 'tests') { list = '<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('') + 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('') } 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>' + @@ -91,13 +100,26 @@ export function openAlmanac(tab: AlmanacTab = shownTab, dev: boolean = shownDev) /** An entry's own dialog: its clip above what it does, and Back to the almanac where it was. */ function openEntry(button: HTMLElement): void { + // Back returns to where the list was scrolled to (user request, 2026-11-01). + const scrolled = dialogBody.scrollTop const { clip, sprite, name } = button.dataset const text = [...button.querySelectorAll('p')].map((p) => p.outerHTML).join('') openDialog({ icon: spriteUrl(sprite!), title: name!, body: '<canvas class="clip" aria-hidden="true"></canvas>' + text, - buttons: [{ label: 'Back', kind: 'plain', onClick: () => openAlmanac() }], + buttons: [ + { + label: 'Back', + kind: 'plain', + onClick: () => { + openAlmanac() + dialogBody.scrollTop = scrolled + // Once laid out, if the dialog wasn't yet. + requestAnimationFrame(() => (dialogBody.scrollTop = scrolled)) + }, + }, + ], }) startClip(document.querySelector<HTMLCanvasElement>('#dialog-body canvas.clip')!, clip as SceneKind) } @@ -113,6 +135,13 @@ dialogBody.addEventListener('click', (e) => { dialogBody.scrollTop = 0 return } + const toast = target.closest<HTMLElement>('[data-toast]') + if (toast) { + const t = TOAST_SAMPLES[Number(toast.dataset.toast)]! + const { title, text } = chargeToast(CHARGE_TEXT[t.kind].name, t.n, t.total) + showToast(spriteUrl(CHARGE_ICONS[t.kind]), title, text) + return + } const entryButton = target.closest<HTMLElement>('.almanac-entry[data-clip]') if (entryButton) openEntry(entryButton) }) diff --git a/src/ui/clip.ts b/src/ui/clip.ts index a1d17df..0fa763b 100644 --- a/src/ui/clip.ts +++ b/src/ui/clip.ts @@ -6,6 +6,9 @@ import type { Gesture, Scene, SceneKind } from '../sim/demo' import { step } from '../sim/turn' import type { Action, ActionId, GameState, Vec } from '../sim/types' import handIcon from '../../assets/kenney-ui-rpg/cursorHand_beige.svg' +import { CHARGE_ICONS, POWER_UP_ICONS, spriteUrl } from '../render/sprites' +import { isCharge } from '../sim/types' +import { CHARGE_TEXT, POWER_UP_TEXT } from './info' import { makeScene } from './scenes' import { timelineAfter, type Timeline } from './timeline' @@ -57,6 +60,10 @@ const FINGER_KINDS: ReadonlySet<SceneKind> = new Set([ const GLIDE_SECONDS = 0.35 const TAP_SECONDS = 0.18 const HOLD_SECONDS_PRESS = 0.45 +/** A turn that leaves a choice open (a gem's offer, a charge's sell-or-swap) is held this long, to read it. */ +const CHOICE_SECONDS = 2 +/** The pause before a choice shows (the turn that led to it comes to rest first), and between two choices. */ +const CHOICE_GAP_SECONDS = 1.1 /** After a turn, before the finger moves on. */ const SETTLE_SECONDS = 0.45 @@ -106,6 +113,7 @@ export function startClip(canvas: HTMLCanvasElement, source: SceneKind | Scene, /** Stops the clip in `canvas`, if one plays there. */ export function stopClip(canvas: HTMLCanvasElement): void { + canvas.parentElement?.querySelector('.clip-choice')?.remove() const bar = canvas.parentElement?.nextElementSibling if (bar?.classList.contains('timeline')) bar.remove() clips = clips.filter((o) => o.canvas !== canvas) @@ -129,6 +137,7 @@ function tick(c: Clip): void { pruneAnimation(c.anim, time) const done = !c.scene || (c.next >= c.scene.actions.length && !c.beats.length && time >= c.at) if (done && c.upcoming) { + c.canvas.parentElement?.querySelector('.clip-choice')?.remove() c.scene = c.upcoming c.upcoming = null c.state = c.scene.start @@ -164,10 +173,18 @@ function tick(c: Clip): void { if (c.finger) c.beats = gestureBeats(c, c.scene.gestures[i]!, action, last) else { play(c, action) - c.at = time + (last ? HOLD_SECONDS : STEP_SECONDS) + // A choice the turn left open (test scenes): after a pause, so the turn is seen to come to + // rest, it shows for long enough to read, then goes (the next turn answers it). + if (c.state.offer || c.state.chargeOffer) { + c.beats = [{ run: () => showChoice(c), then: CHOICE_SECONDS }, { run: () => hideChoice(c), then: CHOICE_GAP_SECONDS }] + // The pause after a choice went was already waited, so an answer's own follow-up choice shows after a short one. + c.at = time + (isAnswer(action) ? 0.2 : CHOICE_GAP_SECONDS) + } else c.at = time + (last ? HOLD_SECONDS : STEP_SECONDS) } // The bar fills over the turn: as long as its beats, or until the next turn. - c.timeline?.set(c.next, c.scene.actions.length, c.scene.moment, c.finger ? c.beats.reduce((sum, b) => sum + b.then, 0) : last ? HOLD_SECONDS : STEP_SECONDS) + 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 + c.timeline?.set(c.next, c.scene.actions.length, c.scene.moment, span) } /** @@ -183,7 +200,45 @@ function still(c: Clip): void { c.finger?.classList.add('away') } +const isAnswer = (a: Action): boolean => a.id === 'choose' || a.id === 'sellCharge' || a.id === 'swapCharge' || a.id === 'fireCharge' + +function hideChoice(c: Clip): void { + c.canvas.parentElement?.querySelector('.clip-choice')?.remove() +} + +/** + * The dialog a turn leaves open, drawn over the clip (the real one isn't shown in a clip): a gem's + * choice with its cards, the scripted pick lit, or a charge's sell-or-swap. True if one is open. + */ +function showChoice(c: Clip): boolean { + const s = c.state! + const frame = c.canvas.parentElement! + hideChoice(c) + // The turn answering it is the next one still to play (`next` has moved on past the turn that opened it). + const next = c.scene!.actions[c.next] + let html = '' + if (s.offer) { + const pick = next?.id === 'choose' ? next.kind : null + const cards = s.offer.map((k) => { + const icon = isCharge(k) ? CHARGE_ICONS[k] : POWER_UP_ICONS[k] + const name = isCharge(k) ? CHARGE_TEXT[k].name : POWER_UP_TEXT[k].name + return `<span class="${k === pick ? 'picked' : ''}"><img src="${spriteUrl(icon)}" alt="" draggable="false">${name}</span>` + }) + html = `<strong>Choose a power-up${s.waitingGems.length ? ' (another gem is waiting)' : ''}</strong><div>${cards.join('')}</div>` + } else if (s.chargeOffer) { + const held = s.charge ? CHARGE_TEXT[s.charge.kind].name : 'the one held' + html = `<strong>A ${CHARGE_TEXT[s.chargeOffer.kind].name} charge, and you carry ${held}</strong><div><span class="${next?.id === 'sellCharge' ? 'picked' : ''}">Keep (sell the new one)</span><span class="${next?.id === 'swapCharge' ? 'picked' : ''}">Take (sell the old one)</span></div>` + } + if (!html) return false + const box = document.createElement('div') + box.className = 'clip-choice' + box.innerHTML = html + frame.append(box) + return true +} + function play(c: Clip, action: Action): void { + c.canvas.parentElement?.querySelector('.clip-choice')?.remove() const result = step(c.state!, action) animateTurn(c.anim, c.state!, result, action, time) c.state = result.state diff --git a/src/ui/toast.ts b/src/ui/toast.ts new file mode 100644 index 0000000..93b2d0a --- /dev/null +++ b/src/ui/toast.ts @@ -0,0 +1,40 @@ +/** + * A toast (user request, 2026-11-01): a short note over the board that fades by itself, for what + * the player got when no dialog explains it (a charge picked up for the second time in a run; the + * icons in the top bar are small). One at a time; a new one replaces the one showing. + */ +const SHOW_MS = 2200 + +let el: HTMLElement | null = null +let timer = 0 + +/** + * What a charge pickup says: `n` picked up, and the total held when that is more (2 held and 3 + * picked up: "Picked up 3 × Nuke", "5 charges in all"; user request, 2026-11-01). + */ +export function chargeToast(name: string, n: number, total: number): { title: string; text: string } { + return { title: n > 1 ? `Picked up ${n} × ${name}` : `Picked up ${name}`, text: total > n ? `${total} charges in all` : '' } +} + +export function showToast(icon: string, title: string, text: string): void { + if (!el) { + el = document.createElement('div') + el.id = 'toast' + el.setAttribute('role', 'status') + // In the top layer, so it also shows over a dialog (the almanac's samples). + el.popover = 'manual' + document.body.append(el) + } + el.innerHTML = `<img src="${icon}" alt="" draggable="false"><div><strong>${title}</strong>${text ? `<span>${text}</span>` : ''}</div>` + el.classList.remove('show') + if (!el.matches(':popover-open')) el.showPopover?.() + // Restarts the fade when one replaces another. + void el.offsetWidth + el.classList.add('show') + window.clearTimeout(timer) + timer = window.setTimeout(() => { + el?.classList.remove('show') + // Hidden once the fade is done. + window.setTimeout(() => !el?.classList.contains('show') && el?.hidePopover?.(), 300) + }, SHOW_MS) +} diff --git a/src/ui/whatsnew.ts b/src/ui/whatsnew.ts index cb5acbc..da8b731 100644 --- a/src/ui/whatsnew.ts +++ b/src/ui/whatsnew.ts @@ -1,4 +1,4 @@ -import { CHANNEL, earlyRuns, NEXT_PATH, STABLE_PATH, storageKey } from '../channel' +import { CHANNEL, earlyRuns, HOME_URL, NEXT_PATH, NEXT_URL, 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' @@ -59,17 +59,33 @@ export function loadNextInfo(): void { .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() +/** + * The menu's box at the top (user request, 2026-11-01): which version this is, and a button to the + * other one, with both addresses. The released copy only offers the early one once it has heard + * from it (`nextInfo`). Each copy keeps its own saved run and scores. + */ +export function channelBox(): string { + if (CHANNEL === 'local') return '' + const next = CHANNEL === 'next' + const there = next ? HOME_URL : NEXT_URL + const offer = next || nextInfo return ( - (nextInfo ? `<a href="${NEXT_PATH}">Try it early</a>` : '') + - (early ? `${nextInfo ? ' · ' : ''}Early adopter: ${early} ${early === 1 ? 'run' : 'runs'} finished on the next version` : '') + `<div class="channel-box"><p><strong>${next ? 'Early adopter version' : 'Official version'}</strong>` + + `<span class="channel-sub">${next ? 'what is coming next; it may have rough edges' : 'the released game'}</span></p>` + + (offer + ? `<button type="button" class="channel-switch" data-go="${there}">Switch to the ${next ? 'official' : 'early adopter'} version</button>` + + `<p class="credit">${there.replace(/^https?:\/\//, '')} · each version keeps its own saved run and scores</p>` + : '') + + `</div>` ) } +/** The menu's small line about the early adopter runs finished on the next version, from either copy. */ +export function channelLine(): string { + const early = earlyRuns() + return early ? `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 diff --git a/test/channel.test.ts b/test/channel.test.ts index 141031e..bb6a49e 100644 --- a/test/channel.test.ts +++ b/test/channel.test.ts @@ -1,9 +1,13 @@ import { describe, expect, it } from 'vitest' -import { CHANNEL, storageKey } from '../src/channel' +import { CHANNEL, NEXT_PATH, STABLE_PATH, 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') }) + it('the paths are worked out from the addresses in package.json', () => { + expect(STABLE_PATH).toBe('/explodorama/') + expect(NEXT_PATH).toBe('/explodorama-next/') + }) }) diff --git a/test/toast.test.ts b/test/toast.test.ts new file mode 100644 index 0000000..32921ff --- /dev/null +++ b/test/toast.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest' +import { chargeToast } from '../src/ui/toast' + +describe('the charge toast (user request, 2026-11-01)', () => { + it('says how many were picked up and the total held', () => { + expect(chargeToast('Nuke', 3, 5)).toEqual({ title: 'Picked up 3 × Nuke', text: '5 charges in all' }) + }) + it('a single pickup with none held is just the name', () => { + expect(chargeToast('Shield', 1, 1)).toEqual({ title: 'Picked up Shield', text: '' }) + }) + it('one more on top of those held still gives the total', () => { + expect(chargeToast('Shield', 1, 2)).toEqual({ title: 'Picked up Shield', text: '2 charges in all' }) + }) +}) diff --git a/vite.config.ts b/vite.config.ts index 4c4e18f..812e5e8 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,6 +1,7 @@ import { defineConfig } from 'vite' import { qrcode } from 'vite-plugin-qrcode' import { execSync } from 'node:child_process' +import { readFileSync } from 'node:fs' import fuzzyworld from 'fuzzyworld-tools/vite' import { UNRELEASED, VERSION } from './src/changelog.ts' @@ -29,6 +30,11 @@ process.env.VITE_COMMIT_DIRTY = git('status --porcelain') ? '1' : '' // 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' +// Where each copy lives, from package.json (`homepage`, `nextHomepage`): the menu shows and links them (src/channel.ts). +const pkg = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8')) as { homepage: string; nextHomepage: string } +// (Assigning undefined would make the text "undefined".) +if (pkg.homepage) process.env.VITE_HOME_URL = pkg.homepage +if (pkg.nextHomepage) process.env.VITE_NEXT_URL = pkg.nextHomepage export default defineConfig({ base,