explodorama / commit
Offer: select, then Choose; dialogs fit the screen; mode clips and stills
CHANGELOG.md | 1 + PLAN.md | 5 ++- README.md | 10 +++-- decisions.md | 42 +++++++++++++++++ docs/screenshots/almanac-charges.png | Bin 280855 -> 282941 bytes docs/screenshots/almanac-enemies.png | Bin 196295 -> 195175 bytes docs/screenshots/almanac.png | Bin 400082 -> 410063 bytes docs/screenshots/auto-move.png | Bin 217145 -> 217157 bytes docs/screenshots/bomb-picked.png | Bin 216434 -> 216471 bytes docs/screenshots/death.png | Bin 235149 -> 235128 bytes docs/screenshots/dev-offer.png | Bin 217879 -> 200791 bytes docs/screenshots/enemies.png | Bin 220100 -> 220425 bytes docs/screenshots/gem-offer.png | Bin 214176 -> 214178 bytes docs/screenshots/gem.png | Bin 214183 -> 214188 bytes docs/screenshots/info-hud.png | Bin 183703 -> 175377 bytes docs/screenshots/info-mode.png | Bin 215678 -> 215594 bytes docs/screenshots/lingering.png | Bin 219582 -> 219627 bytes docs/screenshots/menu.png | Bin 142192 -> 142122 bytes docs/screenshots/move-picked.png | Bin 216597 -> 216615 bytes docs/screenshots/nuke.png | Bin 240183 -> 240183 bytes src/main.ts | 1 + src/sim/demo.ts | 21 ++++++++- src/style.css | 85 +++++++++++++++++------------------ src/ui/clip.ts | 13 ++++++ src/ui/info.ts | 2 +- src/ui/offer.ts | 73 +++++++++++++++++------------- 26 files changed, 172 insertions(+), 81 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8af9049..cd23d57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -126,6 +126,7 @@ saved before the change is dropped and the next visit starts a new run. ### Fixed +- Dialogs never run off the bottom of a phone's screen: only their middle scrolls, and their buttons always show. - Long dialogs (the almanac, the rainbow gem's offer) scroll with a finger again. - The player is drawn a little higher, so their feet never touch the line between tiles. - A top-bar number bumping at the edge of its row (Turn) is no longer cut off. diff --git a/PLAN.md b/PLAN.md index a6723e8..3950f96 100644 --- a/PLAN.md +++ b/PLAN.md @@ -781,10 +781,13 @@ new scenes (*done 2026-10-24*, clip-only, `clipOnly` in sim/demo.ts: `lastEnemy` `crate`, `pillar` for walls; lives play the `dieEnemy` scene; turn and par, which only a number shows, and the modes have none); 5. the offer's cards, the almanac and the "Ouch!" replay (*done 2026-10-24*, the user's choices: one clip above the -offer's cards, playing the card whose picture — now its own button — was tapped or pointed at; +offer's cards, playing the card selected — *since 2026-10-24 (user request) a tap on a card +selects it and Choose takes it*; almanac entries with a clip are buttons that open it in their own dialog, Back returning; the banner replays the player's own last 4 turns of the attempt, a demo room's worth of the level round where they died). Clips can now be several at once, fixed scenes or made each loop. +*Also done 2026-10-24:* the modes' tag plays `rainbowGem` (Developer) or `immortal`, and under +reduced motion every clip is a still of its moment (the scene played to its end, held). ## 13b. Run options (user request, 2026-10-21; built) diff --git a/README.md b/README.md index bfff82b..97942a4 100644 --- a/README.md +++ b/README.md @@ -129,7 +129,8 @@ made on the spot like the demo's scenes and new every loop: a bomb counting down lingering flame, a crate breaking, flame stopping at a pillar, the last enemy dying and the exit opening, losing a life, the exit, the gem, coins, each power-up and charge; for the actions (Move, Bomb, Wait) and the power-ups you work yourself (Run, Kick, Throw, Teleport, Diagonal, the Detonator), -a finger shows how. Enemies have only their picture. +a finger shows how. Enemies have only their picture. With reduced motion turned on, each clip is +a still picture of its moment instead. **Menu:** the round button at the top right (or **Esc** with nothing picked). It shows the level and seed, and has the **Almanac** — every power-up, charge and enemy type, with its @@ -250,9 +251,10 @@ uncovered:** flame on it destroys it and lets out 3 enemies, just like the exit those enemies can't be hurt by the flame that let them out). **Taking the gem offers 5 different power-ups, and you choose one.** The dialog shows each with -what it does; it can't be closed without choosing, and choosing takes no turn. A clip above the -cards plays the first one; tap a card's picture (or point at it with a mouse) to play that one, -and tap the card itself to choose it. The 5 are drawn +what it does; it can't be closed without choosing, and choosing takes no turn. Choosing takes two +taps: **tap a card** to select it (it turns green, and a clip above the cards shows it in action), +then **Choose** to take it. The first card is selected to begin with. Heart and Coin bag, which +have nothing to show on the board, show their picture there instead. The 5 are drawn at random, but not all are equally likely: common ones (Bomb +1, Fire +1) come up far more often than rare ones (Teleport, Heart). An ability or pass you already hold is never offered. The offer is fixed by the seed and level, so a retry after a death offers the same. If you die on the turn you take the gem, there diff --git a/decisions.md b/decisions.md index 7bc400d..20843d5 100644 --- a/decisions.md +++ b/decisions.md @@ -1486,3 +1486,45 @@ open their clip, and a replay of the player's own last turns on the banner. 9 × 11 board fitted and centred between the bars. The enemy shot taps a Blob (1, 7) now; the auto-move and long-press shots, which tap too, show what their comments say: the way to (3, 3) in blue with Go, and a bomb armed right of the player. + +### 2026-10-24 — Clips: the modes, and stills under reduced motion + +The last of the clips plan. **The modes' tag** now plays a clip: the rainbow gem beside the +player, taken (Developer mode; a new clip-only scene, `rainbowGem`), or the player unharmed in a +blast or by an enemy (`immortal`); a run with both shows Developer's. **Under reduced motion** a +clip is a still, as the user chose ("whatever is clearer"): the scene played to its end at once +and held — the moment just after it, flame still on the board, the player's pose — with no finger +and no next loop. Checked in a browser with reduced motion emulated: the Blast dialog shows its +still, the flame on the board; both mode clips play; no errors. + +### 2026-10-24 — The gem's offer: select, then Choose + +User report: in the (rainbow) gem's offer a tap on a card's description chose it at once; it should +be two steps — tap to select and see it in action, then confirm. Now a tap anywhere on a card +selects it (ringed green, `aria-pressed`), the clip above the cards plays it, and a **Choose Kick** +button (it names the selection) takes it; the first card starts selected. Every gem's offer works +this way, not only the rainbow one, so it is the same everywhere. The separate picture button and +the mouse-hover preview of the step before are gone: the selection is the preview. Checked in a +browser with touch: a tap on Kick keeps the dialog open and changes the button to "Choose Kick"; +Choose takes it (Kick appears in the top bar). + +### 2026-10-24 — Dialogs fit the visible screen + +User report (a screenshot from a phone): the gem's offer ran off the bottom of the screen, its +Choose button cut in half. The dialog's body had a height guessed from the screen's (`100vh` less +150 or 260 px for the rest); on a phone `100vh` includes the part behind the browser's address +bar, and the offer's two-line title, clip and Choose button took more than the guess. Now the +panel is a column never taller than what the browser shows (`100dvh`, less room for the close +button above it; `100vh` where `dvh` isn't known) and only its body scrolls, so the title and +buttons always show, whatever is in it. Checked at 390 × 640, 390 × 844 and 1280 × 600: the +rainbow gem's offer and the almanac fit, the Choose button 30 px above the panel's bottom edge. + +### 2026-10-24 — Heart and Coin bag show their picture in the offer + +User report: selecting Heart or Coin bag in the gem's offer left the clip's place empty. They have +no clip (nothing changes on the board: a life, coins), so their picture now shows there, big, on a +pale box the clip's size and shape; any other selection brings the clip back. On the way: a +hidden clip canvas didn't hide (`canvas.clip` sets `display: block`, which beats `hidden`), so +`canvas.clip[hidden]` is `display: none` now — the "Ouch!" banner's canvas, hidden when there is no +replay, relied on it too. Checked in a browser: Heart and Coin bag show their pictures, Kick its +clip again. diff --git a/docs/screenshots/almanac-charges.png b/docs/screenshots/almanac-charges.png index eddefd4..e3f0657 100644 Binary files a/docs/screenshots/almanac-charges.png and b/docs/screenshots/almanac-charges.png differ diff --git a/docs/screenshots/almanac-enemies.png b/docs/screenshots/almanac-enemies.png index ed0017a..3f47be9 100644 Binary files a/docs/screenshots/almanac-enemies.png and b/docs/screenshots/almanac-enemies.png differ diff --git a/docs/screenshots/almanac.png b/docs/screenshots/almanac.png index de2e477..810ccae 100644 Binary files a/docs/screenshots/almanac.png and b/docs/screenshots/almanac.png differ diff --git a/docs/screenshots/auto-move.png b/docs/screenshots/auto-move.png index c6f0331..a033ca7 100644 Binary files a/docs/screenshots/auto-move.png and b/docs/screenshots/auto-move.png differ diff --git a/docs/screenshots/bomb-picked.png b/docs/screenshots/bomb-picked.png index a9fefb5..0a79c17 100644 Binary files a/docs/screenshots/bomb-picked.png and b/docs/screenshots/bomb-picked.png differ diff --git a/docs/screenshots/death.png b/docs/screenshots/death.png index 07505eb..3e07817 100644 Binary files a/docs/screenshots/death.png and b/docs/screenshots/death.png differ diff --git a/docs/screenshots/dev-offer.png b/docs/screenshots/dev-offer.png index d40573e..39ca629 100644 Binary files a/docs/screenshots/dev-offer.png and b/docs/screenshots/dev-offer.png differ diff --git a/docs/screenshots/enemies.png b/docs/screenshots/enemies.png index 6e4d19b..ab0b126 100644 Binary files a/docs/screenshots/enemies.png and b/docs/screenshots/enemies.png differ diff --git a/docs/screenshots/gem-offer.png b/docs/screenshots/gem-offer.png index 130af7f..9f05d0b 100644 Binary files a/docs/screenshots/gem-offer.png and b/docs/screenshots/gem-offer.png differ diff --git a/docs/screenshots/gem.png b/docs/screenshots/gem.png index 6b318ff..37727c7 100644 Binary files a/docs/screenshots/gem.png and b/docs/screenshots/gem.png differ diff --git a/docs/screenshots/info-hud.png b/docs/screenshots/info-hud.png index bc3fe13..f46d8f6 100644 Binary files a/docs/screenshots/info-hud.png and b/docs/screenshots/info-hud.png differ diff --git a/docs/screenshots/info-mode.png b/docs/screenshots/info-mode.png index 9c1cf21..350d19f 100644 Binary files a/docs/screenshots/info-mode.png and b/docs/screenshots/info-mode.png differ diff --git a/docs/screenshots/lingering.png b/docs/screenshots/lingering.png index 77624f6..38164a7 100644 Binary files a/docs/screenshots/lingering.png and b/docs/screenshots/lingering.png differ diff --git a/docs/screenshots/menu.png b/docs/screenshots/menu.png index 024c09c..c2b7886 100644 Binary files a/docs/screenshots/menu.png and b/docs/screenshots/menu.png differ diff --git a/docs/screenshots/move-picked.png b/docs/screenshots/move-picked.png index 867f7b1..f8790a3 100644 Binary files a/docs/screenshots/move-picked.png and b/docs/screenshots/move-picked.png differ diff --git a/docs/screenshots/nuke.png b/docs/screenshots/nuke.png index 5ed23e1..c4880b2 100644 Binary files a/docs/screenshots/nuke.png and b/docs/screenshots/nuke.png differ diff --git a/src/main.ts b/src/main.ts index 1a648bf..57e1417 100644 --- a/src/main.ts +++ b/src/main.ts @@ -786,6 +786,7 @@ const SCENE_ICONS: Record<SceneKind, string> = { pierce: CHARGE_ICONS.pierce, power: CHARGE_ICONS.power, nuke: CHARGE_ICONS.nuke, shield: CHARGE_ICONS.shield, freeze: CHARGE_ICONS.freeze, decoy: CHARGE_ICONS.decoy, exit: 'door_open_top', doorBlast: 'door_closed_top', lastEnemy: 'door_open_top', crate: 'block_planks', pillar: 'block_blue', + rainbowGem: 'gem_green', immortal: 'star', dieIntoBlast: 'fireball', dieNotFar: 'fireball', dieBlockedIn: 'bomb', dieChain: 'bomb', dieEnemy: 'slime_normal_rest', dieDetonator: POWER_UP_ICONS.detonator, dieRun: POWER_UP_ICONS.run, } diff --git a/src/sim/demo.ts b/src/sim/demo.ts index f1ab70b..63c356c 100644 --- a/src/sim/demo.ts +++ b/src/sim/demo.ts @@ -40,7 +40,7 @@ export type SceneKind = | 'wallPass' | 'bombPass' | 'flamePass' | 'star' | 'pierce' | 'power' | 'nuke' | 'shield' | 'freeze' | 'decoy' | 'exit' | 'doorBlast' - | 'lastEnemy' | 'crate' | 'pillar' + | 'lastEnemy' | 'crate' | 'pillar' | 'rainbowGem' | 'immortal' | 'dieIntoBlast' | 'dieNotFar' | 'dieBlockedIn' | 'dieChain' | 'dieEnemy' | 'dieDetonator' | 'dieRun' /** @@ -396,6 +396,25 @@ export const SCENES: Record<SceneKind, SceneDef> = { clipOnly: true, shows: (t) => t.events.some((e) => e.type === 'blockDestroyed' && t.next.gems.some((g) => at(g.pos, e.pos))), }, + // The run's modes, for their tag's dialog. + rainbowGem: { + caption: 'Developer mode: a rainbow gem beside you, offering every power-up and charge.', + enemies: ['blob'], + prepare: (s, rng) => { + s.options = { ...s.options, dev: true } + const near = tilesAt(s, 1) + if (near.length) s.gems.push({ pos: near[rng.int(near.length)]!, color: 'rainbow', extra: true }) + }, + clipOnly: true, + shows: (t) => t.events.some((e) => e.type === 'powerUpTaken'), + }, + immortal: { + caption: 'Immortal: nothing can hurt you.', + enemies: ['seeker', 'blob'], + prepare: (s) => (s.options = { ...s.options, immortal: true }), + clipOnly: true, + shows: (t) => explosions(t).some((e) => e.tiles.some((p) => at(p, t.next.player))) || t.next.enemies.some((e) => at(e.pos, t.next.player)), + }, pillar: { caption: 'Flame stops at a pillar.', enemies: ['blob'], diff --git a/src/style.css b/src/style.css index d405422..d68733e 100644 --- a/src/style.css +++ b/src/style.css @@ -509,10 +509,18 @@ img { } /* The RPG expansion's parchment panel, stretched by its edges (9-slice). */ +/* A column: picture, title, body, buttons. It is never taller than the screen — what the browser + shows of it, without the address bar (dvh; vh where dvh isn't known), with room for the close + button sticking out above — and only the body scrolls, so the buttons always show (user report, 2026-10-24: a long offer's Choose button ran + off the bottom of a phone; the body's height was a guess from 100vh). */ #dialog .panel { position: relative; - display: grid; - justify-items: center; + display: flex; + flex-direction: column; + align-items: center; + box-sizing: border-box; + max-height: calc(100vh - 64px); + max-height: calc(100dvh - 64px); gap: 10px; padding: 14px 18px 18px; border: 12px solid transparent; @@ -549,7 +557,9 @@ img { } #dialog-body { - max-height: calc(100vh - 260px); + flex: 0 1 auto; + align-self: stretch; + min-height: 0; overflow-y: auto; /* Scrolls by touch, wheel or trackpad, without a scroll bar across the parchment. */ scrollbar-width: none; @@ -573,10 +583,6 @@ img { } /* A dialog answered from its body (the gem's choice) has no buttons below: more room for it. */ -#dialog .panel.required #dialog-body { - max-height: calc(100vh - 150px); -} - #dialog-buttons:empty { display: none; } @@ -667,8 +673,8 @@ button.almanac-entry.has-clip:active { background: rgb(53 53 65 / 0.06); } -/* The gem's choice (ui/offer.ts): the clip of the power-up looked at, kept in view while the cards - scroll; each card's picture beside it, a button that plays it there; the card itself chooses. */ +/* The gem's choice (ui/offer.ts): the clip of the selected power-up, kept in view while the cards + scroll; a tap on a card selects it (ringed green), and the Choose button takes it. */ .offer-clip { position: sticky; top: 0; @@ -677,39 +683,9 @@ button.almanac-entry.has-clip:active { background: #ece3ce; } -.offer-row { - display: grid; - grid-template-columns: 56px 1fr; - gap: 8px; - align-items: stretch; - margin: 0 0 8px; -} - -.offer-preview { - display: grid; - place-items: center; - padding: 0; - border: 2px solid var(--ink); - border-radius: 12px; - background: #fff8e6; - box-shadow: 0 3px 0 rgb(53 53 65 / 0.35); - cursor: pointer; -} - -.offer-preview img { - width: 40px; - height: 40px; -} - -/* The one playing above. */ -.offer-row.previewed .offer-preview { - border-color: var(--accent-dark); - background: #d9f6e9; -} - .offer-choice { width: 100%; - margin: 0; + margin: 0 0 8px; text-align: left; padding: 8px 10px; border: 2px solid var(--ink); @@ -721,13 +697,29 @@ button.almanac-entry.has-clip:active { cursor: pointer; } -#dialog-body .offer-choice strong { +/* In place of the clip, for a power-up with nothing to show on the board (Heart, Coin bag): its + picture, big, in a room-shaped box as wide as a clip. */ +.offer-still { display: block; + box-sizing: border-box; + width: 100%; + max-width: 300px; + aspect-ratio: 9 / 7; + margin: 0 auto; + padding: 18%; + border-radius: 8px; + background: #fff8e6; + object-fit: contain; } -#dialog-body .offer-choice p { - margin: 2px 0 0; - font-size: 14px; +.offer-still[hidden] { + display: none; +} + +/* The selected one. */ +.offer-choice[aria-pressed='true'] { + border-color: var(--accent-dark); + background: #d9f6e9; } .offer-choice:active { @@ -1349,6 +1341,11 @@ canvas.clip { border-radius: 8px; } +/* A clip's canvas that isn't wanted (the banner's when nothing is replayed, the offer's for Heart). */ +canvas.clip[hidden] { + display: none; +} + /* A clip with the finger (ui/clip.ts): the canvas in a frame, the finger and its marks over it. */ .clip-frame { position: relative; diff --git a/src/ui/clip.ts b/src/ui/clip.ts index 1b747ab..86d0433 100644 --- a/src/ui/clip.ts +++ b/src/ui/clip.ts @@ -127,6 +127,7 @@ function tick(c: Clip): void { c.next = 0 c.at = time + LEAD_SECONDS c.anim = newAnimation() + if (matchMedia('(prefers-reduced-motion: reduce)').matches) return still(c) if (c.finger) { // The finger appears beside the player, where the eye should start (as in the demo; user // report, 2026-10-24: it came in from the top left corner), and glides from there. @@ -158,6 +159,18 @@ function tick(c: Clip): void { } } +/** + * With reduced motion, a clip is a still (user's choice, 2026-10-24: "whatever is clearer"): the + * scene's moment, just after it — the flame still on the board, the player's pose — held, with + * no finger and no next loop. + */ +function still(c: Clip): void { + for (const action of c.scene!.actions) c.state = step(c.state!, action).state + c.next = c.scene!.actions.length + c.at = Infinity + c.finger?.classList.add('away') +} + function play(c: Clip, action: Action): void { const result = step(c.state!, action) animateTurn(c.anim, c.state!, result, action, time) diff --git a/src/ui/info.ts b/src/ui/info.ts index 8c460bc..f64baa9 100644 --- a/src/ui/info.ts +++ b/src/ui/info.ts @@ -135,7 +135,7 @@ export function hudInfo(item: HudItem, s: GameState): Info { clip: 'dieEnemy', } case 'modes': - return { icon: 'lever', title: 'Run settings', body: modeLines(s) } + return { icon: 'lever', title: 'Run settings', body: modeLines(s), clip: s.options.dev ? 'rainbowGem' : 'immortal' } case 'level': return { icon: 'flag_green_a', diff --git a/src/ui/offer.ts b/src/ui/offer.ts index 321fc11..4a71827 100644 --- a/src/ui/offer.ts +++ b/src/ui/offer.ts @@ -2,7 +2,7 @@ import { isCharge, type OfferKind } from '../sim/types' import { CHARGE_ICONS, POWER_UP_ICONS, spriteUrl } from '../render/sprites' import type { SceneKind } from '../sim/demo' import { startClip, stopClip } from './clip' -import { closeDialog, openDialog } from './dialog' +import { openDialog } from './dialog' import { CHARGE_TEXT, POWER_UP_CLIP, POWER_UP_TEXT } from './info' /** @@ -10,64 +10,77 @@ import { CHARGE_TEXT, POWER_UP_CLIP, POWER_UP_TEXT } from './info' * laid out like the almanac. One must be picked; the dialog can't be closed any other way. * Developer mode's rainbow gem offers the charges too (2026-10-24). * - * One clip above the cards (PLAN.md §13c; user's choice, 2026-10-24) plays the power-up looked at: - * the first one's at first, then the one whose picture was last tapped (or hovered with a mouse). - * The picture is its own button, beside the card; choosing is still one tap on the card. + * Choosing takes two taps (user request, 2026-10-24: a tap on a card chose it at once): a tap on a + * card selects it, and the clip above the cards (PLAN.md §13c) plays it; **Choose** takes the one + * selected. The first card is selected at first. */ let chosen: ((kind: OfferKind) => void) | null = null +let selected: OfferKind | null = null export function openOffer(offer: OfferKind[], choose: (kind: OfferKind) => void): void { chosen = choose openDialog({ title: 'Choose a power-up', body: - '<div class="offer-clip"><canvas class="clip" aria-hidden="true"></canvas></div>' + + '<div class="offer-clip"><canvas class="clip" aria-hidden="true"></canvas><img class="offer-still" alt="" draggable="false" hidden></div>' + offer .map((k) => { const text = isCharge(k) ? CHARGE_TEXT[k] : POWER_UP_TEXT[k] return ( - `<div class="offer-row" data-row="${k}">` + - `<button type="button" class="offer-preview" data-preview="${k}" aria-label="Show ${text.name}">` + - `<img src="${spriteUrl(isCharge(k) ? CHARGE_ICONS[k] : POWER_UP_ICONS[k])}" alt="" draggable="false"></button>` + - `<button type="button" class="offer-choice" data-offer="${k}"><strong>${text.name}</strong><p>${text.text}</p></button></div>` + `<button type="button" class="almanac-entry offer-choice" data-offer="${k}" aria-pressed="false">` + + `<img src="${spriteUrl(isCharge(k) ? CHARGE_ICONS[k] : POWER_UP_ICONS[k])}" alt="" draggable="false">` + + `<div><strong>${text.name}</strong><p>${text.text}</p></div></button>` ) }) .join(''), - buttons: [], + buttons: [ + { + label: 'Choose', + kind: 'primary', + onClick: () => { + chosen = null + if (selected) choose(selected) + }, + }, + ], required: true, }) - preview(offer.find((k) => clipOf(k)) ?? offer[0]!) + select(offer[0]!) +} + +function nameOf(k: OfferKind): string { + return (isCharge(k) ? CHARGE_TEXT[k] : POWER_UP_TEXT[k]).name } function clipOf(k: OfferKind): SceneKind | undefined { return isCharge(k) ? k : POWER_UP_CLIP[k] } -/** The clip above the cards plays `k` (one without a clip, Heart or Coin bag, leaves it blank). */ -function preview(k: OfferKind): void { +/** + * Selects `k`: its card marked, the Choose button naming it, and the clip above the cards playing + * it; one without a clip (Heart, Coin bag: nothing to see on the board) shows its picture there, + * big, instead (user report, 2026-10-24: the space was left empty). + */ +function select(k: OfferKind): void { + selected = k + for (const card of document.querySelectorAll<HTMLElement>('#dialog-body .offer-choice')) { + card.setAttribute('aria-pressed', String(card.dataset.offer === k)) + } + const label = document.querySelector('#dialog-buttons button span') + if (label) label.textContent = `Choose ${nameOf(k)}` const canvas = document.querySelector<HTMLCanvasElement>('#dialog-body .offer-clip canvas.clip') - if (!canvas) return - for (const row of document.querySelectorAll('.offer-row')) row.classList.toggle('previewed', (row as HTMLElement).dataset.row === k) + const still = document.querySelector<HTMLImageElement>('#dialog-body .offer-still') + if (!canvas || !still) return const clip = clipOf(k) + canvas.hidden = !clip + still.hidden = !!clip if (clip) return startClip(canvas, clip) stopClip(canvas) - canvas.getContext('2d')?.clearRect(0, 0, canvas.width, canvas.height) + still.src = spriteUrl(isCharge(k) ? CHARGE_ICONS[k] : POWER_UP_ICONS[k]) } const dialogBody = document.getElementById('dialog-body')! dialogBody.addEventListener('click', (e) => { - const look = (e.target as Element).closest<HTMLElement>('[data-preview]') - if (look && chosen) return preview(look.dataset.preview as OfferKind) - const button = (e.target as Element).closest<HTMLElement>('[data-offer]') - if (!button || !chosen) return - const choose = chosen - chosen = null - closeDialog(() => choose(button.dataset.offer as OfferKind)) -}) - -// With a mouse, pointing at a card plays it too. -dialogBody.addEventListener('pointerover', (e) => { - if (e.pointerType !== 'mouse' || !chosen) return - const row = (e.target as Element).closest<HTMLElement>('[data-row]') - if (row && !row.classList.contains('previewed')) preview(row.dataset.row as OfferKind) + const card = (e.target as Element).closest<HTMLElement>('[data-offer]') + if (card && chosen) select(card.dataset.offer as OfferKind) })