fuzzyworld

explodorama / files / src / ui / almanac.ts

almanac.ts

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

565a6873 2026-10-25 Pavel Safronov1import { ENEMY_ORDER, enemyKinds } from '../sim/levelgen'
52d68b59 2026-10-24 Pavel Safronov2import { OFFER_SIZE } from '../sim/turn'
99869630 2026-10-23 Pavel Safronov3import { CHARGE_IDS, type EnemyKind, type PowerUpKind } from '../sim/types'
4import { CHARGE_ICONS, ENEMY_SPRITES, POWER_UP_ICONS, spriteUrl } from '../render/sprites'
32dd53cd 2026-10-25 Pavel Safronov5import type { SceneKind } from '../sim/demo'
99869630 2026-10-23 Pavel Safronov6import { openDialog } from './dialog'
32dd53cd 2026-10-25 Pavel Safronov7import { startClip } from './clip'
8edb5cb9 2026-11-01 Pavel Safronov8import { TEST_KINDS, TEST_SCENES } from '../sim/testScenes'
b89399da 2026-11-01 Pavel Safronov9import { chargeToast, showToast } from './toast'
92cc4ee3 2026-11-02 Pavel Safronov10import { createPadOverlay, type PadOverlay, type PadPart } from './padOverlay'
11import type { PadFamily } from './padMap'
6c3fcc31 2026-10-26 Pavel Safronov12import { BOARD_TEXT, CHARGE_TEXT, ENEMY_TEXT, POWER_UP_CLIP, POWER_UP_TEXT } from './info'
99869630 2026-10-23 Pavel Safronov13
14/**
15 * The almanac (user request, 2026-10-22): every power-up, action charge and enemy type, with its
6c3fcc31 2026-10-26 Pavel Safronov16 * picture and what it does, opened from the menu. Its tabs swap the list in place. Since 2026-10-25
17 * a Board tab too: crates, pillars and the board's objects (PLAN.md §19.3).
99869630 2026-10-23 Pavel Safronov18 */
8edb5cb9 2026-11-01 Pavel Safronov19export type AlmanacTab = 'powerUps' | 'charges' | 'enemies' | 'board' | 'tests'
99869630 2026-10-23 Pavel Safronov20
21const TABS: [AlmanacTab, string][] = [
22 ['powerUps', 'Power-ups'],
23 ['charges', 'Charges'],
24 ['enemies', 'Enemies'],
6c3fcc31 2026-10-26 Pavel Safronov25 ['board', 'Board'],
8edb5cb9 2026-11-01 Pavel Safronov26 ['tests', 'Tests'],
99869630 2026-10-23 Pavel Safronov27]
28
29/** The power-ups in the order they make sense to read: stats, abilities, passes, the rest. */
30const POWER_UPS: PowerUpKind[] = [
fcf44f32 2026-10-25 Pavel Safronov31 'bombPlus', 'firePlus', 'tnt', 'fusePlus', 'run', 'kick', 'throw', 'diagonal', 'slide', 'rpg', 'detonator', 'proximity',
32 'wallPass', 'bombPass', 'star', 'heart', 'coinBag',
99869630 2026-10-23 Pavel Safronov33]
34
565a6873 2026-10-25 Pavel Safronov35const ENEMIES = ENEMY_ORDER
99869630 2026-10-23 Pavel Safronov36
b89399da 2026-11-01 Pavel Safronov37/** The Tests tab's samples of the toast: tapping one shows it, as in play (add one for each new kind of toast). */
38const TOAST_SAMPLES = [
39 { 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 },
40 { kind: 'shield' as const, name: 'Toast: a single charge', about: 'One Shield picked up, none held: just its name.', n: 1, total: 1 },
41]
42
92cc4ee3 2026-11-02 Pavel Safronov43/** The Tests tab's sample of the demo's virtual controller (padOverlay.ts): each tap presses the next part, each family in turn. */
44const PAD_SAMPLE_FAMILIES: [PadFamily, string][] = [['xbox', 'Xbox'], ['playstation', 'PlayStation'], ['nintendo', 'Switch']]
45const PAD_SAMPLE_PARTS: PadPart[] = ['up', 'right', 'down', 'left', 'move', 'bomb', 'charge', 'wait', 'l1', 'r1', 'stick', 'start']
46const padSamples = new WeakMap<HTMLElement, { overlay: PadOverlay; presses: number }>()
47
48function padSample(button: HTMLElement): void {
49 let sample = padSamples.get(button)
50 if (!sample) {
51 const overlay = createPadOverlay('compact')
52 button.querySelector('div')!.append(overlay.el)
53 padSamples.set(button, (sample = { overlay, presses: 0 }))
54 }
55 const [family, name] = PAD_SAMPLE_FAMILIES[Math.floor(sample.presses / PAD_SAMPLE_PARTS.length) % PAD_SAMPLE_FAMILIES.length]!
56 const part = PAD_SAMPLE_PARTS[sample.presses % PAD_SAMPLE_PARTS.length]!
57 sample.presses++
58 sample.overlay.setFamily(family)
59 sample.overlay.press(part, 0.8, part === 'stick' ? 'left' : undefined)
60 button.querySelector('.pad-sample-note')!.textContent = `${name}: ${part}`
61}
62
99869630 2026-10-23 Pavel Safronov63/** The first level an enemy type can appear on. */
64function firstLevel(kind: EnemyKind): number {
65 let level = 1
66 while (!enemyKinds(level).includes(kind)) level++
67 return level
68}
69
32dd53cd 2026-10-25 Pavel Safronov70/**
71 * An entry; one with a clip (PLAN.md §13c) is a button that opens it in its own dialog (user's
72 * choice, 2026-10-24), with a small ▶ to say so. Enemies, Heart and Coin bag have none.
73 */
74function entry(sprite: string, name: string, lines: string[], clip?: SceneKind): string {
75 const inner = `<img src="${spriteUrl(sprite)}" alt="" draggable="false"><div><strong>${name}</strong>${lines.map((l) => `<p>${l}</p>`).join('')}</div>`
76 return clip
77 ? `<button type="button" class="almanac-entry has-clip" data-clip="${clip}" data-sprite="${sprite}" data-name="${name}">${inner}</button>`
78 : `<div class="almanac-entry">${inner}</div>`
99869630 2026-10-23 Pavel Safronov79}
80
81function body(tab: AlmanacTab): string {
8edb5cb9 2026-11-01 Pavel Safronov82 // The Tests tab is for a Developer run (user request, 2026-10-31).
83 const tabs = TABS.filter(([id]) => id !== 'tests' || shownDev).map(([id, label]) => `<button type="button" data-tab="${id}" aria-pressed="${id === tab}">${label}</button>`).join('')
99869630 2026-10-23 Pavel Safronov84 let list: string
85 if (tab === 'powerUps') {
86 list =
52d68b59 2026-10-24 Pavel Safronov87 `<p class="almanac-intro">Every level hides a gem under a crate. Burn the crate and walk onto the gem to choose one of ${OFFER_SIZE} of these.</p>` +
32dd53cd 2026-10-25 Pavel Safronov88 POWER_UPS.map((k) => entry(POWER_UP_ICONS[k], POWER_UP_TEXT[k].name, [POWER_UP_TEXT[k].text], POWER_UP_CLIP[k])).join('')
99869630 2026-10-23 Pavel Safronov89 } else if (tab === 'charges') {
90 list =
d3e4ed26 2026-10-25 Pavel Safronov91 '<p class="almanac-intro">Enemies sometimes drop these. You carry one at a time and use it with the charge button, beside Bomb; a Shield works by itself.</p>' +
32dd53cd 2026-10-25 Pavel Safronov92 CHARGE_IDS.map((id) => entry(CHARGE_ICONS[id], CHARGE_TEXT[id].name, [CHARGE_TEXT[id].text], id)).join('')
6c3fcc31 2026-10-26 Pavel Safronov93 } else if (tab === 'board') {
94 list =
95 '<p class="almanac-intro">What you find on the board. New things turn up a few levels apart, so each is met on its own first. Tap one on the board to read about it there too.</p>' +
96 Object.values(BOARD_TEXT)
97 .map((t) => entry(t.icon, t.name, t.from ? [...t.text, `From level ${t.from}.`] : t.text, t.clip))
98 .join('')
8edb5cb9 2026-11-01 Pavel Safronov99 } else if (tab === 'tests') {
100 list =
101 '<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>' +
b89399da 2026-11-01 Pavel Safronov102 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('') +
103 '<h3 class="release-head">Interface pieces <small>tap to show</small></h3>' +
92cc4ee3 2026-11-02 Pavel Safronov104 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('') +
105 `<button type="button" class="almanac-entry" data-pad-sample><img src="${spriteUrl('xbox_button_color_a')}" alt="" draggable="false"><div><strong>Demo controller</strong><p>The pad the gamepad demo and clips show. Each tap presses the next part, on Xbox, then PlayStation, then Switch.</p><p class="pad-sample-note">Tap to start.</p></div></button>`
99869630 2026-10-23 Pavel Safronov106 } else {
107 list =
108 '<p class="almanac-intro">Every enemy shows its next 3 moves as arrows. Flame kills them; touching one costs you a life.</p>' +
109 ENEMIES.map((k) => entry(ENEMY_SPRITES[k].a, ENEMY_TEXT[k].name, [ENEMY_TEXT[k].moves, `From level ${firstLevel(k)}.`])).join('')
110 }
111 return `<div class="almanac-tabs" role="group" aria-label="Almanac sections">${tabs}</div>${list}`
112}
113
32dd53cd 2026-10-25 Pavel Safronov114let shownTab: AlmanacTab = 'powerUps'
8edb5cb9 2026-11-01 Pavel Safronov115/** Whether the run is a Developer one (it shows the Tests tab); kept for Back from an entry. */
116let shownDev = false
32dd53cd 2026-10-25 Pavel Safronov117
8edb5cb9 2026-11-01 Pavel Safronov118export function openAlmanac(tab: AlmanacTab = shownTab, dev: boolean = shownDev): void {
119 shownDev = dev
120 shownTab = tab === 'tests' && !dev ? 'powerUps' : tab
121 openDialog({ title: 'Almanac', body: body(shownTab), buttons: [{ label: 'Close', kind: 'plain' }] })
99869630 2026-10-23 Pavel Safronov122}
123
32dd53cd 2026-10-25 Pavel Safronov124/** An entry's own dialog: its clip above what it does, and Back to the almanac where it was. */
125function openEntry(button: HTMLElement): void {
b89399da 2026-11-01 Pavel Safronov126 // Back returns to where the list was scrolled to (user request, 2026-11-01).
127 const scrolled = dialogBody.scrollTop
32dd53cd 2026-10-25 Pavel Safronov128 const { clip, sprite, name } = button.dataset
129 const text = [...button.querySelectorAll('p')].map((p) => p.outerHTML).join('')
130 openDialog({
131 icon: spriteUrl(sprite!),
132 title: name!,
133 body: '<canvas class="clip" aria-hidden="true"></canvas>' + text,
b89399da 2026-11-01 Pavel Safronov134 buttons: [
135 {
136 label: 'Back',
137 kind: 'plain',
138 onClick: () => {
139 openAlmanac()
140 dialogBody.scrollTop = scrolled
141 // Once laid out, if the dialog wasn't yet.
142 requestAnimationFrame(() => (dialogBody.scrollTop = scrolled))
143 },
144 },
145 ],
32dd53cd 2026-10-25 Pavel Safronov146 })
147 startClip(document.querySelector<HTMLCanvasElement>('#dialog-body canvas.clip')!, clip as SceneKind)
148}
149
150// A tab swaps the list without closing the dialog; an entry with a clip opens it.
99869630 2026-10-23 Pavel Safronov151const dialogBody = document.getElementById('dialog-body')!
152dialogBody.addEventListener('click', (e) => {
32dd53cd 2026-10-25 Pavel Safronov153 const target = e.target as Element
154 const tab = target.closest<HTMLElement>('[data-tab]')
155 if (tab) {
156 shownTab = tab.dataset.tab as AlmanacTab
157 dialogBody.innerHTML = body(shownTab)
158 dialogBody.scrollTop = 0
159 return
160 }
92cc4ee3 2026-11-02 Pavel Safronov161 const padButton = target.closest<HTMLElement>('[data-pad-sample]')
162 if (padButton) return padSample(padButton)
b89399da 2026-11-01 Pavel Safronov163 const toast = target.closest<HTMLElement>('[data-toast]')
164 if (toast) {
165 const t = TOAST_SAMPLES[Number(toast.dataset.toast)]!
166 const { title, text } = chargeToast(CHARGE_TEXT[t.kind].name, t.n, t.total)
167 showToast(spriteUrl(CHARGE_ICONS[t.kind]), title, text)
168 return
169 }
32dd53cd 2026-10-25 Pavel Safronov170 const entryButton = target.closest<HTMLElement>('.almanac-entry[data-clip]')
171 if (entryButton) openEntry(entryButton)
99869630 2026-10-23 Pavel Safronov172})