fuzzyworld

explodorama / files / src / ui / whatsnew.ts

whatsnew.ts

Blame: the commit that last changed each line · file · history

b89399da 2026-11-01 Pavel Safronov1import { CHANNEL, earlyRuns, HOME_URL, NEXT_PATH, NEXT_URL, storageKey } from '../channel'
bb3823d2 2026-10-31 Pavel Safronov2import { newsFor, RELEASES, UNRELEASED, VERSION, type Change, type Release } from '../changelog'
5607bea2 2026-10-31 Pavel Safronov3import { spriteUrl } from '../render/sprites'
4import type { SceneKind } from '../sim/demo'
5import { startClip } from './clip'
6import { 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 */
bb3823d2 2026-10-31 Pavel Safronov13const LAST_KEY = storageKey('lastVersion')
5607bea2 2026-10-31 Pavel Safronov14
15function lastPlayed(): string | null {
16 try {
17 return localStorage.getItem(LAST_KEY)
18 } catch {
19 return null
20 }
21}
22
23function 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. */
32export function newSinceLastVisit(): Release[] {
33 const { news, record } = newsFor(lastPlayed())
34 if (record) markPlayed()
35 return news
36}
37
bb3823d2 2026-10-31 Pavel Safronov38/** What the next copy says about itself (vite.config.ts writes next.json into that build). */
39interface NextInfo {
40 version: string
41 commit: string
42 unreleased: Change[]
43}
44let 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 */
52export 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
b89399da 2026-11-01 Pavel Safronov62/**
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 */
67export 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
bb3823d2 2026-10-31 Pavel Safronov72 return (
b89399da 2026-11-01 Pavel Safronov73 `<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>`
bb3823d2 2026-10-31 Pavel Safronov80 )
81}
82
b89399da 2026-11-01 Pavel Safronov83/** The menu's small line about the early adopter runs finished on the next version, from either copy. */
84export function channelLine(): string {
85 const early = earlyRuns()
86 return early ? `Early adopter: ${early} ${early === 1 ? 'run' : 'runs'} finished on the next version` : ''
87}
88
bb3823d2 2026-10-31 Pavel Safronov89/** What is finished and not yet released: the next copy's own list, or from the released copy the next one's teaser. */
90function 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
5607bea2 2026-10-31 Pavel Safronov97const KIND_LABEL: Record<Change['kind'], string> = { added: 'Added', changed: 'Changed', removed: 'Removed' }
98
99function 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
108function 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. */
116export function openChanges(releases: Release[] = RELEASES, intro = false): void {
117 if (intro) markPlayed()
118 openDialog({
119 title: intro ? `What's new in ${VERSION}` : 'Changes',
bb3823d2 2026-10-31 Pavel Safronov120 body: (intro ? '<p class="almanac-intro">Since you last played:</p>' : unreleased()) + releases.map(release).join(''),
5607bea2 2026-10-31 Pavel Safronov121 buttons: [{ label: intro ? 'Got it' : 'Close', kind: 'plain' }],
122 })
123 shown = { releases, intro }
124}
125
126let shown = { releases: RELEASES, intro: false }
127
e9c64495 2026-11-01 Pavel Safronov128/** 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). */
5607bea2 2026-10-31 Pavel Safronov129function 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
e9c64495 2026-11-01 Pavel Safronov133 const body = document.getElementById('dialog-body')!
134 const scrolled = body.scrollTop
5607bea2 2026-10-31 Pavel Safronov135 openDialog({
136 icon: spriteUrl(sprite!),
137 title: name!,
138 body: '<canvas class="clip" aria-hidden="true"></canvas>' + text,
e9c64495 2026-11-01 Pavel Safronov139 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 ],
5607bea2 2026-10-31 Pavel Safronov151 })
152 startClip(document.querySelector<HTMLCanvasElement>('#dialog-body canvas.clip')!, changeClip as SceneKind)
153}
154
155document.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})