explodorama / files / src / ui / shop.ts
shop.ts
e78b7781 2026-10-25 Pavel Safronov | 1 | import { CHARGE_ICONS, POWER_UP_ICONS, spriteUrl } from '../render/sprites' |
| 2 | import type { SceneKind } from '../sim/demo' | |
| 3 | import { PRICES, rerollPrice } from '../sim/shop' | |
| 4 | import { isCharge, type GameState, type OfferKind } from '../sim/types' | |
| 5 | import { startClip, stopClip } from './clip' | |
| 6 | import { dialogOpen, openDialog } from './dialog' | |
e6bb7c42 2026-10-25 Pavel Safronov | 7 | import { CHARGE_TEXT, changeLine, POWER_UP_CLIP, POWER_UP_TEXT } from './info' |
e78b7781 2026-10-25 Pavel Safronov | 8 | import coinIcon from '../../assets/kenney/hud_coin.png' |
| 9 | ||
| 10 | /** | |
| 11 | * The shop's dialog (sim/shop.ts, PLAN.md §8): the coins, a clip of the item selected (as in the | |
| 12 | * gem's offer), the items with their prices, and Restock and Next level side by side. The | |
| 13 | * selected item has its own Buy button, so the dialog's buttons take one short row and the items | |
| 14 | * have room (user report, 2026-10-24: three big buttons left little, and it scrolled all the | |
| 15 | * time). Buying and restocking keep it open (and it shows the new state); Next level leaves. It | |
| 16 | * can't be closed any other way. | |
| 17 | */ | |
| 18 | export interface ShopHandlers { | |
| 19 | buy(kind: OfferKind): void | |
| 20 | reroll(): void | |
| 21 | leave(): void | |
| 22 | } | |
| 23 | ||
| 24 | let selected: OfferKind | null = null | |
| 25 | /** Whether the open dialog is the shop, the state it shows, and what to do. */ | |
| 26 | let open = false | |
| 27 | let current: GameState | null = null | |
| 28 | let handlers: ShopHandlers | null = null | |
| 29 | ||
| 30 | function textOf(k: OfferKind): { name: string; text: string } { | |
| 31 | return isCharge(k) ? CHARGE_TEXT[k] : POWER_UP_TEXT[k] | |
| 32 | } | |
| 33 | ||
| 34 | function clipOf(k: OfferKind): SceneKind | undefined { | |
| 35 | return isCharge(k) ? k : POWER_UP_CLIP[k] | |
| 36 | } | |
| 37 | ||
| 38 | export function showShop(s: GameState, h: ShopHandlers): void { | |
| 39 | selected = null | |
| 40 | handlers = h | |
| 41 | openDialog({ | |
| 42 | title: 'Shop', | |
| 43 | body: | |
| 44 | '<p class="shop-coins"></p>' + | |
| 45 | '<div class="offer-clip"><canvas class="clip" aria-hidden="true"></canvas><img class="offer-still" alt="" draggable="false" hidden></div>' + | |
| 46 | '<div class="shop-items"></div>', | |
| 47 | buttons: [ | |
| 48 | { label: 'Restock', kind: 'plain', keepOpen: true, onClick: h.reroll }, | |
| 49 | { label: 'Next level', kind: 'primary', onClick: () => ((open = false), h.leave()) }, | |
| 50 | ], | |
| 51 | required: true, | |
| 52 | className: 'shop', | |
| 53 | }) | |
| 54 | open = true | |
| 55 | updateShop(s) | |
| 56 | } | |
| 57 | ||
| 58 | /** Shows the shop as `s` has it: after a purchase or a restock. Nothing if it isn't open. */ | |
| 59 | export function updateShop(s: GameState): void { | |
| 60 | const shop = s.shop | |
| 61 | if (!open || !dialogOpen() || !shop) return | |
| 62 | current = s | |
| 63 | document.querySelector('#dialog-body .shop-coins')!.innerHTML = | |
| 64 | `<img src="${coinIcon}" alt="" draggable="false"> You have <b>${s.coins}</b> ${s.coins === 1 ? 'coin' : 'coins'}.` | |
| 65 | document.querySelector('#dialog-body .shop-items')!.innerHTML = shop.items | |
| 66 | .map((k) => { | |
| 67 | const sold = shop.sold.includes(k) | |
| 68 | const price = sold ? 'Sold' : `<img src="${coinIcon}" alt="" draggable="false">${PRICES[k]}` | |
| 69 | // A card selects (it can't be a <button>: its Buy button is inside it). | |
| 70 | const buy = sold | |
| 71 | ? '' | |
| 72 | : `<button type="button" class="ui-button primary buy" data-buy="${k}"${s.coins < PRICES[k] ? ' disabled' : ''}>` + | |
| 73 | `<span>${s.coins < PRICES[k] ? `Need ${PRICES[k] - s.coins} more` : `Buy for ${PRICES[k]}`}</span></button>` | |
| 74 | return ( | |
| 75 | `<div class="almanac-entry offer-choice shop-item${sold ? ' sold' : ''}" data-item="${k}" role="button" tabindex="0" aria-pressed="false">` + | |
| 76 | `<img src="${spriteUrl(isCharge(k) ? CHARGE_ICONS[k] : POWER_UP_ICONS[k])}" alt="" draggable="false">` + | |
e6bb7c42 2026-10-25 Pavel Safronov | 77 | `<div><strong>${textOf(k).name}<span class="price">${price}</span></strong>${changeLine(s, k)}<p>${textOf(k).text}</p>${buy}</div></div>` |
e78b7781 2026-10-25 Pavel Safronov | 78 | ) |
| 79 | }) | |
| 80 | .join('') | |
| 81 | // After a restock or a purchase, the first item still for sale, unless the selected one still is. | |
| 82 | if (!selected || !shop.items.includes(selected) || shop.sold.includes(selected)) selected = shop.items.find((k) => !shop.sold.includes(k)) ?? null | |
| 83 | select(s, selected) | |
| 84 | } | |
| 85 | ||
| 86 | /** Selects `k` (or nothing, all sold): its card marked, showing its Buy button, the clip playing it. */ | |
| 87 | function select(s: GameState, k: OfferKind | null): void { | |
| 88 | selected = k | |
| 89 | const shop = s.shop! | |
| 90 | for (const card of document.querySelectorAll<HTMLElement>('#dialog-body .shop-item')) { | |
| 91 | card.setAttribute('aria-pressed', String(card.dataset.item === k)) | |
| 92 | } | |
| 93 | const [restock] = document.querySelectorAll<HTMLButtonElement>('#dialog-buttons button') | |
| 94 | restock!.querySelector('span')!.textContent = `Restock — ${rerollPrice(shop)}` | |
| 95 | restock!.disabled = s.coins < rerollPrice(shop) | |
| 96 | const canvas = document.querySelector<HTMLCanvasElement>('#dialog-body .offer-clip canvas.clip') | |
| 97 | const still = document.querySelector<HTMLImageElement>('#dialog-body .offer-still') | |
| 98 | if (!canvas || !still) return | |
| 99 | const clip = k && clipOf(k) | |
| 100 | canvas.hidden = !clip | |
| 101 | still.hidden = !!clip || !k | |
| 102 | if (clip) return startClip(canvas, clip) | |
| 103 | stopClip(canvas) | |
| 104 | if (k) still.src = spriteUrl(isCharge(k) ? CHARGE_ICONS[k] : POWER_UP_ICONS[k]) | |
| 105 | } | |
| 106 | ||
| 107 | // A tap on an item for sale selects it; its Buy button buys it. | |
| 108 | const dialogBody = document.getElementById('dialog-body')! | |
| 109 | dialogBody.addEventListener('click', (e) => { | |
| 110 | if (!open || !current?.shop) return | |
| 111 | const buy = (e.target as Element).closest<HTMLElement>('[data-buy]') | |
| 112 | if (buy) return handlers?.buy(buy.dataset.buy as OfferKind) | |
| 113 | const card = (e.target as Element).closest<HTMLElement>('[data-item]') | |
| 114 | if (card && !card.classList.contains('sold')) select(current, card.dataset.item as OfferKind) | |
| 115 | }) | |
| 116 | // The cards are buttons for the keyboard too: Enter or Space selects one. | |
| 117 | dialogBody.addEventListener('keydown', (e) => { | |
| 118 | const card = (e.target as Element).closest<HTMLElement>('[data-item]') | |
| 119 | if (!open || !current?.shop || !card || (e.key !== 'Enter' && e.key !== ' ') || e.target !== card) return | |
| 120 | e.preventDefault() | |
| 121 | if (!card.classList.contains('sold')) select(current, card.dataset.item as OfferKind) | |
| 122 | }) |