explodorama / files / src / ui / almanac.ts
almanac.ts
| 1 | import { ENEMY_ORDER, enemyKinds } from '../sim/levelgen' |
| 2 | import { OFFER_SIZE } from '../sim/turn' |
| 3 | import { CHARGE_IDS, type EnemyKind, type PowerUpKind } from '../sim/types' |
| 4 | import { CHARGE_ICONS, ENEMY_SPRITES, POWER_UP_ICONS, spriteUrl } from '../render/sprites' |
| 5 | import type { SceneKind } from '../sim/demo' |
| 6 | import { openDialog } from './dialog' |
| 7 | import { startClip } from './clip' |
| 8 | import { TEST_KINDS, TEST_SCENES } from '../sim/testScenes' |
| 9 | import { chargeToast, showToast } from './toast' |
| 10 | import { createPadOverlay, type PadOverlay, type PadPart } from './padOverlay' |
| 11 | import type { PadFamily } from './padMap' |
| 12 | import { BOARD_TEXT, CHARGE_TEXT, ENEMY_TEXT, POWER_UP_CLIP, POWER_UP_TEXT } from './info' |
| 13 | |
| 14 | /** |
| 15 | * The almanac (user request, 2026-10-22): every power-up, action charge and enemy type, with its |
| 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). |
| 18 | */ |
| 19 | export type AlmanacTab = 'powerUps' | 'charges' | 'enemies' | 'board' | 'tests' |
| 20 | |
| 21 | const TABS: [AlmanacTab, string][] = [ |
| 22 | ['powerUps', 'Power-ups'], |
| 23 | ['charges', 'Charges'], |
| 24 | ['enemies', 'Enemies'], |
| 25 | ['board', 'Board'], |
| 26 | ['tests', 'Tests'], |
| 27 | ] |
| 28 | |
| 29 | /** The power-ups in the order they make sense to read: stats, abilities, passes, the rest. */ |
| 30 | const POWER_UPS: PowerUpKind[] = [ |
| 31 | 'bombPlus', 'firePlus', 'tnt', 'fusePlus', 'run', 'kick', 'throw', 'diagonal', 'slide', 'rpg', 'detonator', 'proximity', |
| 32 | 'wallPass', 'bombPass', 'star', 'heart', 'coinBag', |
| 33 | ] |
| 34 | |
| 35 | const ENEMIES = ENEMY_ORDER |
| 36 | |
| 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 | |
| 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 | |
| 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 | |
| 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>` |
| 79 | } |
| 80 | |
| 81 | function body(tab: AlmanacTab): string { |
| 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('') |
| 84 | let list: string |
| 85 | if (tab === 'powerUps') { |
| 86 | list = |
| 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>` + |
| 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('') |
| 89 | } else if (tab === 'charges') { |
| 90 | list = |
| 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>' + |
| 92 | CHARGE_IDS.map((id) => entry(CHARGE_ICONS[id], CHARGE_TEXT[id].name, [CHARGE_TEXT[id].text], id)).join('') |
| 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('') |
| 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>' + |
| 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>' + |
| 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>` |
| 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 | |
| 114 | let shownTab: AlmanacTab = 'powerUps' |
| 115 | /** Whether the run is a Developer one (it shows the Tests tab); kept for Back from an entry. */ |
| 116 | let shownDev = false |
| 117 | |
| 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' }] }) |
| 122 | } |
| 123 | |
| 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 { |
| 126 | // Back returns to where the list was scrolled to (user request, 2026-11-01). |
| 127 | const scrolled = dialogBody.scrollTop |
| 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, |
| 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 | ], |
| 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. |
| 151 | const dialogBody = document.getElementById('dialog-body')! |
| 152 | dialogBody.addEventListener('click', (e) => { |
| 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 | } |
| 161 | const padButton = target.closest<HTMLElement>('[data-pad-sample]') |
| 162 | if (padButton) return padSample(padButton) |
| 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 | } |
| 170 | const entryButton = target.closest<HTMLElement>('.almanac-entry[data-clip]') |
| 171 | if (entryButton) openEntry(entryButton) |
| 172 | }) |