explodorama / files / src / ui / almanac.ts
almanac.ts
565a6873 2026-10-25 Pavel Safronov | 1 | import { ENEMY_ORDER, enemyKinds } from '../sim/levelgen' |
52d68b59 2026-10-24 Pavel Safronov | 2 | import { OFFER_SIZE } from '../sim/turn' |
99869630 2026-10-23 Pavel Safronov | 3 | import { CHARGE_IDS, type EnemyKind, type PowerUpKind } from '../sim/types' |
| 4 | import { CHARGE_ICONS, ENEMY_SPRITES, POWER_UP_ICONS, spriteUrl } from '../render/sprites' | |
32dd53cd 2026-10-25 Pavel Safronov | 5 | import type { SceneKind } from '../sim/demo' |
99869630 2026-10-23 Pavel Safronov | 6 | import { openDialog } from './dialog' |
32dd53cd 2026-10-25 Pavel Safronov | 7 | import { startClip } from './clip' |
8edb5cb9 2026-11-01 Pavel Safronov | 8 | import { TEST_KINDS, TEST_SCENES } from '../sim/testScenes' |
b89399da 2026-11-01 Pavel Safronov | 9 | import { chargeToast, showToast } from './toast' |
92cc4ee3 2026-11-02 Pavel Safronov | 10 | import { createPadOverlay, type PadOverlay, type PadPart } from './padOverlay' |
| 11 | import type { PadFamily } from './padMap' | |
6c3fcc31 2026-10-26 Pavel Safronov | 12 | import { BOARD_TEXT, CHARGE_TEXT, ENEMY_TEXT, POWER_UP_CLIP, POWER_UP_TEXT } from './info' |
99869630 2026-10-23 Pavel Safronov | 13 | |
| 14 | /** | |
| 15 | * The almanac (user request, 2026-10-22): every power-up, action charge and enemy type, with its | |
6c3fcc31 2026-10-26 Pavel Safronov | 16 | * 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 Safronov | 18 | */ |
8edb5cb9 2026-11-01 Pavel Safronov | 19 | export type AlmanacTab = 'powerUps' | 'charges' | 'enemies' | 'board' | 'tests' |
99869630 2026-10-23 Pavel Safronov | 20 | |
| 21 | const TABS: [AlmanacTab, string][] = [ | |
| 22 | ['powerUps', 'Power-ups'], | |
| 23 | ['charges', 'Charges'], | |
| 24 | ['enemies', 'Enemies'], | |
6c3fcc31 2026-10-26 Pavel Safronov | 25 | ['board', 'Board'], |
8edb5cb9 2026-11-01 Pavel Safronov | 26 | ['tests', 'Tests'], |
99869630 2026-10-23 Pavel Safronov | 27 | ] |
| 28 | ||
| 29 | /** The power-ups in the order they make sense to read: stats, abilities, passes, the rest. */ | |
| 30 | const POWER_UPS: PowerUpKind[] = [ | |
fcf44f32 2026-10-25 Pavel Safronov | 31 | 'bombPlus', 'firePlus', 'tnt', 'fusePlus', 'run', 'kick', 'throw', 'diagonal', 'slide', 'rpg', 'detonator', 'proximity', |
| 32 | 'wallPass', 'bombPass', 'star', 'heart', 'coinBag', | |
99869630 2026-10-23 Pavel Safronov | 33 | ] |
| 34 | ||
565a6873 2026-10-25 Pavel Safronov | 35 | const ENEMIES = ENEMY_ORDER |
99869630 2026-10-23 Pavel Safronov | 36 | |
b89399da 2026-11-01 Pavel Safronov | 37 | /** The Tests tab's samples of the toast: tapping one shows it, as in play (add one for each new kind of toast). */ |
| 38 | const 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 Safronov | 43 | /** The Tests tab's sample of the demo's virtual controller (padOverlay.ts): each tap presses the next part, each family in turn. */ |
| 44 | const PAD_SAMPLE_FAMILIES: [PadFamily, string][] = [['xbox', 'Xbox'], ['playstation', 'PlayStation'], ['nintendo', 'Switch']] | |
| 45 | const PAD_SAMPLE_PARTS: PadPart[] = ['up', 'right', 'down', 'left', 'move', 'bomb', 'charge', 'wait', 'l1', 'r1', 'stick', 'start'] | |
| 46 | const padSamples = new WeakMap<HTMLElement, { overlay: PadOverlay; presses: number }>() | |
| 47 | ||
| 48 | function 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 Safronov | 63 | /** The first level an enemy type can appear on. */ |
| 64 | function 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 Safronov | 70 | /** |
| 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 | */ | |
| 74 | function 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 Safronov | 79 | } |
| 80 | ||
| 81 | function body(tab: AlmanacTab): string { | |
8edb5cb9 2026-11-01 Pavel Safronov | 82 | // 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 Safronov | 84 | let list: string |
| 85 | if (tab === 'powerUps') { | |
| 86 | list = | |
52d68b59 2026-10-24 Pavel Safronov | 87 | `<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 Safronov | 88 | 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 Safronov | 89 | } else if (tab === 'charges') { |
| 90 | list = | |
d3e4ed26 2026-10-25 Pavel Safronov | 91 | '<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 Safronov | 92 | CHARGE_IDS.map((id) => entry(CHARGE_ICONS[id], CHARGE_TEXT[id].name, [CHARGE_TEXT[id].text], id)).join('') |
6c3fcc31 2026-10-26 Pavel Safronov | 93 | } 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 Safronov | 99 | } 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 Safronov | 102 | 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 Safronov | 104 | 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 Safronov | 106 | } 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 Safronov | 114 | let shownTab: AlmanacTab = 'powerUps' |
8edb5cb9 2026-11-01 Pavel Safronov | 115 | /** Whether the run is a Developer one (it shows the Tests tab); kept for Back from an entry. */ |
| 116 | let shownDev = false | |
32dd53cd 2026-10-25 Pavel Safronov | 117 | |
8edb5cb9 2026-11-01 Pavel Safronov | 118 | export 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 Safronov | 122 | } |
| 123 | ||
32dd53cd 2026-10-25 Pavel Safronov | 124 | /** An entry's own dialog: its clip above what it does, and Back to the almanac where it was. */ |
| 125 | function openEntry(button: HTMLElement): void { | |
b89399da 2026-11-01 Pavel Safronov | 126 | // Back returns to where the list was scrolled to (user request, 2026-11-01). |
| 127 | const scrolled = dialogBody.scrollTop | |
32dd53cd 2026-10-25 Pavel Safronov | 128 | 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 Safronov | 134 | 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 Safronov | 146 | }) |
| 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 Safronov | 151 | const dialogBody = document.getElementById('dialog-body')! |
| 152 | dialogBody.addEventListener('click', (e) => { | |
32dd53cd 2026-10-25 Pavel Safronov | 153 | 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 Safronov | 161 | const padButton = target.closest<HTMLElement>('[data-pad-sample]') |
| 162 | if (padButton) return padSample(padButton) | |
b89399da 2026-11-01 Pavel Safronov | 163 | 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 Safronov | 170 | const entryButton = target.closest<HTMLElement>('.almanac-entry[data-clip]') |
| 171 | if (entryButton) openEntry(entryButton) | |
99869630 2026-10-23 Pavel Safronov | 172 | }) |