fuzzyworld

explodorama / files / src / ui / shop.ts

shop.ts

5.7 KB · history · blame

1import { CHARGE_ICONS, POWER_UP_ICONS, spriteUrl } from '../render/sprites'
2import type { SceneKind } from '../sim/demo'
3import { PRICES, rerollPrice } from '../sim/shop'
4import { isCharge, type GameState, type OfferKind } from '../sim/types'
5import { startClip, stopClip } from './clip'
6import { dialogOpen, openDialog } from './dialog'
7import { CHARGE_TEXT, changeLine, POWER_UP_CLIP, POWER_UP_TEXT } from './info'
8import 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 */
18export interface ShopHandlers {
19 buy(kind: OfferKind): void
20 reroll(): void
21 leave(): void
22}
23
24let selected: OfferKind | null = null
25/** Whether the open dialog is the shop, the state it shows, and what to do. */
26let open = false
27let current: GameState | null = null
28let handlers: ShopHandlers | null = null
29
30function textOf(k: OfferKind): { name: string; text: string } {
31 return isCharge(k) ? CHARGE_TEXT[k] : POWER_UP_TEXT[k]
32}
33
34function clipOf(k: OfferKind): SceneKind | undefined {
35 return isCharge(k) ? k : POWER_UP_CLIP[k]
36}
37
38export 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. */
59export 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">` +
77 `<div><strong>${textOf(k).name}<span class="price">${price}</span></strong>${changeLine(s, k)}<p>${textOf(k).text}</p>${buy}</div></div>`
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. */
87function 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.
108const dialogBody = document.getElementById('dialog-body')!
109dialogBody.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.
117dialogBody.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})