explodorama / commit
Semver 0.1.0 as the base release, with an in-game list of changes
CHANGELOG.md | 15 +++++++-- README.md | 20 ++++++++---- decisions.md | 14 ++++++++ src/changelog.ts | 88 ++++++++++++++++++++++++++++++++++++++++++++++++++ src/main.ts | 27 +++++++++++++--- src/style.css | 17 ++++++++++ src/ui/whatsnew.ts | 85 ++++++++++++++++++++++++++++++++++++++++++++++++ test/changelog.test.ts | 53 ++++++++++++++++++++++++++++++ test/version.test.ts | 4 +-- 9 files changed, 309 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 75fafb7..e7d9903 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,11 @@ # Changelog All notable changes to Explodorama are written down here. The format follows -[Keep a Changelog](https://keepachangelog.com/en/1.1.0/). There has been no tagged release -yet, so everything so far is under **Unreleased**. +[Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Releases are semver, git-tagged +by Pavel. **0.1.0 is the base**: everything under it below is part of it, and the player-facing list +of changes in the game ([src/changelog.ts](src/changelog.ts)) starts there. From now on, each entry +that reaches players is also added to `UNRELEASED` in that file. "Version N" in older entries is +the rules version N (`SIM_VERSION`). A saved run is only resumed if it was made with the same version of the rules (`SIM_VERSION` in [src/sim/record.ts](src/sim/record.ts)). When a change below bumps it, a run @@ -12,6 +15,14 @@ saved before the change is dropped and the next visit starts a new run. ### 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. + +## [0.1.0] - 2026-10-31 + +The base: everything below. + +### Added + - **Deadly moves are marked:** Wait turns red when waiting would cost you a life, and with a button picked, a tile that would is red with a cross. Taps, keys with nothing picked, the long press and auto-move refuse such a move (the tile flashes red); the button then the tile still makes it. - **Demo scenes for the board objects** (hiding in a bush, a bush fire, metal stopping flame, an oil spill, crossing spikes, a barrel going off), and a **Board** tab in the almanac listing crates, pillars, every object and rubble, with clips. diff --git a/README.md b/README.md index b577e8d..506cb77 100644 --- a/README.md +++ b/README.md @@ -11,12 +11,20 @@ seven charges (one carried at a time, used when you choose), a hidden gem on eve mid-run work. Curses and the Reaper enemy are planned — see [PLAN.md](PLAN.md). -Version: **40**, shown at the bottom of the menu with the commit the game was built from (a link -to it on GitLab, plus "plus local changes" in a build with uncommitted changes) and a link to the -[source code](https://code.fuzzyworld.net/explodorama/). The version is the rules version -(`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. A run saved under another version is not -resumed, and a seed gives the same dungeon only within one version. +Version: **0.1.0** (semver; the base release). The menu's bottom line shows it with the rules +version and the commit the game was built from (a link to it on GitLab, plus "plus local +changes" in a build with uncommitted changes) and a link to the +[source code](https://code.fuzzyworld.net/explodorama/). Tapping the version opens the list of +changes in every release, each with its date and, for most, a demo scene. A player who comes +back on a newer version is shown what came after the one they last played (kept in this +browser's localStorage) when the opening demo ends; a first visit, and a test URL, show nothing. Releases are git tags, made when Pavel decides a batch is ready; between +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. + +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, +and a seed gives the same dungeon only within one rules version.  diff --git a/decisions.md b/decisions.md index 2e133d7..93840da 100644 --- a/decisions.md +++ b/decisions.md @@ -2251,3 +2251,17 @@ ink (`#353541`) and sizes to 128 px: four tall-grass tufts (`grass_a`–`grass_d canopies (`canopy_a`, `canopy_b`, composed but not used yet). A bush's picture is picked from its cell index (`bushSprite`), so neighbours differ but a bush never changes picture between frames. Credited in the menu and the README with the other packs. + +### 2026-10-31 — Semver releases and an in-game list of changes + +User request (2026-10-31): the game gets semver versions, git-tagged releases, and a "what's new +since you last played" dialog with demo scenes and text. Decided with the user: **0.1.0 is the +base** (no history is rebuilt; the list starts there); the last version played is kept in +localStorage (`explodorama.lastVersion`); Claude keeps the version, `UNRELEASED` and the tags +consistent, **Pavel decides when a release happens**. `SIM_VERSION` stays as the rules version +(shown as "rules 40" beside the release) and is independent of it. The data lives in +`src/changelog.ts` (releases, each change with kind, date, text and optional clip); a first +visit only records the version and shows nothing; the dialog opens when the demo ends. Tapping the +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). diff --git a/src/changelog.ts b/src/changelog.ts new file mode 100644 index 0000000..44fe341 --- /dev/null +++ b/src/changelog.ts @@ -0,0 +1,88 @@ +import type { SceneKind } from './sim/demo' + +/** + * What changed in each release, for the player (user request, 2026-10-31). Shown in the game as + * "what's new since you last played" and, from the menu's version, as the full list. Not the + * developer's CHANGELOG.md, which is longer and written for people who read the code. + * + * Versions are semver, git-tagged by hand when Pavel decides a batch is ready to release. 0.1.0 + * is the base: nothing before it is listed. Between releases, finished changes collect in + * UNRELEASED; when a release is tagged they move into a new entry at the top of RELEASES, and + * `version` in package.json goes up to match (test/changelog.test.ts checks that). + * + * This is separate from SIM_VERSION (sim/record.ts), the rules version that decides whether a + * saved run resumes. One release can contain several rules versions. + */ +export interface Change { + kind: 'added' | 'changed' | 'removed' + /** Sprite name, from `assets/kenney/`. */ + icon: string + title: string + /** Paragraphs of plain text, as in the almanac. */ + text: string[] + /** The demo scene its entry plays as a looping clip (ui/clip.ts); none for a removal. */ + clip?: SceneKind + /** When it was added, as YYYY-MM-DD. */ + date: string +} + +export interface Release { + /** Semver, x.y.z. */ + version: string + /** YYYY-MM-DD. */ + date: string + /** One line: what this release is. */ + summary: string + changes: Change[] +} + +/** Newest first. */ +export const RELEASES: Release[] = [ + { + version: '0.1.0', + date: '2026-10-31', + summary: 'The first release, and the base later ones are compared with. The Almanac in the menu describes everything in it.', + changes: [], + }, +] + +/** Finished since the latest release, not yet part of one. Moved into RELEASES when one is tagged. */ +export const UNRELEASED: Change[] = [] + +/** The version being played. */ +export const VERSION = RELEASES[0]!.version + +/** Parse "x.y.z" into numbers; null if it isn't that. */ +export function parseVersion(v: string): [number, number, number] | null { + const m = /^(\d+)\.(\d+)\.(\d+)$/.exec(v) + return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null +} + +/** Negative if a < b, 0 if equal, positive if a > b. An unparseable version counts as older than any. */ +export function compareVersions(a: string, b: string): number { + const pa = parseVersion(a) + const pb = parseVersion(b) + if (!pa || !pb) return pa ? 1 : pb ? -1 : 0 + for (let i = 0; i < 3; i++) { + const d = pa[i]! - pb[i]! + if (d) return d + } + return 0 +} + +/** The releases newer than `last`, newest first; none if `last` is current (or newer, after a rollback). */ +export function releasesSince(last: string, releases: Release[] = RELEASES): Release[] { + return releases.filter((r) => compareVersions(r.version, last) > 0) +} + +/** + * What to show a player who last played `last` (null: never, or a value that wasn't kept), and + * whether to record the current version now. A first visit has nothing to compare with: nothing + * to show, record it. Newer releases: show them (recording waits until they are shown). Current: + * nothing. Newer than the game (a rollback): nothing, and the newer record stays. + */ +export function newsFor(last: string | null, releases: Release[] = RELEASES): { news: Release[]; record: boolean } { + if (last === null) return { news: [], record: true } + const news = releasesSince(last, releases) + return { news, record: !news.length && compareVersions(last, releases[0]!.version) < 0 } +} diff --git a/src/main.ts b/src/main.ts index 66d1c3d..7984925 100644 --- a/src/main.ts +++ b/src/main.ts @@ -34,6 +34,8 @@ 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 { VERSION } from './changelog' +import { newSinceLastVisit, openChanges } from './ui/whatsnew' 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' @@ -434,7 +436,7 @@ function bugDetails(withRecord: boolean): string { const modes = [state.options.dev && 'Developer', state.options.immortal && 'Immortal'].filter(Boolean).join(', ') || 'none' const errors = recentErrors() return [ - `Version ${SIM_VERSION}${commit}`, + `Version ${VERSION} (rules ${SIM_VERSION})${commit}`, `Device: ${device()}; ${navigator.userAgent}`, `Seed ${state.seed}, level ${state.level}, turn ${state.turn}, ${state.lives} lives, ${state.status}; modes: ${modes}`, `Errors caught: ${errors.length ? '\n' + errors.map((e) => `- ${e}`).join('\n') : 'none'}`, @@ -450,7 +452,7 @@ function bugDetails(withRecord: boolean): string { function reportBug(): void { const full = bugDetails(true) const mailto = (details: string) => - `mailto:${BUG_EMAIL}?subject=${encodeURIComponent(`Bug report: version ${SIM_VERSION}, level ${state.level}`)}` + + `mailto:${BUG_EMAIL}?subject=${encodeURIComponent(`Bug report: version ${VERSION} (rules ${SIM_VERSION}), level ${state.level}`)}` + `&body=${encodeURIComponent(`What happened:\n\n\n\n---\n${details}`)}` const withRecord = mailto(full) if (withRecord.length <= MAILTO_MAX) { @@ -487,8 +489,9 @@ function showMenu(): void { feedbackChoices() + `<p class="credit">Art and sounds by <a href="https://www.kenney.nl" target="_blank" rel="noopener">Kenney</a>: ` + `New Platformer Pack, UI Pack, UI Pack: RPG Expansion, Game Icons, Board Game Icons, Splat Pack and Foliage Sprites (CC0). Thank you!</p>` + - // The version is the rules version: it changes exactly when old saves stop resuming. - `<p class="credit">Version ${SIM_VERSION}${commitLine()}. ` + + // 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>`, buttons: [ { label: 'Resume', kind: 'primary', icon: playIcon }, @@ -500,6 +503,10 @@ function showMenu(): void { ], className: 'menu', }) + document.getElementById('changes-link')!.addEventListener('click', (e) => { + e.preventDefault() + closeDialog(() => openChanges()) + }) // The switches work at once, without a button (user request, 2026-10-24: sounds and vibration). for (const input of document.querySelectorAll<HTMLInputElement>('#dialog-body .feedback input')) { input.addEventListener('change', () => { @@ -1246,6 +1253,15 @@ function endDemo(): void { showOffer() showChargeOffer() if (state.shop) openShopDialog() + // What changed since the player last played, once the demo is out of the way. + // After the demo's own dialog has finished closing (dialog.ts); not over a choice that reopened. + if (news.length) { + setTimeout(() => { + if (dialogOpen()) return + openChanges(news, true) + news = [] + }, 250) + } } // The tap or key that ends the demo goes no further: not to the board, nor to a button that @@ -1292,6 +1308,9 @@ addEventListener( true, ) +// What came after the version the player last played, shown when the demo ends (endDemo). Test +// URLs neither show it nor change what is remembered. +let news = testing ? [] : newSinceLastVisit() if (!testing || params.has('demo')) startDemo() // LittleJS's development build writes its version and frame rate top left; the top bar used to diff --git a/src/style.css b/src/style.css index cabd1ba..9a9f8d5 100644 --- a/src/style.css +++ b/src/style.css @@ -1608,3 +1608,20 @@ canvas.clip[hidden] { white-space: pre; pointer-events: none; } + +/* The changes dialog (ui/whatsnew.ts): a heading per release, each change tagged with its kind and date. */ +#dialog-body .release-head { + margin: 14px 0 2px; + font-size: 16px; +} + +#dialog-body .release-head small { + color: var(--muted); + font-size: 11px; + font-weight: normal; +} + +#dialog-body .almanac-entry p.change-kind { + font-size: 11px; + color: var(--muted); +} diff --git a/src/ui/whatsnew.ts b/src/ui/whatsnew.ts new file mode 100644 index 0000000..5822546 --- /dev/null +++ b/src/ui/whatsnew.ts @@ -0,0 +1,85 @@ +import { newsFor, RELEASES, VERSION, type Change, type Release } from '../changelog' +import { spriteUrl } from '../render/sprites' +import type { SceneKind } from '../sim/demo' +import { startClip } from './clip' +import { openDialog } from './dialog' + +/** + * The changes dialog (user request, 2026-10-31). Opened on its own when the game opens on a + * 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' + +function lastPlayed(): string | null { + try { + return localStorage.getItem(LAST_KEY) + } catch { + return null + } +} + +function markPlayed(): void { + try { + localStorage.setItem(LAST_KEY, VERSION) + } catch { + // No storage (a private window): the player sees the changes again next time. + } +} + +/** What to tell the player about when the game opens (newsFor, changelog.ts); records the version where that says to. */ +export function newSinceLastVisit(): Release[] { + const { news, record } = newsFor(lastPlayed()) + if (record) markPlayed() + return news +} + +const KIND_LABEL: Record<Change['kind'], string> = { added: 'Added', changed: 'Changed', removed: 'Removed' } + +function change(c: Change): string { + const inner = + `<img src="${spriteUrl(c.icon)}" alt="" draggable="false"><div><strong>${c.title}</strong>` + + `<p class="change-kind ${c.kind}">${KIND_LABEL[c.kind]}, ${c.date}</p>${c.text.map((t) => `<p>${t}</p>`).join('')}</div>` + return c.clip + ? `<button type="button" class="almanac-entry has-clip" data-change-clip="${c.clip}" data-sprite="${c.icon}" data-name="${c.title}">${inner}</button>` + : `<div class="almanac-entry">${inner}</div>` +} + +function release(r: Release): string { + return ( + `<h3 class="release-head">${r.version} <small>${r.date}</small></h3><p>${r.summary}</p>` + + r.changes.map(change).join('') + ) +} + +/** The dialog: `releases` newest first; with `intro` it is the "what's new" greeting. */ +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(''), + buttons: [{ label: intro ? 'Got it' : 'Close', kind: 'plain' }], + }) + shown = { releases, intro } +} + +let shown = { releases: RELEASES, intro: false } + +/** A change's own dialog, its clip above what it does; Back returns to the list (from its top). */ +function openChange(button: HTMLElement): void { + const { changeClip, sprite, name } = button.dataset + const text = [...button.querySelectorAll('p')].map((p) => p.outerHTML).join('') + const back = shown + openDialog({ + icon: spriteUrl(sprite!), + title: name!, + body: '<canvas class="clip" aria-hidden="true"></canvas>' + text, + buttons: [{ label: 'Back', kind: 'plain', onClick: () => openChanges(back.releases, back.intro) }], + }) + startClip(document.querySelector<HTMLCanvasElement>('#dialog-body canvas.clip')!, changeClip as SceneKind) +} + +document.getElementById('dialog-body')!.addEventListener('click', (e) => { + const entry = (e.target as Element).closest<HTMLElement>('.almanac-entry[data-change-clip]') + if (entry) openChange(entry) +}) diff --git a/test/changelog.test.ts b/test/changelog.test.ts new file mode 100644 index 0000000..4fa82d1 --- /dev/null +++ b/test/changelog.test.ts @@ -0,0 +1,53 @@ +import { readFileSync } from 'node:fs' +import { describe, expect, it } from 'vitest' +import { compareVersions, newsFor, parseVersion, RELEASES, releasesSince, UNRELEASED, VERSION, type Release } from '../src/changelog' + +const release = (version: string): Release => ({ version, date: '2026-01-01', summary: '', changes: [] }) + +describe('changelog', () => { + it('the newest release is the version in package.json', () => { + expect(VERSION).toBe(JSON.parse(readFileSync('package.json', 'utf8')).version) + }) + + it('releases are valid semver, newest first, with no repeats', () => { + for (const r of RELEASES) expect(parseVersion(r.version), r.version).not.toBeNull() + for (let i = 1; i < RELEASES.length; i++) { + expect(compareVersions(RELEASES[i - 1]!.version, RELEASES[i]!.version)).toBeGreaterThan(0) + } + }) + + it('every change has a date, a title and text', () => { + for (const c of [...UNRELEASED, ...RELEASES.flatMap((r) => r.changes)]) { + expect(c.date).toMatch(/^\d{4}-\d{2}-\d{2}$/) + expect(c.title).not.toBe('') + expect(c.text.length).toBeGreaterThan(0) + } + }) + + it('compares numerically, not as text', () => { + expect(compareVersions('0.10.0', '0.9.0')).toBeGreaterThan(0) + expect(compareVersions('1.0.0', '0.99.99')).toBeGreaterThan(0) + expect(compareVersions('0.1.0', '0.1.0')).toBe(0) + }) + + it('lists the releases after the last one played', () => { + const all = [release('0.3.0'), release('0.2.0'), release('0.1.0')] + expect(releasesSince('0.1.0', all).map((r) => r.version)).toEqual(['0.3.0', '0.2.0']) + expect(releasesSince('0.3.0', all)).toEqual([]) + expect(releasesSince('0.4.0', all)).toEqual([]) + }) + + it('decides what a returning player sees', () => { + const all = [release('0.2.0'), release('0.1.0')] + expect(newsFor(null, all)).toEqual({ news: [], record: true }) + expect(newsFor('0.1.0', all)).toEqual({ news: [all[0]], record: false }) + expect(newsFor('0.2.0', all)).toEqual({ news: [], record: false }) + expect(newsFor('0.3.0', all)).toEqual({ news: [], record: false }) + // A stored value that isn't a version counts as older than any: everything is shown. + expect(newsFor('junk', all).news).toEqual(all) + }) + + it('the README states the current version', () => { + expect(readFileSync('README.md', 'utf8')).toContain(`Version: **${VERSION}**`) + }) +}) diff --git a/test/version.test.ts b/test/version.test.ts index 01d1a0a..59cbca2 100644 --- a/test/version.test.ts +++ b/test/version.test.ts @@ -3,9 +3,9 @@ import { describe, expect, it } from 'vitest' import { SIM_VERSION } from '../src/sim/record' describe('version', () => { - // The game's version is SIM_VERSION (shown in the menu); the README states it by hand. + // The rules version is SIM_VERSION (shown in the menu); the README states it by hand. it('the README states the current SIM_VERSION', () => { - const stated = readFileSync('README.md', 'utf8').match(/^Version: \*\*(\d+)\*\*/m)?.[1] + const stated = readFileSync('README.md', 'utf8').match(/^Rules version: \*\*(\d+)\*\*/m)?.[1] expect(Number(stated)).toBe(SIM_VERSION) }) })