explodorama / files / src / changelog.ts
changelog.ts
bb3823d2 2026-10-31 Pavel Safronov | 1 | import type { SceneKind } from './sim/demo.ts' |
5607bea2 2026-10-31 Pavel Safronov | 2 | |
| 3 | /** | |
| 4 | * What changed in each release, for the player (user request, 2026-10-31). Shown in the game as | |
| 5 | * "what's new since you last played" and, from the menu's version, as the full list. Not the | |
| 6 | * developer's CHANGELOG.md, which is longer and written for people who read the code. | |
| 7 | * | |
| 8 | * Versions are semver, git-tagged by hand when Pavel decides a batch is ready to release. 0.1.0 | |
| 9 | * is the base: nothing before it is listed. Between releases, finished changes collect in | |
| 10 | * UNRELEASED; when a release is tagged they move into a new entry at the top of RELEASES, and | |
| 11 | * `version` in package.json goes up to match (test/changelog.test.ts checks that). | |
| 12 | * | |
| 13 | * This is separate from SIM_VERSION (sim/record.ts), the rules version that decides whether a | |
| 14 | * saved run resumes. One release can contain several rules versions. | |
| 15 | */ | |
| 16 | export interface Change { | |
| 17 | kind: 'added' | 'changed' | 'removed' | |
| 18 | /** Sprite name, from `assets/kenney/`. */ | |
| 19 | icon: string | |
| 20 | title: string | |
| 21 | /** Paragraphs of plain text, as in the almanac. */ | |
| 22 | text: string[] | |
| 23 | /** The demo scene its entry plays as a looping clip (ui/clip.ts); none for a removal. */ | |
| 24 | clip?: SceneKind | |
| 25 | /** When it was added, as YYYY-MM-DD. */ | |
| 26 | date: string | |
| 27 | } | |
| 28 | ||
| 29 | export interface Release { | |
| 30 | /** Semver, x.y.z. */ | |
| 31 | version: string | |
| 32 | /** YYYY-MM-DD. */ | |
| 33 | date: string | |
| 34 | /** One line: what this release is. */ | |
| 35 | summary: string | |
| 36 | changes: Change[] | |
| 37 | } | |
| 38 | ||
| 39 | /** Newest first. */ | |
| 40 | export const RELEASES: Release[] = [ | |
805e7a9f 2026-11-03 Pavel Safronov | 41 | { |
| 42 | version: '0.2.0', | |
| 43 | date: '2026-11-02', | |
| 44 | summary: 'Play with a controller or just the keyboard, with demos for every kind of input; autopilot, oil, and many fixes.', | |
| 45 | changes: [ | |
| 46 | { | |
| 47 | kind: 'added', | |
| 48 | icon: 'star', | |
| 49 | title: 'Play with a controller', | |
| 50 | text: [ | |
| 51 | 'A gamepad now plays a whole run: the board, the menu, power-up choices, the shop, the almanac and the screens after a death or a level. A square rests on your tile; the d-pad hops it between the tiles you can act on, and the face buttons act there. Hold a button until its ring fills to confirm. Every button on screen shows its controller picture, and "What is that?" works with R1.', | |
| 52 | 'The demo has controller scenes with a virtual controller instead of a finger, and its own buttons work with the d-pad and R1.', | |
| 53 | ], | |
| 54 | date: '2026-11-02', | |
| 55 | }, | |
| 56 | { | |
| 57 | kind: 'added', | |
| 58 | icon: 'star', | |
| 59 | title: 'Play with just the keyboard', | |
| 60 | text: [ | |
| 61 | 'Hold Shift and press an arrow (or W A S D) to show a cursor on the board, hop it between the blue tiles, and act with M, B, V or Enter. Press ? to ask what something is. Alt and arrows move the view, and Home brings it back to you.', | |
| 62 | 'With a controller or the keyboard, a faded blue marks every far tile auto-move can walk to, with the way shown before you confirm; pointing at one with a mouse shows it too.', | |
| 63 | ], | |
| 64 | date: '2026-11-02', | |
| 65 | }, | |
| 66 | { | |
| 67 | kind: 'added', | |
| 68 | icon: 'star', | |
| 69 | title: 'Demos for every kind of input', | |
| 70 | text: ['A button under the demo switches what it is shown for: touch, mouse, keyboard, or a PlayStation, Xbox, Switch or generic controller. A shuffle button plays the scenes in a random order, every scene has a bar showing how much is left, and only a click, tap, Enter or Esc (or holding the confirm button on a controller) ends the demo.'], | |
| 71 | date: '2026-11-02', | |
| 72 | }, | |
| 73 | { | |
| 74 | kind: 'added', | |
| 75 | icon: 'coin_gold', | |
| 76 | title: 'Autopilot', | |
| 77 | text: [ | |
| 78 | 'When the last enemy is gone, the character walks on its own to every coin, charge and gem still lying about, then to the exit, along a safe way. Gems and charges still ask what you want, and every move is a real turn, so gathering can lose the par bonus.', | |
| 79 | 'Stop it with Esc, L1 on a controller, or the Stop button on the note at the top. To do it all yourself, turn off "Autopilot" under Settings in the menu.', | |
| 80 | ], | |
| 81 | date: '2026-11-02', | |
| 82 | }, | |
| 83 | { | |
| 84 | kind: 'added', | |
| 85 | icon: 'star', | |
| 86 | title: 'A fullscreen button', | |
| 87 | text: ['The game never goes fullscreen by itself. Use the new button next to the menu button, or F11; Esc or F11 leaves it.'], | |
| 88 | date: '2026-11-02', | |
| 89 | }, | |
| 90 | { | |
| 91 | kind: 'changed', | |
| 92 | icon: 'star', | |
| 93 | title: 'Smoother play', | |
| 94 | text: [ | |
| 95 | 'Turns now count from 1. A round Cancel button puts away a picked action or a walk you set. The view stays where you put it when you drag the board (reported by Pavel). A gem or charge choice whose window got lost opens again. The screens between levels and after a death are panels like the menu.', | |
| 96 | ], | |
| 97 | date: '2026-11-02', | |
| 98 | }, | |
| 99 | { | |
| 100 | kind: 'added', | |
| 101 | icon: 'bomb_active', | |
| 102 | title: 'Try the next version early', | |
| 103 | text: ['The menu opens with a box saying whether you are playing the official or the early adopter version, with a button to switch. The early copy is built from the newest work, with its own saves and scores; runs finished there are marked "Early adopter".'], | |
| 104 | date: '2026-11-01', | |
| 105 | }, | |
| 106 | { | |
| 107 | kind: 'changed', | |
| 108 | icon: 'flag_green_a', | |
| 109 | title: 'Wall Pass is now Crate Pass', | |
| 110 | text: ['The power-up lets you walk through crates, not walls, so it has a name that says so. It works exactly as before.'], | |
| 111 | clip: 'wallPass', | |
| 112 | date: '2026-11-01', | |
| 113 | }, | |
| 114 | { | |
| 115 | kind: 'added', | |
| 116 | icon: 'bomb', | |
| 117 | title: 'A note when you pick up a charge', | |
| 118 | text: ['The first charge of each kind in a run still explains itself. After that, picking one up shows a small note with its name, how many you got and how many you hold.'], | |
| 119 | date: '2026-11-01', | |
| 120 | }, | |
| 121 | { | |
| 122 | kind: 'changed', | |
| 123 | icon: 'gem_green', | |
| 124 | title: 'Two gems in one move', | |
| 125 | text: ['A slide, a run or an oil slide that takes two gems now asks for both choices, one after the other. The second used to be lost.'], | |
| 126 | date: '2026-10-31', | |
| 127 | }, | |
| 128 | { | |
| 129 | kind: 'changed', | |
| 130 | icon: 'oil', | |
| 131 | title: 'Oil is slippery', | |
| 132 | text: [ | |
| 133 | 'Step onto oil and you slide straight on over the spill, stopping on its last tile in that line (or short of a bomb or an enemy). You pick up what lies on the way, and flame where you end up burns you.', | |
| 134 | 'Blobs, Seekers, Drifters, Sprinters and Hunters slip too, and their arrows show where the slide ends. Ghosts and Wisps fly over it.', | |
| 135 | ], | |
| 136 | clip: 'oilSlide', | |
| 137 | date: '2026-10-31', | |
| 138 | }, | |
| 139 | { | |
| 140 | kind: 'changed', | |
| 141 | icon: 'bomb', | |
| 142 | title: 'Kick or walk: you choose', | |
| 143 | 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.'], | |
| 144 | date: '2026-10-31', | |
| 145 | }, | |
| 146 | { | |
| 147 | kind: 'changed', | |
| 148 | icon: 'block_metal', | |
| 149 | title: 'A tap on a crate walks to it', | |
| 150 | text: ['Tapping a crate (even from two tiles away, with Run) now walks you to the tile beside it.'], | |
| 151 | date: '2026-10-31', | |
| 152 | }, | |
| 153 | { | |
| 154 | kind: 'changed', | |
| 155 | icon: 'charge_freeze', | |
| 156 | title: 'Use a Freeze before swapping charges', | |
| 157 | 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.'], | |
| 158 | date: '2026-10-31', | |
| 159 | }, | |
| 160 | { | |
| 161 | kind: 'added', | |
| 162 | icon: 'rock', | |
| 163 | title: 'A demo of rubble forming', | |
| 164 | text: ['The Rubble entry in the Almanac now shows a cracked pillar being broken and the ceiling coming down.'], | |
| 165 | clip: 'rubble', | |
| 166 | date: '2026-10-31', | |
| 167 | }, | |
| 168 | ], | |
| 169 | }, | |
5607bea2 2026-10-31 Pavel Safronov | 170 | { |
| 171 | version: '0.1.0', | |
| 172 | date: '2026-10-31', | |
| 173 | summary: 'The first release, and the base later ones are compared with. The Almanac in the menu describes everything in it.', | |
| 174 | changes: [], | |
| 175 | }, | |
| 176 | ] | |
| 177 | ||
| 178 | /** Finished since the latest release, not yet part of one. Moved into RELEASES when one is tagged. */ | |
351348d1 2026-11-04 Pavel Safronov | 179 | export const UNRELEASED: Change[] = [ |
| 180 | { | |
| 181 | kind: 'changed', | |
| 182 | icon: 'bomb', | |
| 183 | title: 'The autopilot keeps out of bomb blasts', | |
| 184 | text: ['With Bomb Pass, the autopilot could walk through your own ticking bomb to fetch something and be caught in the blast. It now waits for bombs to go off, and walks out of a blast zone first.'], | |
| 185 | date: '2026-11-03', | |
| 186 | }, | |
| 187 | ] | |
5607bea2 2026-10-31 Pavel Safronov | 188 | |
| 189 | /** The version being played. */ | |
| 190 | export const VERSION = RELEASES[0]!.version | |
| 191 | ||
| 192 | /** Parse "x.y.z" into numbers; null if it isn't that. */ | |
| 193 | export function parseVersion(v: string): [number, number, number] | null { | |
| 194 | const m = /^(\d+)\.(\d+)\.(\d+)$/.exec(v) | |
| 195 | return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null | |
| 196 | } | |
| 197 | ||
| 198 | /** Negative if a < b, 0 if equal, positive if a > b. An unparseable version counts as older than any. */ | |
| 199 | export function compareVersions(a: string, b: string): number { | |
| 200 | const pa = parseVersion(a) | |
| 201 | const pb = parseVersion(b) | |
| 202 | if (!pa || !pb) return pa ? 1 : pb ? -1 : 0 | |
| 203 | for (let i = 0; i < 3; i++) { | |
| 204 | const d = pa[i]! - pb[i]! | |
| 205 | if (d) return d | |
| 206 | } | |
| 207 | return 0 | |
| 208 | } | |
| 209 | ||
| 210 | /** The releases newer than `last`, newest first; none if `last` is current (or newer, after a rollback). */ | |
| 211 | export function releasesSince(last: string, releases: Release[] = RELEASES): Release[] { | |
| 212 | return releases.filter((r) => compareVersions(r.version, last) > 0) | |
| 213 | } | |
| 214 | ||
| 215 | /** | |
| 216 | * What to show a player who last played `last` (null: never, or a value that wasn't kept), and | |
| 217 | * whether to record the current version now. A first visit has nothing to compare with: nothing | |
| 218 | * to show, record it. Newer releases: show them (recording waits until they are shown). Current: | |
| 219 | * nothing. Newer than the game (a rollback): nothing, and the newer record stays. | |
| 220 | */ | |
| 221 | export function newsFor(last: string | null, releases: Release[] = RELEASES): { news: Release[]; record: boolean } { | |
| 222 | if (last === null) return { news: [], record: true } | |
| 223 | const news = releasesSince(last, releases) | |
| 224 | return { news, record: !news.length && compareVersions(last, releases[0]!.version) < 0 } | |
| 225 | } |