Public: anyone can read this; the crew can change it.
forked from trevato/cs · proposes · 4 behind
The shooter's entry sheds its first concept: the buy panel becomes its own module #257
Check · Review · Land · Live done — Landed and live in production.
torque/cs-engine:torque/250-the-shooter-s-entry-sheds-its-first-conc → main 2 commits fork
6 files
changed +326
−31
README.md +6
| @@ -656,6 +656,12 @@ never checked itself. | ||
| 656 | 656 | the round will let them have it, and what handing it over does to their wallet |
| 657 | 657 | and their kit — `priceOf`, `canBuy`, `buy`, the pocket limits in `CARRY` and |
| 658 | 658 | the `Ammo` a fresh gun comes with), |
| 659 | `buy` (the panel that economy is spent through: the shelf a side is shown — | |
| 660 | its guns, the armour, the kit only a CT can carry — the price `economy` gives | |
| 661 | for the body in front of you, and which tiles the wallet and the round leave | |
| 662 | pressable. It takes the element to draw into, the round and the player, and a | |
| 663 | call that says which item was pressed; it reaches for no document and buys | |
| 664 | nothing, so `main` spends the money in the one place that holds a world), | |
| 659 | 665 | `bomb` (the objective: `giveBomb`, `dropBomb`, `updateBomb` and the |
| 660 | 666 | `useProgress` a readout draws its bar from — carrying it, planting it on a |
| 661 | 667 | site, cutting the wire with a kit or without one; it hands back what the |
src/client/buy.test.ts added +178
| @@ -0,0 +1,178 @@ | ||
| 1 | /** | |
| 2 | * The buy panel's own tests, which are what `main.test.ts` used to assert | |
| 3 | * through a whole booted frame: the shelf a side is shown, the markup it is | |
| 4 | * drawn as — to the character, for a real world in its freeze time — what a | |
| 5 | * price is for the body in front of you, which tiles a wallet and a round leave | |
| 6 | * pressable, and what a press hands back. | |
| 7 | * | |
| 8 | * The page under it is the engine's `fakePage`: one element to draw into and a | |
| 9 | * `createElement` that makes buttons, which is the whole of what this module | |
| 10 | * borrows from a browser. Most of it needs no world at all — a `Market` is an | |
| 11 | * object with a phase in it and a `Buyer` is the kit and the wallet, the way | |
| 12 | * `economy.test.ts` writes them — and the exact-markup test uses a real | |
| 13 | * `createWorld`, because a module that only works against its own fixtures has | |
| 14 | * proved nothing. | |
| 15 | */ | |
| 16 | ||
| 17 | import { expect, test } from "bun:test"; | |
| 18 | import { fakePage, type FakeElement } from "../engine/index.ts"; | |
| 19 | import { drawBuy, HINT, offers, type Counter } from "./buy.ts"; | |
| 20 | import { emptyAmmo, type BuyItem, type Buyer, type Market } from "./economy.ts"; | |
| 21 | import { DE_FORGE } from "./map.ts"; | |
| 22 | import { SHORT } from "./match.ts"; | |
| 23 | import { ARMOR_PRICE, DEFUSE_KIT_PRICE, HELMET_PRICE, WEAPONS } from "./weapons.ts"; | |
| 24 | import { createWorld } from "./world.ts"; | |
| 25 | ||
| 26 | const freeze: Market = { match: { phase: "freeze" } }; | |
| 27 | const live: Market = { match: { phase: "live" } }; | |
| 28 | ||
| 29 | const buyer = (over: Partial<Buyer> = {}): Buyer => ({ | |
| 30 | team: "ct", alive: true, money: 5000, | |
| 31 | armor: 0, helmet: false, defuseKit: false, | |
| 32 | weapon: "usp", primary: null, secondary: "usp", | |
| 33 | ammo: { knife: { mag: 0, reserve: 0 }, usp: emptyAmmo("usp") }, | |
| 34 | pocket: { he: 0, flash: 0, smoke: 0 }, | |
| 35 | ...over, | |
| 36 | }); | |
| 37 | ||
| 38 | /** A panel to draw into, the buttons it made, and every item a press handed back. */ | |
| 39 | function shop(): { | |
| 40 | panel: FakeElement; | |
| 41 | counter: Counter; | |
| 42 | pressed: BuyItem[]; | |
| 43 | tiles(): FakeElement[]; | |
| 44 | labels(): (string | null)[]; | |
| 45 | /** The tile whose label starts with this word, which is how a test names one. */ | |
| 46 | tile(label: string): FakeElement; | |
| 47 | } { | |
| 48 | const page = fakePage({ ids: ["buy"] }); | |
| 49 | const panel = page.by("buy"); | |
| 50 | const pressed: BuyItem[] = []; | |
| 51 | const tiles = (): FakeElement[] => panel.querySelector(".grid")?.children ?? []; | |
| 52 | return { | |
| 53 | panel, | |
| 54 | pressed, | |
| 55 | tiles, | |
| 56 | labels: () => tiles().map((tile) => tile.textContent), | |
| 57 | // By label and not by index: a weapon added to the table adds a tile, and a | |
| 58 | // test about the armour should not move when one is. | |
| 59 | tile: (label) => { | |
| 60 | const found = tiles().find((tile) => tile.textContent?.startsWith(label)); | |
| 61 | if (found === undefined) throw new Error(`no tile for ${label}`); | |
| 62 | return found; | |
| 63 | }, | |
| 64 | counter: { | |
| 65 | panel, | |
| 66 | button: () => page.document.createElement("button"), | |
| 67 | press: (item) => void pressed.push(item), | |
| 68 | }, | |
| 69 | }; | |
| 70 | } | |
| 71 | ||
| 72 | test("the shelf is this side's guns, the armour, and the kit only a CT can carry", () => { | |
| 73 | // Every buyable weapon in the table, in the table's order, which is how the | |
| 74 | // smoke #165 added reached the panel without a line in here: the shelf is | |
| 75 | // `weapons.ts`'s list and not a second copy of it. | |
| 76 | expect(offers("ct").map((offer) => offer.item)) | |
| 77 | .toEqual(["usp", "deagle", "mp9", "m4a4", "awp", "he", "flash", "smoke", "armor", "helmet", "kit"]); | |
| 78 | // A T's pistol is the Glock and its rifle is the AK, and there is no kit on the | |
| 79 | // shelf at all: a tile a side can never press is a tile that should not be there. | |
| 80 | expect(offers("t").map((offer) => offer.item)) | |
| 81 | .toEqual(["glock", "deagle", "mp9", "ak47", "awp", "he", "flash", "smoke", "armor", "helmet"]); | |
| 82 | expect(offers("t").map((offer) => offer.label)).not.toContain("Defuse kit"); | |
| 83 | }); | |
| 84 | ||
| 85 | test("the panel a real world in its freeze time is drawn as, to the character", () => { | |
| 86 | const world = createWorld(DE_FORGE, SHORT, "me", "ct", 5, 3); | |
| 87 | expect(world.match.phase).toBe("freeze"); | |
| 88 | expect(world.me.money).toBe(800); | |
| 89 | const it = shop(); | |
| 90 | drawBuy(it.counter, world, world.me); | |
| 91 | ||
| 92 | expect(it.panel.innerHTML).toBe( | |
| 93 | `<h2>Buy <small>$800</small></h2><div class="grid"></div>` + | |
| 94 | `<p class="hint">${HINT}</p>`, | |
| 95 | ); | |
| 96 | // Eleven tiles, each a `<button>` — a press that submits nothing — labelled | |
| 97 | // with the price this body pays, and dead exactly where 800 dollars cannot | |
| 98 | // reach. | |
| 99 | expect(it.tiles().map((tile) => tile.type)).toEqual(Array(11).fill("button")); | |
| 100 | expect(it.tiles().map((tile) => [tile.textContent, tile.disabled])).toEqual([ | |
| 101 | ["USP-S · $200", false], | |
| 102 | ["Desert Eagle · $700", false], | |
| 103 | ["MP9 · $1250", true], | |
| 104 | ["M4A4 · $3100", true], | |
| 105 | ["AWP · $4750", true], | |
| 106 | ["HE Grenade · $300", false], | |
| 107 | ["Flashbang · $200", false], | |
| 108 | ["Smoke Grenade · $300", false], | |
| 109 | ["Kevlar · $650", false], | |
| 110 | ["Kevlar + Helmet · $1000", true], | |
| 111 | ["Defuse kit · $400", false], | |
| 112 | ]); | |
| 113 | }); | |
| 114 | ||
| 115 | test("drawing again is the panel again, not a second one under it", () => { | |
| 116 | const it = shop(); | |
| 117 | const me = buyer(); | |
| 118 | drawBuy(it.counter, freeze, me); | |
| 119 | const drawn = it.tiles().length; | |
| 120 | expect(drawn).toBe(offers("ct").length); | |
| 121 | me.money = 3000; | |
| 122 | drawBuy(it.counter, freeze, me); | |
| 123 | expect(it.tiles().length).toBe(drawn); | |
| 124 | expect(it.panel.innerHTML).toContain("$3000"); | |
| 125 | }); | |
| 126 | ||
| 127 | test("a price is for the body in front of you: a vest on is a helmet at helmet price", () => { | |
| 128 | const it = shop(); | |
| 129 | drawBuy(it.counter, freeze, buyer({ armor: 100 })); | |
| 130 | expect(it.labels()).toContain(`Kevlar + Helmet · $${HELMET_PRICE}`); | |
| 131 | // The vest is already on, so it is priced at nothing — and a tile at nothing is | |
| 132 | // pressable, although `economy.ts` refuses the buy. That is today's behaviour | |
| 133 | // and this pull moved it rather than fixing it: see #250. | |
| 134 | expect(it.labels()).toContain("Kevlar · $0"); | |
| 135 | expect(it.tile("Kevlar ·").disabled).toBe(false); | |
| 136 | // Bare, the helmet is the vest as well and costs both. | |
| 137 | const bare = shop(); | |
| 138 | drawBuy(bare.counter, freeze, buyer()); | |
| 139 | expect(bare.labels()).toContain(`Kevlar + Helmet · $${ARMOR_PRICE + HELMET_PRICE}`); | |
| 140 | expect(bare.labels()).toContain(`Defuse kit · $${DEFUSE_KIT_PRICE}`); | |
| 141 | }); | |
| 142 | ||
| 143 | test("a wallet that cannot reach a price is a dead tile, to the dollar", () => { | |
| 144 | const it = shop(); | |
| 145 | drawBuy(it.counter, freeze, buyer({ money: WEAPONS.awp.price - 1 })); | |
| 146 | expect(it.tile(`AWP · $${WEAPONS.awp.price}`).disabled).toBe(true); | |
| 147 | const rich = shop(); | |
| 148 | drawBuy(rich.counter, freeze, buyer({ money: WEAPONS.awp.price })); | |
| 149 | expect(rich.tile(`AWP · $${WEAPONS.awp.price}`).disabled).toBe(false); | |
| 150 | }); | |
| 151 | ||
| 152 | test("outside the freeze time every tile is dead, and the prices are still readable", () => { | |
| 153 | const it = shop(); | |
| 154 | drawBuy(it.counter, live, buyer({ money: 16_000 })); | |
| 155 | expect(it.tiles().length).toBe(offers("ct").length); | |
| 156 | expect(it.tiles().every((tile) => tile.disabled)).toBe(true); | |
| 157 | expect(it.labels()).toContain(`AWP · $${WEAPONS.awp.price}`); | |
| 158 | // And to a body, wherever the round is: a corpse has nothing to spend it on. | |
| 159 | const dead = shop(); | |
| 160 | drawBuy(dead.counter, freeze, buyer({ alive: false, money: 16_000 })); | |
| 161 | expect(dead.tiles().every((tile) => tile.disabled)).toBe(true); | |
| 162 | }); | |
| 163 | ||
| 164 | test("a press hands the item back and spends nothing itself", () => { | |
| 165 | const it = shop(); | |
| 166 | const me = buyer(); | |
| 167 | drawBuy(it.counter, freeze, me); | |
| 168 | it.tile("USP-S").fire("click"); | |
| 169 | it.tile("Kevlar + Helmet").fire("click"); | |
| 170 | expect(it.pressed).toEqual(["usp", "helmet"]); | |
| 171 | // The wallet and the kit are the caller's to change: this module reads them. | |
| 172 | expect(me.money).toBe(5000); | |
| 173 | expect(me.helmet).toBe(false); | |
| 174 | // A dead tile is dead because a browser will not click it, not because the | |
| 175 | // panel checks twice — so what is asserted here is that one press is one item. | |
| 176 | it.tile("USP-S").fire("click"); | |
| 177 | expect(it.pressed).toEqual(["usp", "helmet", "usp"]); | |
| 178 | }); |
src/client/buy.ts added +97
| @@ -0,0 +1,97 @@ | ||
| 1 | /** | |
| 2 | * The buy panel: the shop as a player reads it. What is on the shelf is this | |
| 3 | * side's guns, the armour, and the kit only a CT can carry; what each one costs | |
| 4 | * is `economy.ts`'s answer for *this* body — a vest already on is a helmet at | |
| 5 | * helmet price — and whether a tile can be pressed is the wallet and the round | |
| 6 | * together, which is the same `canBuy` a buy itself is refused by. Drawn from | |
| 7 | * scratch every time it opens and again after every press, so the prices and | |
| 8 | * the money in the heading are the prices and the money right now. | |
| 9 | * | |
| 10 | * It is not a browser and it is not the frame. It takes the element to draw | |
| 11 | * into, the two things a price is worked out from — the round and the player — | |
| 12 | * and the two calls it cannot make itself: one that makes a `<button>` and one | |
| 13 | * that says which item was pressed. `main.ts` is the only file that knows where | |
| 14 | * that element came from; nothing here reaches for a document, a host or a | |
| 15 | * world. A press does not buy: it says what was wanted and hands it back, so | |
| 16 | * the wallet is still spent in one place — the frame, on the world it is | |
| 17 | * simulating — and a tile left over from a match that has ended cannot reach | |
| 18 | * into a match that has not begun. | |
| 19 | * | |
| 20 | * Nothing rendered here comes from outside the program: every label is a | |
| 21 | * constant of `weapons.ts` or of this file, and the only value interpolated is | |
| 22 | * a number off the player. No name, no field, nothing a person typed is in the | |
| 23 | * markup, which is why there is no escaping in it to read. | |
| 24 | */ | |
| 25 | ||
| 26 | import type { Listens } from "../engine/index.ts"; | |
| 27 | import { canBuy, priceOf, type BuyItem, type Buyer, type Market } from "./economy.ts"; | |
| 28 | import type { Team } from "./match.ts"; | |
| 29 | import { buyable } from "./weapons.ts"; | |
| 30 | ||
| 31 | /** One thing on the shelf: what a player reads, and what a press of it buys. */ | |
| 32 | export type Offer = { item: BuyItem; label: string }; | |
| 33 | ||
| 34 | /** | |
| 35 | * The element the panel is drawn into, as far as this file is concerned: the | |
| 36 | * markup it is given, and the grid it just wrote into that markup. | |
| 37 | * `unknown` because a browser's own `querySelector` is wider than any subset. | |
| 38 | */ | |
| 39 | export type Shelf = { | |
| 40 | innerHTML: string; | |
| 41 | querySelector(selectors: string): unknown; | |
| 42 | appendChild(child: unknown): void; | |
| 43 | }; | |
| 44 | ||
| 45 | /** One tile in the grid: a button, its label, whether it can be pressed. */ | |
| 46 | export type Tile = Listens & { type: string; textContent: string | null; disabled: boolean }; | |
| 47 | ||
| 48 | /** | |
| 49 | * What the page has to lend the panel: the element, a way to make a button, and | |
| 50 | * what a press means. `press` is the whole of what this module hands back — | |
| 51 | * the item wanted, once per press — and the caller decides what it costs. | |
| 52 | */ | |
| 53 | export type Counter = { | |
| 54 | panel: Shelf; | |
| 55 | /** A fresh `<button>`, unattached. `document.createElement("button")` is one. */ | |
| 56 | button(): Tile; | |
| 57 | press(item: BuyItem): void; | |
| 58 | }; | |
| 59 | ||
| 60 | /** What the panel says under the grid: the two keys that close it, and the one rule. */ | |
| 61 | export const HINT = "B or Escape closes. You can only buy in the freeze time."; | |
| 62 | ||
| 63 | /** | |
| 64 | * The shelf, in the order it is drawn: this side's guns and grenades as | |
| 65 | * `weapons.ts` lists them, then the armour, then the defuse kit — which is on | |
| 66 | * the shelf for a CT and not on it at all for a T, because a T cannot carry one | |
| 67 | * and a tile that is never pressable is a tile that should not be there. | |
| 68 | */ | |
| 69 | export function offers(team: Team): Offer[] { | |
| 70 | const guns: Offer[] = buyable(team).map((weapon) => ({ item: weapon.id, label: weapon.name })); | |
| 71 | const kit: Offer[] = team === "ct" ? [{ item: "kit", label: "Defuse kit" }] : []; | |
| 72 | return [...guns, { item: "armor", label: "Kevlar" }, { item: "helmet", label: "Kevlar + Helmet" }, ...kit]; | |
| 73 | } | |
| 74 | ||
| 75 | /** | |
| 76 | * Draw the panel: the heading with what is left to spend, one tile per offer | |
| 77 | * with the price this body pays for it, and the hint. A tile is dead when the | |
| 78 | * wallet cannot reach the price or when the round is not one a buy is allowed | |
| 79 | * in at all — the panel shows the prices either way rather than emptying | |
| 80 | * itself, because what the next freeze time costs is worth reading. | |
| 81 | */ | |
| 82 | export function drawBuy(counter: Counter, market: Market, player: Buyer): void { | |
| 83 | const open = canBuy(market, player); | |
| 84 | counter.panel.innerHTML = | |
| 85 | `<h2>Buy <small>$${player.money}</small></h2><div class="grid"></div>` + | |
| 86 | `<p class="hint">${HINT}</p>`; | |
| 87 | const grid = counter.panel.querySelector(".grid") as Shelf; | |
| 88 | for (const { item, label } of offers(player.team)) { | |
| 89 | const price = priceOf(item, player); | |
| 90 | const tile = counter.button(); | |
| 91 | tile.type = "button"; | |
| 92 | tile.textContent = `${label} · $${price}`; | |
| 93 | tile.disabled = price > player.money || !open; | |
| 94 | tile.addEventListener("click", () => counter.press(item)); | |
| 95 | grid.appendChild(tile); | |
| 96 | } | |
| 97 | } |
src/client/main.test.ts +11 −4
| @@ -2,7 +2,10 @@ | ||
| 2 | 2 | * The frame itself, booted against the stub page in `stage.test-helper.ts`: |
| 3 | 3 | * the ids it looks up, the match a click starts, how many fixed steps one frame |
| 4 | 4 | * is worth, what the buy overlay does to the input, what losing the pointer |
| 5 | * means, and what it posts to the leaderboard. Nothing here is a browser and | |
| 5 | * means, and what it posts to the leaderboard. The buy panel itself is not in | |
| 6 | * here: `buy.test.ts` holds the shelf and the prices, and what is left below is | |
| 7 | * the wiring — the keys that open and close it, the pointer it lets go, and the | |
| 8 | * money a press costs the world. Nothing here is a browser and | |
| 6 | 9 | * nothing is timed by a real clock — the test decides when a frame happens and |
| 7 | 10 | * how long it took. |
| 8 | 11 | * |
| @@ -291,24 +294,28 @@ test("the mouse turns the view, and while scoped it turns it by the zoom's share | ||
| 291 | 294 | expect(world.me.yaw).toBeCloseTo(plainFrom - 0.484, 9); |
| 292 | 295 | }); |
| 293 | 296 | |
| 294 | test("B opens the buy overlay: it is rebuilt, it lets the pointer go and it takes the focus", () => { | |
| 297 | test("B opens the buy overlay: it is drawn, it lets the pointer go and it takes the focus", () => { | |
| 295 | 298 | const stage = playing(); |
| 296 | 299 | stage.lock(true); |
| 297 | 300 | stage.press("KeyB"); |
| 298 | 301 | const buy = stage.by("buy"); |
| 299 | 302 | expect(buy.hidden).toBe(false); |
| 300 | expect(buy.innerHTML).toContain("Buy"); | |
| 303 | // What is in the panel is `buy.test.ts`'s — the shelf, the prices, which tiles | |
| 304 | // are dead. Here it is that it was drawn at all, that the pointer was let go on | |
| 305 | // purpose, and that the first tile has the focus. | |
| 301 | 306 | const grid = buy.querySelector(".grid"); |
| 302 | 307 | expect(grid?.children.length).toBeGreaterThan(4); |
| 303 | 308 | expect(grid?.children[0]?.focused).toBe(1); |
| 304 | 309 | expect(stage.document.pointerLockElement).toBe(null); |
| 305 | 310 | |
| 306 | // Buying takes the money, beeps, and the panel is rebuilt with the new prices. | |
| 311 | // A press is wired to the world this frame is simulating: the money goes, the | |
| 312 | // beep plays, and the panel is drawn again with what is left. | |
| 307 | 313 | const world = (stage.step(FRAME), worldOf(stage)); |
| 308 | 314 | const before = world?.me.money ?? 0; |
| 309 | 315 | grid?.children[0]?.fire("click"); |
| 310 | 316 | expect(world?.me.money).toBeLessThan(before); |
| 311 | 317 | expect(stage.sounds).toContain("beep"); |
| 318 | expect(buy.innerHTML).toContain(`$${world?.me.money}`); | |
| 312 | 319 | |
| 313 | 320 | // Escape closes it, and so does pressing B again. |
| 314 | 321 | stage.window.fire("keydown", { code: "Escape" }); |
src/client/main.ts +21 −27
| @@ -5,6 +5,9 @@ | ||
| 5 | 5 | * the frame's data and the engine's renderer draws that — built here, from this |
| 6 | 6 | * game's atlas and this game's meshes — and the HUD is painted on top. The page's overlays — menu, buy, pause and the summary a match ends on — are |
| 7 | 7 | * plain DOM, so they are reachable with a keyboard and big enough for a thumb. |
| 8 | * The buy panel is one of them and is not in here: `buy.ts` draws the shelf out | |
| 9 | * of the round and the wallet, and what this file keeps is the element, the keys | |
| 10 | * that open and close it, and what a press costs the world. | |
| 8 | 11 | * |
| 9 | 12 | * A match ends on a scoreboard and not on a menu. The frame that sees |
| 10 | 13 | * `match_over` stops the run and, out of the world's final state before |
| @@ -85,8 +88,9 @@ import { drawPreview, previewText } from "./preview.ts"; | ||
| 85 | 88 | import { buildScene, createScene, SKY, type Scene } from "./scene.ts"; |
| 86 | 89 | import { usePlan, usePolicy } from "./bots.ts"; |
| 87 | 90 | import { createSpectator, cycle, follow } from "./spectate.ts"; |
| 88 | import { buyable, WEAPONS } from "./weapons.ts"; | |
| 89 | import { buy, canBuy, priceOf, type BuyItem } from "./economy.ts"; | |
| 91 | import { WEAPONS } from "./weapons.ts"; | |
| 92 | import { drawBuy } from "./buy.ts"; | |
| 93 | import { buy, canBuy, type BuyItem } from "./economy.ts"; | |
| 90 | 94 | import { createWorld, eyePos, say as showMessage, update, type Player, type World } from "./world.ts"; |
| 91 | 95 | import { unpack } from "../engine/index.ts"; |
| 92 | 96 | |
| @@ -536,32 +540,22 @@ export function boot(host: Host): Frame { | ||
| 536 | 540 | } |
| 537 | 541 | |
| 538 | 542 | // --- the buy menu, rebuilt whenever it opens so the prices are honest |
| 539 | function renderBuy(): void { | |
| 543 | /** | |
| 544 | * The shelf, the prices and what a wallet can reach are `buy.ts`'s. What is | |
| 545 | * here is the wiring: the element it draws into, the button factory, and what | |
| 546 | * a press costs — the only half of it that touches the world, and the world is | |
| 547 | * read at the press rather than captured, so a tile left over from a match | |
| 548 | * that has ended spends nothing. | |
| 549 | */ | |
| 550 | const renderBuy = (): void => { | |
| 540 | 551 | if (world === null) return; |
| 541 | const me = world.me; | |
| 542 | const items: [BuyItem, string][] = [ | |
| 543 | ...buyable(me.team).map((w) => [w.id, w.name] as [BuyItem, string]), | |
| 544 | ["armor", "Kevlar"], | |
| 545 | ["helmet", "Kevlar + Helmet"], | |
| 546 | ...(me.team === "ct" ? ([["kit", "Defuse kit"]] as [BuyItem, string][]) : []), | |
| 547 | ]; | |
| 548 | buyPanel.innerHTML = | |
| 549 | `<h2>Buy <small>$${me.money}</small></h2><div class="grid"></div>` + | |
| 550 | `<p class="hint">B or Escape closes. You can only buy in the freeze time.</p>`; | |
| 551 | const grid = buyPanel.querySelector(".grid") as Panel; | |
| 552 | for (const [item, label] of items) { | |
| 553 | const price = priceOf(item, me); | |
| 554 | const button = doc.createElement("button"); | |
| 555 | button.type = "button"; | |
| 556 | button.textContent = `${label} · $${price}`; | |
| 557 | button.disabled = price > me.money || !canBuy(world, me); | |
| 558 | button.addEventListener("click", () => { | |
| 559 | if (world === null) return; | |
| 560 | if (buy(world, world.me, item)) audio.beep(700, 0.05); | |
| 561 | renderBuy(); | |
| 562 | }); | |
| 563 | grid.appendChild(button); | |
| 564 | } | |
| 552 | drawBuy({ panel: buyPanel, button: () => doc.createElement("button"), press: pressBuy }, world, world.me); | |
| 553 | }; | |
| 554 | ||
| 555 | function pressBuy(item: BuyItem): void { | |
| 556 | if (world === null) return; | |
| 557 | if (buy(world, world.me, item)) audio.beep(700, 0.05); | |
| 558 | renderBuy(); | |
| 565 | 559 | } |
| 566 | 560 | |
| 567 | 561 | function openBuy(open: boolean): void { |
src/games.ts +13
| @@ -465,6 +465,19 @@ export type Game = { | ||
| 465 | 465 | * #216 measured, and nothing outside `src/client/`, `src/engine/trace.ts` and |
| 466 | 466 | * `src/pages/game.ts` changed — the range and the example ship no world, no |
| 467 | 467 | * weapons and no cloud. Measured 2026-09-21. |
| 468 | * #250 moved the buy panel out of the entry into `src/client/buy.ts` — the | |
| 469 | * shelf, the prices and which tiles a wallet and a round leave pressable — and | |
| 470 | * the app grew 199 raw / 71 gzipped bytes for it: 98,665 → 98,864 raw and | |
| 471 | * 38,895 → 38,966 gzipped, measured on top of #165 rather than under it. It is | |
| 472 | * a move and not a feature, so those bytes are the seam itself: a module | |
| 473 | * boundary the minifier will not inline across, the object the frame lends the | |
| 474 | * panel, and the hint sentence and the shelf now named rather than written | |
| 475 | * inline. Nothing was added to the shelf to get them — the smoke #165 put in | |
| 476 | * the weapon table arrived on the panel through `buyable`, which is the seam | |
| 477 | * working rather than a line anybody wrote. It fits the 103,000 / 41,000 #165 | |
| 478 | * set with 4,136 raw and 2,034 gzipped to spare, so no ceiling moved. Neither | |
| 479 | * other bundle changed a byte: nothing outside `src/client/` did. Measured | |
| 480 | * 2026-09-21. | |
| 468 | 481 | */ |
| 469 | 482 | export const GAMES: readonly Game[] = [ |
| 470 | 483 | { |
new above
offline