explodorama / files / src / ui / whatsnew.ts
whatsnew.ts
| 1 | import { CHANNEL, earlyRuns, HOME_URL, NEXT_PATH, NEXT_URL, storageKey } from '../channel' |
| 2 | import { newsFor, RELEASES, UNRELEASED, VERSION, type Change, type Release } from '../changelog' |
| 3 | import { spriteUrl } from '../render/sprites' |
| 4 | import type { SceneKind } from '../sim/demo' |
| 5 | import { startClip } from './clip' |
| 6 | import { openDialog } from './dialog' |
| 7 | |
| 8 | /** |
| 9 | * The changes dialog (user request, 2026-10-31). Opened on its own when the game opens on a |
| 10 | * newer version than the player last played, listing what came after it; or from the version in |
| 11 | * the menu, listing every release. The last version played is kept in this browser. |
| 12 | */ |
| 13 | const LAST_KEY = storageKey('lastVersion') |
| 14 | |
| 15 | function lastPlayed(): string | null { |
| 16 | try { |
| 17 | return localStorage.getItem(LAST_KEY) |
| 18 | } catch { |
| 19 | return null |
| 20 | } |
| 21 | } |
| 22 | |
| 23 | function markPlayed(): void { |
| 24 | try { |
| 25 | localStorage.setItem(LAST_KEY, VERSION) |
| 26 | } catch { |
| 27 | // No storage (a private window): the player sees the changes again next time. |
| 28 | } |
| 29 | } |
| 30 | |
| 31 | /** What to tell the player about when the game opens (newsFor, changelog.ts); records the version where that says to. */ |
| 32 | export function newSinceLastVisit(): Release[] { |
| 33 | const { news, record } = newsFor(lastPlayed()) |
| 34 | if (record) markPlayed() |
| 35 | return news |
| 36 | } |
| 37 | |
| 38 | /** What the next copy says about itself (vite.config.ts writes next.json into that build). */ |
| 39 | interface NextInfo { |
| 40 | version: string |
| 41 | commit: string |
| 42 | unreleased: Change[] |
| 43 | } |
| 44 | let nextInfo: NextInfo | null = null |
| 45 | |
| 46 | /** |
| 47 | * The released copy asks the next one what it has that isn't released yet (once, when the game |
| 48 | * opens), to tell the player: a teaser in the Changes dialog and a "Try it early" link in the |
| 49 | * menu. Silent if there is no next copy, or none reachable. Its entries' clips are dropped: the |
| 50 | * released game can't make scenes it doesn't know. |
| 51 | */ |
| 52 | export function loadNextInfo(): void { |
| 53 | if (CHANNEL !== 'stable') return |
| 54 | fetch(NEXT_PATH + 'next.json', { cache: 'no-store' }) |
| 55 | .then((r) => (r.ok ? r.json() : null)) |
| 56 | .then((json: NextInfo | null) => { |
| 57 | if (json && Array.isArray(json.unreleased)) nextInfo = { ...json, unreleased: json.unreleased.map((c) => ({ ...c, clip: undefined })) } |
| 58 | }) |
| 59 | .catch(() => {}) |
| 60 | } |
| 61 | |
| 62 | /** |
| 63 | * The menu's box at the top (user request, 2026-11-01): which version this is, and a button to the |
| 64 | * other one, with both addresses. The released copy only offers the early one once it has heard |
| 65 | * from it (`nextInfo`). Each copy keeps its own saved run and scores. |
| 66 | */ |
| 67 | export function channelBox(): string { |
| 68 | if (CHANNEL === 'local') return '' |
| 69 | const next = CHANNEL === 'next' |
| 70 | const there = next ? HOME_URL : NEXT_URL |
| 71 | const offer = next || nextInfo |
| 72 | return ( |
| 73 | `<div class="channel-box"><p><strong>${next ? 'Early adopter version' : 'Official version'}</strong>` + |
| 74 | `<span class="channel-sub">${next ? 'what is coming next; it may have rough edges' : 'the released game'}</span></p>` + |
| 75 | (offer |
| 76 | ? `<button type="button" class="channel-switch" data-go="${there}">Switch to the ${next ? 'official' : 'early adopter'} version</button>` + |
| 77 | `<p class="credit">${there.replace(/^https?:\/\//, '')} · each version keeps its own saved run and scores</p>` |
| 78 | : '') + |
| 79 | `</div>` |
| 80 | ) |
| 81 | } |
| 82 | |
| 83 | /** The menu's small line about the early adopter runs finished on the next version, from either copy. */ |
| 84 | export function channelLine(): string { |
| 85 | const early = earlyRuns() |
| 86 | return early ? `Early adopter: ${early} ${early === 1 ? 'run' : 'runs'} finished on the next version` : '' |
| 87 | } |
| 88 | |
| 89 | /** What is finished and not yet released: the next copy's own list, or from the released copy the next one's teaser. */ |
| 90 | function unreleased(): string { |
| 91 | const list = CHANNEL === 'stable' ? nextInfo?.unreleased ?? [] : UNRELEASED |
| 92 | if (!list.length) return '' |
| 93 | const link = CHANNEL === 'stable' ? `<p><a href="${NEXT_PATH}">Try it early</a>, in a copy of the game of its own.</p>` : '' |
| 94 | 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('')}` |
| 95 | } |
| 96 | |
| 97 | const KIND_LABEL: Record<Change['kind'], string> = { added: 'Added', changed: 'Changed', removed: 'Removed' } |
| 98 | |
| 99 | function change(c: Change): string { |
| 100 | const inner = |
| 101 | `<img src="${spriteUrl(c.icon)}" alt="" draggable="false"><div><strong>${c.title}</strong>` + |
| 102 | `<p class="change-kind ${c.kind}">${KIND_LABEL[c.kind]}, ${c.date}</p>${c.text.map((t) => `<p>${t}</p>`).join('')}</div>` |
| 103 | return c.clip |
| 104 | ? `<button type="button" class="almanac-entry has-clip" data-change-clip="${c.clip}" data-sprite="${c.icon}" data-name="${c.title}">${inner}</button>` |
| 105 | : `<div class="almanac-entry">${inner}</div>` |
| 106 | } |
| 107 | |
| 108 | function release(r: Release): string { |
| 109 | return ( |
| 110 | `<h3 class="release-head">${r.version} <small>${r.date}</small></h3><p>${r.summary}</p>` + |
| 111 | r.changes.map(change).join('') |
| 112 | ) |
| 113 | } |
| 114 | |
| 115 | /** The dialog: `releases` newest first; with `intro` it is the "what's new" greeting. */ |
| 116 | export function openChanges(releases: Release[] = RELEASES, intro = false): void { |
| 117 | if (intro) markPlayed() |
| 118 | openDialog({ |
| 119 | title: intro ? `What's new in ${VERSION}` : 'Changes', |
| 120 | body: (intro ? '<p class="almanac-intro">Since you last played:</p>' : unreleased()) + releases.map(release).join(''), |
| 121 | buttons: [{ label: intro ? 'Got it' : 'Close', kind: 'plain' }], |
| 122 | }) |
| 123 | shown = { releases, intro } |
| 124 | } |
| 125 | |
| 126 | let shown = { releases: RELEASES, intro: false } |
| 127 | |
| 128 | /** A change's own dialog, its clip above what it does; Back returns to the list, scrolled to where it was (user request, 2026-11-01). */ |
| 129 | function openChange(button: HTMLElement): void { |
| 130 | const { changeClip, sprite, name } = button.dataset |
| 131 | const text = [...button.querySelectorAll('p')].map((p) => p.outerHTML).join('') |
| 132 | const back = shown |
| 133 | const body = document.getElementById('dialog-body')! |
| 134 | const scrolled = body.scrollTop |
| 135 | openDialog({ |
| 136 | icon: spriteUrl(sprite!), |
| 137 | title: name!, |
| 138 | body: '<canvas class="clip" aria-hidden="true"></canvas>' + text, |
| 139 | buttons: [ |
| 140 | { |
| 141 | label: 'Back', |
| 142 | kind: 'plain', |
| 143 | onClick: () => { |
| 144 | openChanges(back.releases, back.intro) |
| 145 | body.scrollTop = scrolled |
| 146 | // Once laid out, if the dialog wasn't yet. |
| 147 | requestAnimationFrame(() => (body.scrollTop = scrolled)) |
| 148 | }, |
| 149 | }, |
| 150 | ], |
| 151 | }) |
| 152 | startClip(document.querySelector<HTMLCanvasElement>('#dialog-body canvas.clip')!, changeClip as SceneKind) |
| 153 | } |
| 154 | |
| 155 | document.getElementById('dialog-body')!.addEventListener('click', (e) => { |
| 156 | const entry = (e.target as Element).closest<HTMLElement>('.almanac-entry[data-change-clip]') |
| 157 | if (entry) openChange(entry) |
| 158 | }) |