Public: anyone can read this; the crew can change it.
forked from trevato/cs
Buying never takes the mouse off the game: number keys in the buy menu, and a rebuy #274
Check · Review · Land · Live done — Landed and live in production.
vesper/cs-engine:vesper/153-buying-never-takes-the-mouse-off-the-gam → main 1 commit fork
14 files
changed +753
−83
README.md +19 −5
| @@ -348,7 +348,14 @@ the body it was measured against is a rumour. | ||
| 348 | 348 | <kbd>G</kbd> throws the HE grenade in your pocket and <kbd>T</kbd> the |
| 349 | 349 | flashbang, if you bought them, |
| 350 | 350 | <kbd>E</kbd> plants or defuses, <kbd>B</kbd> opens the buy menu in the freeze |
| 351 | time, <kbd>1</kbd>/<kbd>2</kbd>/<kbd>3</kbd> switch guns — and **a switch costs CS's deploy | |
| 351 | time — **and buying never takes the mouse off the game**: the panel opens with | |
| 352 | the pointer still locked, every tile is printed with the key that buys it (the | |
| 353 | number row, <kbd>1</kbd> to <kbd>9</kbd>, then <kbd>0</kbd> and <kbd>-</kbd>, | |
| 354 | left to right), <kbd>X</kbd> buys last round's loadout again as far as the money | |
| 355 | goes, and <kbd>B</kbd> or <kbd>Esc</kbd> closes. The tiles are still buttons, so | |
| 356 | a thumb taps them and a keyboard tabs to them, and with the panel open the | |
| 357 | trigger and the weapon keys are off the player while your feet still work — | |
| 358 | <kbd>1</kbd>/<kbd>2</kbd>/<kbd>3</kbd> switch guns — and **a switch costs CS's deploy | |
| 352 | 359 | time**: the carry speed changes on the tick you press the key, so tapping |
| 353 | 360 | <kbd>3</kbd> runs at the knife's 250 u/s straight away, but the weapon you come |
| 354 | 361 | back to cannot fire, scope or reload until it is up (a pistol 0.7 s, a rifle or |
| @@ -748,10 +755,17 @@ never checked itself. | ||
| 748 | 755 | the `Ammo` a fresh gun comes with), |
| 749 | 756 | `buy` (the panel that economy is spent through: the shelf a side is shown — |
| 750 | 757 | its guns, the armour, the kit only a CT can carry — the price `economy` gives |
| 751 | for the body in front of you, and which tiles the wallet and the round leave | |
| 752 | pressable. It takes the element to draw into, the round and the player, and a | |
| 753 | call that says which item was pressed; it reaches for no document and buys | |
| 754 | nothing, so `main` spends the money in the one place that holds a world), | |
| 758 | for the body in front of you, which tiles the wallet and the round leave | |
| 759 | pressable, the key each tile is printed with, and the one control that is not | |
| 760 | an item: last round's loadout, bought again. It takes the element to draw | |
| 761 | into, the round and the player, and the calls that say what was pressed; it | |
| 762 | reaches for no document and buys nothing, so `main` spends the money in the | |
| 763 | one place that holds a world), | |
| 764 | `loadout` (what you bought, and buying it again: a round's purchases in the | |
| 765 | order they were made, rolled over on the round, and the rebuy that walks that | |
| 766 | list through `economy`'s `buy` — stepping over a gun you survived with and | |
| 767 | stopping at the first thing the money cannot reach — plus the one line it | |
| 768 | says about it), | |
| 755 | 769 | `report` (the round on its way to the leaderboard: a world turned into the |
| 756 | 770 | result the server takes, posted as the round is scored, and the one that did |
| 757 | 771 | not land kept for another attempt on the policy `pending` decides — the |
scripts/build.test.ts +1 −1
| @@ -45,7 +45,7 @@ function fixture(): { dir: string; entries: Entry[]; source: (n: string, t: stri | ||
| 45 | 45 | |
| 46 | 46 | test("the entry table is what the app ships today, each with the size it may not pass", () => { |
| 47 | 47 | expect(ENTRIES).toEqual([ |
| 48 | { name: "app", entry: "src/client/main.ts", ceiling: { raw: 103_000, gzip: 41_000 } }, | |
| 48 | { name: "app", entry: "src/client/main.ts", ceiling: { raw: 110_000, gzip: 43_500 } }, | |
| 49 | 49 | { name: "range", entry: "src/range/main.ts", ceiling: { raw: 37_000, gzip: 15_500 } }, |
| 50 | 50 | { name: "example", entry: "src/example/main.ts", ceiling: { raw: 23_500, gzip: 10_200 } }, |
| 51 | 51 | ]); |
src/client/binding.test.ts +29 −1
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | import { expect, test } from "bun:test"; |
| 2 | import { CS_KEYS, CS_MOUSE, CS_TAP, readIntent } from "./binding.ts"; | |
| 2 | import { CS_KEYS, CS_MOUSE, CS_TAP, NUMBER_ROW, readIntent, slotAction, slotIndex } from "./binding.ts"; | |
| 3 | 3 | |
| 4 | 4 | const NONE = { x: 0, y: 0 }; |
| 5 | 5 | const intent = (actions: string[], stick = NONE) => readIntent(new Set(actions), stick); |
| @@ -34,6 +34,34 @@ test("the bindings are the ones the keyboard has always had", () => { | ||
| 34 | 34 | // and no browser has a meaning of its own for it to take the window back with. |
| 35 | 35 | expect(CS_KEYS.KeyP).toBe("pause"); |
| 36 | 36 | expect(intent(["pause"])).toEqual(intent([])); |
| 37 | // X is the page's too: it buys last round's loadout again, which is money and | |
| 38 | // not movement, so nothing about an `Input` changes for it either. | |
| 39 | expect(CS_KEYS.KeyX).toBe("rebuy"); | |
| 40 | expect(intent(["rebuy"])).toEqual(intent([])); | |
| 41 | }); | |
| 42 | ||
| 43 | test("the number row is one row of keys, bound in the order it is printed", () => { | |
| 44 | // Eleven keys, left to right, and the code of each is what a browser calls | |
| 45 | // that key: the buy panel prints them on its tiles in this order. | |
| 46 | expect([...NUMBER_ROW]).toEqual(["1", "2", "3", "4", "5", "6", "7", "8", "9", "0", "-"]); | |
| 47 | const codes = ["Digit1", "Digit2", "Digit3", "Digit4", "Digit5", "Digit6", "Digit7", "Digit8", "Digit9", "Digit0", "Minus"]; | |
| 48 | expect(codes.map((code) => CS_KEYS[code])).toEqual(NUMBER_ROW.map((_, i) => slotAction(i))); | |
| 49 | // And the way back, which is how `main.ts` turns a press into the tile under | |
| 50 | // it: every one of them, and nothing else. | |
| 51 | expect(codes.map((code) => slotIndex(CS_KEYS[code] ?? ""))).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); | |
| 52 | expect(slotIndex("forward")).toBeNull(); | |
| 53 | expect(slotIndex("slot12")).toBeNull(); | |
| 54 | expect(slotIndex("slot")).toBeNull(); | |
| 55 | }); | |
| 56 | ||
| 57 | test("only the first three of the row are pockets: the rest are the panel's and the world has never heard of them", () => { | |
| 58 | expect(intent(["slot1"]).slotWanted).toBe(1); | |
| 59 | expect(intent(["slot2"]).slotWanted).toBe(2); | |
| 60 | expect(intent(["slot3"]).slotWanted).toBe(3); | |
| 61 | for (const action of ["slot4", "slot9", "slot10", "slot11"]) { | |
| 62 | expect(intent([action]).slotWanted).toBeNull(); | |
| 63 | expect(intent([action])).toEqual(intent([])); | |
| 64 | } | |
| 37 | 65 | }); |
| 38 | 66 | |
| 39 | 67 | test("nothing held is nothing asked for", () => { |
src/client/binding.ts +38 −1
| @@ -8,6 +8,37 @@ | ||
| 8 | 8 | import { clamp } from "../engine/index.ts"; |
| 9 | 9 | import { NO_INPUT, type Input } from "./world.ts"; |
| 10 | 10 | |
| 11 | /** | |
| 12 | * The number row, left to right, printed as the key prints itself. It is one | |
| 13 | * list because it is one row of keys with two meanings, and which one is meant | |
| 14 | * is the frame's question rather than the keyboard's: with the buy panel open | |
| 15 | * every key of it presses the tile it is printed on, and with the panel closed | |
| 16 | * the first three are the weapon pockets and the rest do nothing. | |
| 17 | * | |
| 18 | * Eleven, because a CT's shelf is eleven tiles — five guns, three grenades, the | |
| 19 | * vest, the vest and helmet, and the kit — and a tile with no key on it is a | |
| 20 | * tile you need the mouse for, which is the whole of what #153 was about. | |
| 21 | * `src/client/buy.ts` prints these on the tiles and `src/client/main.ts` reads | |
| 22 | * one back to an index in the same list, so the number on the screen and the | |
| 23 | * key that buys it cannot drift. | |
| 24 | */ | |
| 25 | export const NUMBER_ROW = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "0", "-"] as const; | |
| 26 | ||
| 27 | /** The `KeyboardEvent.code` of each of those keys, in the same order. */ | |
| 28 | const NUMBER_CODES = [ | |
| 29 | "Digit1", "Digit2", "Digit3", "Digit4", "Digit5", | |
| 30 | "Digit6", "Digit7", "Digit8", "Digit9", "Digit0", "Minus", | |
| 31 | ] as const; | |
| 32 | ||
| 33 | /** What the nth key of the number row presses: `slot1` is the first of them. */ | |
| 34 | export const slotAction = (index: number): string => `slot${index + 1}`; | |
| 35 | ||
| 36 | /** Which key of the number row an action is, or null for anything else. */ | |
| 37 | export function slotIndex(action: string): number | null { | |
| 38 | const at = NUMBER_ROW.findIndex((_, index) => slotAction(index) === action); | |
| 39 | return at === -1 ? null : at; | |
| 40 | } | |
| 41 | ||
| 11 | 42 | /** `KeyboardEvent.code` to action. Today's bindings, moved rather than tuned. */ |
| 12 | 43 | export const CS_KEYS: Record<string, string> = { |
| 13 | 44 | KeyW: "forward", KeyS: "back", KeyA: "left", KeyD: "right", |
| @@ -15,7 +46,7 @@ export const CS_KEYS: Record<string, string> = { | ||
| 15 | 46 | Space: "jump", ControlLeft: "duck", KeyC: "duck", ShiftLeft: "walk", |
| 16 | 47 | KeyR: "reload", KeyE: "use", KeyG: "grenade", KeyT: "flash", KeyV: "smoke", |
| 17 | 48 | KeyB: "buy", Tab: "scoreboard", |
| 18 | Digit1: "slot1", Digit2: "slot2", Digit3: "slot3", | |
| 49 | ...Object.fromEntries(NUMBER_CODES.map((code, index) => [code, slotAction(index)])), | |
| 19 | 50 | // Neither of these is a thing the world knows about: `main.ts` hears them on |
| 20 | 51 | // `onAction`, and `readIntent` below never sees either. F asks the browser for |
| 21 | 52 | // the screen; P pauses, which is what Escape means too — and P is a key no |
| @@ -23,6 +54,9 @@ export const CS_KEYS: Record<string, string> = { | ||
| 23 | 54 | // window back, which is what Escape does on a Mac. |
| 24 | 55 | KeyF: "fullscreen", |
| 25 | 56 | KeyP: "pause", |
| 57 | // Nor is this one: X buys last round's loadout again, which is money and not | |
| 58 | // movement, so `main.ts` hears it and `readIntent` never does. | |
| 59 | KeyX: "rebuy", | |
| 26 | 60 | }; |
| 27 | 61 | |
| 28 | 62 | /** Left fires, right scopes. */ |
| @@ -66,6 +100,9 @@ export function readIntent(held: ReadonlySet<string>, stick: { x: number; y: num | ||
| 66 | 100 | return { |
| 67 | 101 | input, |
| 68 | 102 | scoreboard: down("scoreboard"), |
| 103 | // Three pockets, and only three: the rest of the number row belongs to the | |
| 104 | // buy panel, and a world that has never heard of `slot4` cannot be steered | |
| 105 | // by the key that buys the fourth tile. | |
| 69 | 106 | slotWanted: down("slot3") ? 3 : down("slot2") ? 2 : down("slot1") ? 1 : null, |
| 70 | 107 | }; |
| 71 | 108 | } |
src/client/buy.test.ts +86 −25
| @@ -15,8 +15,9 @@ | ||
| 15 | 15 | */ |
| 16 | 16 | |
| 17 | 17 | import { expect, test } from "bun:test"; |
| 18 | import { NUMBER_ROW } from "./binding.ts"; | |
| 18 | 19 | import { fakePage, type FakeElement } from "../engine/index.ts"; |
| 19 | import { drawBuy, HINT, offers, type Counter } from "./buy.ts"; | |
| 20 | import { drawBuy, HINT, labelOf, offers, REBUY_LABEL, type Counter } from "./buy.ts"; | |
| 20 | 21 | import { emptyAmmo, type BuyItem, type Buyer, type Market } from "./economy.ts"; |
| 21 | 22 | import { DE_FORGE } from "./map.ts"; |
| 22 | 23 | import { SHORT } from "./match.ts"; |
| @@ -40,24 +41,39 @@ function shop(): { | ||
| 40 | 41 | panel: FakeElement; |
| 41 | 42 | counter: Counter; |
| 42 | 43 | pressed: BuyItem[]; |
| 44 | /** How many times the rebuy control was pressed. */ | |
| 45 | rebuys(): number; | |
| 43 | 46 | tiles(): FakeElement[]; |
| 47 | /** The one control under the grid: last round's loadout, bought again. */ | |
| 48 | again(): FakeElement; | |
| 44 | 49 | labels(): (string | null)[]; |
| 45 | /** The tile whose label starts with this word, which is how a test names one. */ | |
| 50 | /** Whether any tile's label holds this, whatever number it was printed with. */ | |
| 51 | says(part: string): boolean; | |
| 52 | /** The tile whose label holds this word, which is how a test names one. */ | |
| 46 | 53 | tile(label: string): FakeElement; |
| 47 | 54 | } { |
| 48 | 55 | const page = fakePage({ ids: ["buy"] }); |
| 49 | 56 | const panel = page.by("buy"); |
| 50 | 57 | const pressed: BuyItem[] = []; |
| 58 | let again = 0; | |
| 51 | 59 | const tiles = (): FakeElement[] => panel.querySelector(".grid")?.children ?? []; |
| 52 | 60 | return { |
| 53 | 61 | panel, |
| 54 | 62 | pressed, |
| 63 | rebuys: () => again, | |
| 55 | 64 | tiles, |
| 65 | again: () => { | |
| 66 | const found = panel.querySelector(".row")?.children[0]; | |
| 67 | if (found === undefined) throw new Error("no rebuy control"); | |
| 68 | return found; | |
| 69 | }, | |
| 56 | 70 | 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. | |
| 71 | says: (part) => tiles().some((tile) => tile.textContent?.includes(part) === true), | |
| 72 | // By what the label says and not by index: a weapon added to the table adds | |
| 73 | // a tile and moves every number after it, and a test about the armour should | |
| 74 | // not move when one is. | |
| 59 | 75 | tile: (label) => { |
| 60 | const found = tiles().find((tile) => tile.textContent?.startsWith(label)); | |
| 76 | const found = tiles().find((tile) => tile.textContent?.includes(label)); | |
| 61 | 77 | if (found === undefined) throw new Error(`no tile for ${label}`); |
| 62 | 78 | return found; |
| 63 | 79 | }, |
| @@ -65,6 +81,7 @@ function shop(): { | ||
| 65 | 81 | panel, |
| 66 | 82 | button: () => page.document.createElement("button"), |
| 67 | 83 | press: (item) => void pressed.push(item), |
| 84 | again: () => void (again += 1), | |
| 68 | 85 | }, |
| 69 | 86 | }; |
| 70 | 87 | } |
| @@ -91,25 +108,35 @@ test("the panel a real world in its freeze time is drawn as, to the character", | ||
| 91 | 108 | |
| 92 | 109 | expect(it.panel.innerHTML).toBe( |
| 93 | 110 | `<h2>Buy <small>$800</small></h2><div class="grid"></div>` + |
| 94 | `<p class="hint">${HINT}</p>`, | |
| 111 | `<div class="row"></div><p class="hint">${HINT}</p>`, | |
| 95 | 112 | ); |
| 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. | |
| 113 | // Eleven tiles, each a `<button>` — a press that submits nothing — printed | |
| 114 | // with the key that presses it and the price this body pays, and dead exactly | |
| 115 | // where 800 dollars cannot reach. The eleventh key is the one after 0, because | |
| 116 | // a CT's shelf is one longer than the digits and a tile with no key on it is a | |
| 117 | // tile you need the mouse for. | |
| 99 | 118 | expect(it.tiles().map((tile) => tile.type)).toEqual(Array(11).fill("button")); |
| 100 | 119 | 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], | |
| 120 | ["1 · USP-S · $200", false], | |
| 121 | ["2 · Desert Eagle · $700", false], | |
| 122 | ["3 · MP9 · $1250", true], | |
| 123 | ["4 · M4A4 · $3100", true], | |
| 124 | ["5 · AWP · $4750", true], | |
| 125 | ["6 · HE Grenade · $300", false], | |
| 126 | ["7 · Flashbang · $200", false], | |
| 127 | ["8 · Smoke Grenade · $300", false], | |
| 128 | ["9 · Kevlar · $650", false], | |
| 129 | ["0 · Kevlar + Helmet · $1000", true], | |
| 130 | ["- · Defuse kit · $400", false], | |
| 112 | 131 | ]); |
| 132 | // And the number a tile is printed with is the key `binding.ts` binds to that | |
| 133 | // index: the panel and the keyboard read one list, so they cannot disagree. | |
| 134 | expect(it.tiles().map((tile) => tile.textContent?.split(" · ")[0])) | |
| 135 | .toEqual([...NUMBER_ROW]); | |
| 136 | // Under them, the control that is not an item. Nothing was bought last round | |
| 137 | // here, so it is dead rather than live and empty. | |
| 138 | expect(it.again().textContent).toBe(REBUY_LABEL); | |
| 139 | expect(it.again().disabled).toBe(true); | |
| 113 | 140 | }); |
| 114 | 141 | |
| 115 | 142 | test("drawing again is the panel again, not a second one under it", () => { |
| @@ -127,17 +154,17 @@ test("drawing again is the panel again, not a second one under it", () => { | ||
| 127 | 154 | test("a price is for the body in front of you: a vest on is a helmet at helmet price", () => { |
| 128 | 155 | const it = shop(); |
| 129 | 156 | drawBuy(it.counter, freeze, buyer({ armor: 100 })); |
| 130 | expect(it.labels()).toContain(`Kevlar + Helmet · $${HELMET_PRICE}`); | |
| 157 | expect(it.says(`Kevlar + Helmet · $${HELMET_PRICE}`)).toBe(true); | |
| 131 | 158 | // The vest is already on, so it is priced at nothing — and a tile at nothing is |
| 132 | 159 | // pressable, although `economy.ts` refuses the buy. That is today's behaviour |
| 133 | 160 | // and this pull moved it rather than fixing it: see #250. |
| 134 | expect(it.labels()).toContain("Kevlar · $0"); | |
| 161 | expect(it.says("Kevlar · $0")).toBe(true); | |
| 135 | 162 | expect(it.tile("Kevlar ·").disabled).toBe(false); |
| 136 | 163 | // Bare, the helmet is the vest as well and costs both. |
| 137 | 164 | const bare = shop(); |
| 138 | 165 | 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}`); | |
| 166 | expect(bare.says(`Kevlar + Helmet · $${ARMOR_PRICE + HELMET_PRICE}`)).toBe(true); | |
| 167 | expect(bare.says(`Defuse kit · $${DEFUSE_KIT_PRICE}`)).toBe(true); | |
| 141 | 168 | }); |
| 142 | 169 | |
| 143 | 170 | test("a wallet that cannot reach a price is a dead tile, to the dollar", () => { |
| @@ -154,7 +181,7 @@ test("outside the freeze time every tile is dead, and the prices are still reada | ||
| 154 | 181 | drawBuy(it.counter, live, buyer({ money: 16_000 })); |
| 155 | 182 | expect(it.tiles().length).toBe(offers("ct").length); |
| 156 | 183 | expect(it.tiles().every((tile) => tile.disabled)).toBe(true); |
| 157 | expect(it.labels()).toContain(`AWP · $${WEAPONS.awp.price}`); | |
| 184 | expect(it.says(`AWP · $${WEAPONS.awp.price}`)).toBe(true); | |
| 158 | 185 | // And to a body, wherever the round is: a corpse has nothing to spend it on. |
| 159 | 186 | const dead = shop(); |
| 160 | 187 | drawBuy(dead.counter, freeze, buyer({ alive: false, money: 16_000 })); |
| @@ -176,3 +203,37 @@ test("a press hands the item back and spends nothing itself", () => { | ||
| 176 | 203 | it.tile("USP-S").fire("click"); |
| 177 | 204 | expect(it.pressed).toEqual(["usp", "helmet", "usp"]); |
| 178 | 205 | }); |
| 206 | ||
| 207 | test("the rebuy control is live only when there is a loadout and a round to buy it in", () => { | |
| 208 | const it = shop(); | |
| 209 | // Something was bought last round, and the round is one you can buy in. | |
| 210 | drawBuy(it.counter, freeze, buyer(), ["ak47", "armor"]); | |
| 211 | expect(it.again().disabled).toBe(false); | |
| 212 | it.again().fire("click"); | |
| 213 | expect(it.rebuys()).toBe(1); | |
| 214 | // It spends nothing itself, the way a tile does not: the caller buys. | |
| 215 | expect(it.pressed).toEqual([]); | |
| 216 | ||
| 217 | // The same loadout outside the freeze time is a dead control, like every tile | |
| 218 | // beside it, and so is an empty one in the freeze time. | |
| 219 | const late = shop(); | |
| 220 | drawBuy(late.counter, live, buyer(), ["ak47"]); | |
| 221 | expect(late.again().disabled).toBe(true); | |
| 222 | const nothing = shop(); | |
| 223 | drawBuy(nothing.counter, freeze, buyer(), []); | |
| 224 | expect(nothing.again().disabled).toBe(true); | |
| 225 | }); | |
| 226 | ||
| 227 | test("the hint under the grid says the keys, and an item answers to the name the shelf gave it", () => { | |
| 228 | const it = shop(); | |
| 229 | drawBuy(it.counter, freeze, buyer()); | |
| 230 | expect(it.panel.innerHTML).toContain(HINT); | |
| 231 | expect(HINT).toContain("number row"); | |
| 232 | expect(HINT).toContain("X"); | |
| 233 | expect(HINT).toContain("B or Escape closes"); | |
| 234 | // The sentence a rebuy says is built out of these, so they are the shelf's | |
| 235 | // names and not a second table of them. | |
| 236 | expect(labelOf("ak47", "t")).toBe("AK-47"); | |
| 237 | expect(labelOf("helmet", "ct")).toBe("Kevlar + Helmet"); | |
| 238 | expect(labelOf("kit", "ct")).toBe("Defuse kit"); | |
| 239 | }); |
src/client/buy.ts +61 −14
| @@ -7,10 +7,17 @@ | ||
| 7 | 7 | * scratch every time it opens and again after every press, so the prices and |
| 8 | 8 | * the money in the heading are the prices and the money right now. |
| 9 | 9 | * |
| 10 | * Every tile is printed with the key that presses it — the number row, left to | |
| 11 | * right, from `binding.ts` — because the panel is read with the mouse still on | |
| 12 | * the game: the hand that buys is the hand that is walking out of spawn, and a | |
| 13 | * tile you can only reach by pointing at it is a tile that costs a pointer | |
| 14 | * round-trip. Under them is the one control that is not an item: last round's | |
| 15 | * loadout, bought again. | |
| 16 | * | |
| 10 | 17 | * It is not a browser and it is not the frame. It takes the element to draw |
| 11 | 18 | * 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 | |
| 19 | * and the calls it cannot make itself: one that makes a `<button>`, one that | |
| 20 | * says which item was pressed, and one that says a rebuy was asked for. `main.ts` is the only file that knows where | |
| 14 | 21 | * that element came from; nothing here reaches for a document, a host or a |
| 15 | 22 | * world. A press does not buy: it says what was wanted and hands it back, so |
| 16 | 23 | * the wallet is still spent in one place — the frame, on the world it is |
| @@ -23,6 +30,7 @@ | ||
| 23 | 30 | * markup, which is why there is no escaping in it to read. |
| 24 | 31 | */ |
| 25 | 32 | |
| 33 | import { NUMBER_ROW } from "./binding.ts"; | |
| 26 | 34 | import type { Listens } from "../engine/index.ts"; |
| 27 | 35 | import { canBuy, priceOf, type BuyItem, type Buyer, type Market } from "./economy.ts"; |
| 28 | 36 | import type { Team } from "./match.ts"; |
| @@ -49,16 +57,37 @@ export type Tile = Listens & { type: string; textContent: string | null; disable | ||
| 49 | 57 | * What the page has to lend the panel: the element, a way to make a button, and |
| 50 | 58 | * what a press means. `press` is the whole of what this module hands back — |
| 51 | 59 | * the item wanted, once per press — and the caller decides what it costs. |
| 60 | * `again` is the same thing for the one control that is not an item: last | |
| 61 | * round's loadout, which the caller buys back through the same `buy()`. | |
| 52 | 62 | */ |
| 53 | 63 | export type Counter = { |
| 54 | 64 | panel: Shelf; |
| 55 | 65 | /** A fresh `<button>`, unattached. `document.createElement("button")` is one. */ |
| 56 | 66 | button(): Tile; |
| 57 | 67 | press(item: BuyItem): void; |
| 68 | again(): void; | |
| 58 | 69 | }; |
| 59 | 70 | |
| 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."; | |
| 71 | /** | |
| 72 | * What the panel says under the grid: the keys, then the one rule. It reads the | |
| 73 | * way the menu's hint line reads, and it says the number row rather than naming | |
| 74 | * eleven keys, because each tile is printed with the key that presses it. | |
| 75 | */ | |
| 76 | export const HINT = | |
| 77 | "The number row buys the tile it is printed on · X buys last round's loadout again · " + | |
| 78 | "B or Escape closes. You can only buy in the freeze time."; | |
| 79 | ||
| 80 | /** The control under the grid, and the key it answers to. */ | |
| 81 | export const REBUY_LABEL = "X · Rebuy last round"; | |
| 82 | ||
| 83 | /** The key printed on the nth tile, or "" for a shelf longer than the row. */ | |
| 84 | export const keyFor = (index: number): string => NUMBER_ROW[index] ?? ""; | |
| 85 | ||
| 86 | /** What a tile says: the key that presses it, its name, and what this body pays. */ | |
| 87 | export const tileLabel = (index: number, label: string, price: number): string => { | |
| 88 | const key = keyFor(index); | |
| 89 | return `${key === "" ? "" : `${key} · `}${label} · $${price}`; | |
| 90 | }; | |
| 62 | 91 | |
| 63 | 92 | /** |
| 64 | 93 | * The shelf, in the order it is drawn: this side's guns and grenades as |
| @@ -72,26 +101,44 @@ export function offers(team: Team): Offer[] { | ||
| 72 | 101 | return [...guns, { item: "armor", label: "Kevlar" }, { item: "helmet", label: "Kevlar + Helmet" }, ...kit]; |
| 73 | 102 | } |
| 74 | 103 | |
| 104 | /** The name this shelf gives an item, which is the name a sentence about it uses. */ | |
| 105 | export const labelOf = (item: BuyItem, team: Team): string => | |
| 106 | offers(team).find((offer) => offer.item === item)?.label ?? item; | |
| 107 | ||
| 75 | 108 | /** |
| 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. | |
| 109 | * Draw the panel: the heading with what is left to spend, one tile per offer — | |
| 110 | * numbered with the key that presses it — the rebuy control, and the hint. A | |
| 111 | * tile is dead when the wallet cannot reach the price or when the round is not | |
| 112 | * one a buy is allowed in at all — the panel shows the prices either way rather | |
| 113 | * than emptying itself, because what the next freeze time costs is worth | |
| 114 | * reading. | |
| 115 | * | |
| 116 | * `last` is what was bought the round before, and the whole of what this module | |
| 117 | * wants to know about it is whether there is any of it: a rebuy with nothing to | |
| 118 | * buy back is a dead control rather than a live one that does nothing. | |
| 81 | 119 | */ |
| 82 | export function drawBuy(counter: Counter, market: Market, player: Buyer): void { | |
| 120 | export function drawBuy(counter: Counter, market: Market, player: Buyer, last: readonly BuyItem[] = []): void { | |
| 83 | 121 | const open = canBuy(market, player); |
| 84 | 122 | counter.panel.innerHTML = |
| 85 | 123 | `<h2>Buy <small>$${player.money}</small></h2><div class="grid"></div>` + |
| 86 | `<p class="hint">${HINT}</p>`; | |
| 124 | `<div class="row"></div><p class="hint">${HINT}</p>`; | |
| 87 | 125 | const grid = counter.panel.querySelector(".grid") as Shelf; |
| 88 | for (const { item, label } of offers(player.team)) { | |
| 126 | // The index is the tile's place on the screen and the key that presses it, | |
| 127 | // read out of one list: `main.ts` turns the key back into this same index of | |
| 128 | // this same `offers`, so the number on a tile is the item it buys. | |
| 129 | offers(player.team).forEach(({ item, label }, index) => { | |
| 89 | 130 | const price = priceOf(item, player); |
| 90 | 131 | const tile = counter.button(); |
| 91 | 132 | tile.type = "button"; |
| 92 | tile.textContent = `${label} · $${price}`; | |
| 133 | tile.textContent = tileLabel(index, label, price); | |
| 93 | 134 | tile.disabled = price > player.money || !open; |
| 94 | 135 | tile.addEventListener("click", () => counter.press(item)); |
| 95 | 136 | grid.appendChild(tile); |
| 96 | } | |
| 137 | }); | |
| 138 | const again = counter.button(); | |
| 139 | again.type = "button"; | |
| 140 | again.textContent = REBUY_LABEL; | |
| 141 | again.disabled = !open || last.length === 0; | |
| 142 | again.addEventListener("click", () => counter.again()); | |
| 143 | (counter.panel.querySelector(".row") as Shelf).appendChild(again); | |
| 97 | 144 | } |
src/client/loadout.test.ts added +136
| @@ -0,0 +1,136 @@ | ||
| 1 | /** | |
| 2 | * The wallet's memory: what a round bought, what the round after buys back, and | |
| 3 | * the sentence it says about it. No page and no frame — a `Rounds` is a phase | |
| 4 | * and a round number, and a `Buyer` is the kit and the wallet, the way | |
| 5 | * `economy.test.ts` and `buy.test.ts` write them. | |
| 6 | */ | |
| 7 | ||
| 8 | import { expect, test } from "bun:test"; | |
| 9 | import { emptyAmmo, type Buyer } from "./economy.ts"; | |
| 10 | import { atRound, createLoadout, noteBuy, rebuy, saidRebuy, type Rounds } from "./loadout.ts"; | |
| 11 | import { ARMOR_PRICE, HELMET_PRICE, WEAPONS } from "./weapons.ts"; | |
| 12 | ||
| 13 | const round = (n: number, phase: "freeze" | "live" = "freeze"): Rounds => ({ match: { phase, round: n } }); | |
| 14 | ||
| 15 | const buyer = (over: Partial<Buyer> = {}): Buyer => ({ | |
| 16 | team: "t", alive: true, money: 5000, | |
| 17 | armor: 0, helmet: false, defuseKit: false, | |
| 18 | weapon: "glock", primary: null, secondary: "glock", | |
| 19 | ammo: { knife: { mag: 0, reserve: 0 }, glock: emptyAmmo("glock") }, | |
| 20 | pocket: { he: 0, flash: 0, smoke: 0 }, | |
| 21 | ...over, | |
| 22 | }); | |
| 23 | ||
| 24 | test("a round's purchases become the next round's rebuy, in the order they were made", () => { | |
| 25 | const loadout = createLoadout(); | |
| 26 | atRound(loadout, 1); | |
| 27 | noteBuy(loadout, "ak47"); | |
| 28 | noteBuy(loadout, "armor"); | |
| 29 | noteBuy(loadout, "smoke"); | |
| 30 | // Nothing to buy back yet: round one is the first round anybody bought in. | |
| 31 | expect(loadout.last).toEqual([]); | |
| 32 | ||
| 33 | atRound(loadout, 2); | |
| 34 | expect(loadout.last).toEqual(["ak47", "armor", "smoke"]); | |
| 35 | expect(loadout.bought).toEqual([]); | |
| 36 | }); | |
| 37 | ||
| 38 | test("a round nobody bought anything in is a rebuy of nothing, not of two rounds ago", () => { | |
| 39 | const loadout = createLoadout(); | |
| 40 | atRound(loadout, 1); | |
| 41 | noteBuy(loadout, "ak47"); | |
| 42 | atRound(loadout, 2); | |
| 43 | atRound(loadout, 3); | |
| 44 | expect(loadout.last).toEqual([]); | |
| 45 | expect(saidRebuy(rebuy(round(3), buyer(), loadout), "t")) | |
| 46 | .toBe("Nothing to rebuy: you bought nothing last round."); | |
| 47 | }); | |
| 48 | ||
| 49 | test("a rebuy buys last round's loadout again, through the shop, and says what it bought", () => { | |
| 50 | const loadout = createLoadout(); | |
| 51 | atRound(loadout, 1); | |
| 52 | noteBuy(loadout, "ak47"); | |
| 53 | noteBuy(loadout, "helmet"); | |
| 54 | atRound(loadout, 2); | |
| 55 | ||
| 56 | const me = buyer({ money: WEAPONS.ak47.price + ARMOR_PRICE + HELMET_PRICE }); | |
| 57 | const done = rebuy(round(2), me, loadout); | |
| 58 | expect(done.bought).toEqual(["ak47", "helmet"]); | |
| 59 | expect(done.short).toBeNull(); | |
| 60 | // The kit is the kit the shop hands over, because the shop is what handed it: | |
| 61 | // the gun in your hands, the vest on, the helmet on, and nothing left. | |
| 62 | expect(me.primary).toBe("ak47"); | |
| 63 | expect(me.helmet).toBe(true); | |
| 64 | expect(me.money).toBe(0); | |
| 65 | expect(saidRebuy(done, "t")).toBe("Rebought AK-47, Kevlar + Helmet."); | |
| 66 | // And it is this round's loadout now, so the round after buys the same again. | |
| 67 | expect(loadout.bought).toEqual(["ak47", "helmet"]); | |
| 68 | }); | |
| 69 | ||
| 70 | test("a rebuy stops where the money runs out, and says what it could not afford", () => { | |
| 71 | const loadout = createLoadout(); | |
| 72 | atRound(loadout, 1); | |
| 73 | noteBuy(loadout, "ak47"); | |
| 74 | noteBuy(loadout, "helmet"); | |
| 75 | atRound(loadout, 2); | |
| 76 | ||
| 77 | const me = buyer({ money: WEAPONS.ak47.price + 100 }); | |
| 78 | const done = rebuy(round(2), me, loadout); | |
| 79 | expect(done.bought).toEqual(["ak47"]); | |
| 80 | expect(done.short).toBe("helmet"); | |
| 81 | expect(me.money).toBe(100); | |
| 82 | expect(me.helmet).toBe(false); | |
| 83 | expect(saidRebuy(done, "t")).toBe("Rebought AK-47 — not enough for Kevlar + Helmet."); | |
| 84 | ||
| 85 | // And a wallet that cannot reach the first thing on the list buys none of it. | |
| 86 | const broke = buyer({ money: 10 }); | |
| 87 | const none = rebuy(round(2), broke, createLoadoutWith(["ak47", "armor"])); | |
| 88 | expect(none.bought).toEqual([]); | |
| 89 | expect(broke.money).toBe(10); | |
| 90 | expect(saidRebuy(none, "t")).toBe("Nothing rebought — not enough for AK-47."); | |
| 91 | }); | |
| 92 | ||
| 93 | /** A loadout that has already played a round: `last` is this list. */ | |
| 94 | function createLoadoutWith(last: ("ak47" | "armor")[]) { | |
| 95 | const loadout = createLoadout(); | |
| 96 | atRound(loadout, 1); | |
| 97 | for (const item of last) noteBuy(loadout, item); | |
| 98 | atRound(loadout, 2); | |
| 99 | return loadout; | |
| 100 | } | |
| 101 | ||
| 102 | test("a gun you survived the round with is not bought twice", () => { | |
| 103 | const loadout = createLoadoutWith(["ak47", "armor"]); | |
| 104 | // Still holding the rifle, still wearing the vest: a rebuy is a no-op that | |
| 105 | // spends nothing, and it says so rather than pretending it bought something. | |
| 106 | const me = buyer({ money: 5000, primary: "ak47", armor: 100 }); | |
| 107 | const done = rebuy(round(2), me, loadout); | |
| 108 | expect(done.bought).toEqual([]); | |
| 109 | expect(done.short).toBeNull(); | |
| 110 | expect(me.money).toBe(5000); | |
| 111 | expect(saidRebuy(done, "t")).toBe("Nothing to rebuy: you still have last round's kit."); | |
| 112 | }); | |
| 113 | ||
| 114 | test("armour you already have is stepped over rather than stopped at", () => { | |
| 115 | const loadout = createLoadout(); | |
| 116 | atRound(loadout, 1); | |
| 117 | noteBuy(loadout, "armor"); | |
| 118 | noteBuy(loadout, "ak47"); | |
| 119 | atRound(loadout, 2); | |
| 120 | // The vest survived the round and the rifle did not: the vest is refused by | |
| 121 | // the shop, which is not the money running out, so the rifle is still bought. | |
| 122 | const me = buyer({ money: WEAPONS.ak47.price, armor: 100 }); | |
| 123 | const done = rebuy(round(2), me, loadout); | |
| 124 | expect(done.bought).toEqual(["ak47"]); | |
| 125 | expect(me.primary).toBe("ak47"); | |
| 126 | }); | |
| 127 | ||
| 128 | test("a rebuy outside the freeze time, or dead, buys nothing at all", () => { | |
| 129 | const loadout = createLoadoutWith(["ak47"]); | |
| 130 | const me = buyer({ money: 5000 }); | |
| 131 | expect(rebuy(round(2, "live"), me, loadout).bought).toEqual([]); | |
| 132 | expect(me.money).toBe(5000); | |
| 133 | const dead = buyer({ money: 5000, alive: false }); | |
| 134 | expect(rebuy(round(2), dead, createLoadoutWith(["ak47"])).bought).toEqual([]); | |
| 135 | expect(dead.money).toBe(5000); | |
| 136 | }); |
src/client/loadout.ts added +115
| @@ -0,0 +1,115 @@ | ||
| 1 | /** | |
| 2 | * What you bought, and buying it again. A round's purchases are written down in | |
| 3 | * the order they were made; the round after, that list is what one key buys | |
| 4 | * back — the same items in the same order, as far as the money goes. | |
| 5 | * | |
| 6 | * It spends through `buy()` in `economy.ts` and through nothing else: a rebuy is | |
| 7 | * the same purchases a player would have made by hand, made faster, so every | |
| 8 | * refusal the shop already has — the phase, the wallet, a pocket that is full, a | |
| 9 | * kit a T cannot carry — is the refusal a rebuy gets. What is added here is one | |
| 10 | * rule of its own: a gun you are still holding is not bought twice, because you | |
| 11 | * survived the round with it and money for a second copy of it is money burnt. | |
| 12 | * | |
| 13 | * It knows nothing about a page. The frame tells it which round it is on, the | |
| 14 | * panel tells it what was pressed, and what comes back is a plain result and a | |
| 15 | * sentence — which the frame shows in the HUD's message line and the panel | |
| 16 | * announces, and neither of them composes itself. | |
| 17 | */ | |
| 18 | ||
| 19 | import { labelOf } from "./buy.ts"; | |
| 20 | import { buy, canBuy, priceOf, type BuyItem, type Buyer, type Market } from "./economy.ts"; | |
| 21 | import type { Team } from "./match.ts"; | |
| 22 | ||
| 23 | /** | |
| 24 | * The round a rebuy happens in: whether the shop is open, which is `Market`, and | |
| 25 | * which round it is, which is what makes last round's list last round's. A | |
| 26 | * `World` satisfies it, and this file cannot reach back for one. | |
| 27 | */ | |
| 28 | export type Rounds = Market & { match: { round: number } }; | |
| 29 | ||
| 30 | /** What was bought this round, what was bought the round before, and which round that is. */ | |
| 31 | export type Loadout = { | |
| 32 | /** The round `bought` is being written for. */ | |
| 33 | round: number; | |
| 34 | /** This round's purchases, oldest first. */ | |
| 35 | bought: BuyItem[]; | |
| 36 | /** The round before's, which is what a rebuy buys back. */ | |
| 37 | last: BuyItem[]; | |
| 38 | }; | |
| 39 | ||
| 40 | export const createLoadout = (): Loadout => ({ round: 0, bought: [], last: [] }); | |
| 41 | ||
| 42 | /** | |
| 43 | * A round has begun. What was bought in the one before it becomes the rebuy and | |
| 44 | * the new round starts with an empty list — called every frame, because a round | |
| 45 | * where nothing was bought is a rebuy of nothing and a memory that rolled over | |
| 46 | * only when somebody bought something would hand out a two-round-old loadout. | |
| 47 | */ | |
| 48 | export function atRound(loadout: Loadout, round: number): void { | |
| 49 | if (round === loadout.round) return; | |
| 50 | loadout.last = loadout.bought; | |
| 51 | loadout.bought = []; | |
| 52 | loadout.round = round; | |
| 53 | } | |
| 54 | ||
| 55 | /** One purchase that went through, written down in the order it was made. */ | |
| 56 | export function noteBuy(loadout: Loadout, item: BuyItem): void { | |
| 57 | loadout.bought.push(item); | |
| 58 | } | |
| 59 | ||
| 60 | /** What a rebuy did: what it bought, what the money ran out on, and what it had to buy. */ | |
| 61 | export type Rebought = { | |
| 62 | /** The items bought back, in the order they were bought the first time. */ | |
| 63 | bought: BuyItem[]; | |
| 64 | /** The first item the wallet could not reach, which is where it stopped. */ | |
| 65 | short: BuyItem | null; | |
| 66 | /** How many items last round's loadout held, so "nothing to rebuy" can say so. */ | |
| 67 | had: number; | |
| 68 | }; | |
| 69 | ||
| 70 | /** Do you already hold this gun? A second one of it is money for nothing. */ | |
| 71 | const holds = (player: Buyer, item: BuyItem): boolean => | |
| 72 | player.primary === item || player.secondary === item; | |
| 73 | ||
| 74 | /** | |
| 75 | * Buy last round's loadout again, in the order it was bought, and stop at the | |
| 76 | * first thing the money cannot reach — which is the order that gets the gun | |
| 77 | * before the vest, because that is the order a player bought them in. | |
| 78 | * | |
| 79 | * Anything else the shop refuses — armour already on, a pocket already holding | |
| 80 | * its grenade — is stepped over rather than stopped at: you have it, which is | |
| 81 | * not a reason to abandon the rest of the list. | |
| 82 | */ | |
| 83 | export function rebuy(market: Rounds, player: Buyer, loadout: Loadout): Rebought { | |
| 84 | atRound(loadout, market.match.round); | |
| 85 | const done: Rebought = { bought: [], short: null, had: loadout.last.length }; | |
| 86 | if (!canBuy(market, player)) return done; | |
| 87 | for (const item of loadout.last) { | |
| 88 | if (holds(player, item)) continue; | |
| 89 | if (buy(market, player, item)) { | |
| 90 | loadout.bought.push(item); | |
| 91 | done.bought.push(item); | |
| 92 | continue; | |
| 93 | } | |
| 94 | // It did not happen. The wallet is why when the price is out of reach, and | |
| 95 | // that is where a rebuy stops; anything else is a thing you already have. | |
| 96 | if (priceOf(item, player) > player.money) { | |
| 97 | done.short = item; | |
| 98 | break; | |
| 99 | } | |
| 100 | } | |
| 101 | return done; | |
| 102 | } | |
| 103 | ||
| 104 | /** The one line a rebuy says: what it bought, or what it could not afford. */ | |
| 105 | export function saidRebuy({ bought, short, had }: Rebought, team: Team): string { | |
| 106 | const names = bought.map((item) => labelOf(item, team)).join(", "); | |
| 107 | if (short !== null) | |
| 108 | return bought.length === 0 | |
| 109 | ? `Nothing rebought — not enough for ${labelOf(short, team)}.` | |
| 110 | : `Rebought ${names} — not enough for ${labelOf(short, team)}.`; | |
| 111 | if (bought.length > 0) return `Rebought ${names}.`; | |
| 112 | return had === 0 | |
| 113 | ? "Nothing to rebuy: you bought nothing last round." | |
| 114 | : "Nothing to rebuy: you still have last round's kit."; | |
| 115 | } |
src/client/main.test.ts +111 −13
| @@ -32,6 +32,7 @@ import { expect, test } from "bun:test"; | ||
| 32 | 32 | import { generateLevel, nameOf, seedFrom, seedOf } from "./generate.ts"; |
| 33 | 33 | import type { Level } from "./level.ts"; |
| 34 | 34 | import { boot, COPIED, gameMeshes, NOT_AN_ID, NOT_A_MAP, NOT_COPIED, type Frame } from "./main.ts"; |
| 35 | import { HINT } from "./buy.ts"; | |
| 35 | 36 | import { DE_FORGE } from "./map.ts"; |
| 36 | 37 | import { endRound, SHORT } from "./match.ts"; |
| 37 | 38 | import { LAYERS, materialTextures } from "./materials.ts"; |
| @@ -295,34 +296,127 @@ test("the mouse turns the view, and while scoped it turns it by the zoom's share | ||
| 295 | 296 | expect(world.me.yaw).toBeCloseTo(plainFrom - 0.484, 9); |
| 296 | 297 | }); |
| 297 | 298 | |
| 298 | test("B opens the buy overlay: it is drawn, it lets the pointer go and it takes the focus", () => { | |
| 299 | test("B opens the buy overlay: it is drawn, it keeps the pointer and the shelf takes the focus", () => { | |
| 299 | 300 | const stage = playing(); |
| 300 | 301 | stage.lock(true); |
| 301 | 302 | stage.press("KeyB"); |
| 302 | 303 | const buy = stage.by("buy"); |
| 304 | const shelf = stage.by("buyshelf"); | |
| 303 | 305 | expect(buy.hidden).toBe(false); |
| 304 | 306 | // What is in the panel is `buy.test.ts`'s — the shelf, the prices, which tiles |
| 305 | // are dead. Here it is that it was drawn at all, that the pointer was let go on | |
| 306 | // purpose, and that the first tile has the focus. | |
| 307 | const grid = buy.querySelector(".grid"); | |
| 307 | // are dead. Here it is that it was drawn at all, that the pointer is still the | |
| 308 | // game's, and that the focus is on the shelf rather than on a tile: a focused | |
| 309 | // tile is a tile Space presses, and Space is jump. | |
| 310 | const grid = shelf.querySelector(".grid"); | |
| 308 | 311 | expect(grid?.children.length).toBeGreaterThan(4); |
| 309 | expect(grid?.children[0]?.focused).toBe(1); | |
| 310 | expect(stage.document.pointerLockElement).toBe(null); | |
| 312 | expect(grid?.children[0]?.focused).toBe(0); | |
| 313 | expect(shelf.focused).toBe(1); | |
| 314 | expect(stage.document.pointerLockElement).toBe(stage.scene); | |
| 315 | // And the line under it says what opened and what the keys are, in the live | |
| 316 | // region the page renders: with no cursor to move into the panel, this is what | |
| 317 | // a screen reader is told instead. | |
| 318 | const world = (stage.step(FRAME), worldOf(stage)); | |
| 319 | expect(stage.by("buysays").textContent).toContain(`$${world?.me.money}`); | |
| 320 | expect(stage.by("buysays").textContent).toContain(HINT); | |
| 311 | 321 | |
| 312 | 322 | // A press is wired to the world this frame is simulating: the money goes, the |
| 313 | 323 | // beep plays, and the panel is drawn again with what is left. |
| 314 | const world = (stage.step(FRAME), worldOf(stage)); | |
| 315 | 324 | const before = world?.me.money ?? 0; |
| 316 | 325 | grid?.children[0]?.fire("click"); |
| 317 | 326 | expect(world?.me.money).toBeLessThan(before); |
| 318 | 327 | expect(stage.sounds).toContain("beep"); |
| 319 | expect(buy.innerHTML).toContain(`$${world?.me.money}`); | |
| 328 | expect(shelf.innerHTML).toContain(`$${world?.me.money}`); | |
| 320 | 329 | |
| 321 | // Escape closes it, and so does pressing B again. | |
| 330 | // Escape closes it, and so does pressing B again — and neither touches the | |
| 331 | // pointer, which is still on the game it never left. | |
| 322 | 332 | stage.window.fire("keydown", { code: "Escape" }); |
| 323 | 333 | expect(buy.hidden).toBe(true); |
| 324 | 334 | stage.press("KeyB"); |
| 325 | 335 | expect(buy.hidden).toBe(false); |
| 336 | expect(stage.document.pointerLockElement).toBe(stage.scene); | |
| 337 | expect(stage.scene.locks).toBe(1); | |
| 338 | }); | |
| 339 | ||
| 340 | test("a number key buys the tile it is printed on, and one the wallet cannot reach buys nothing", () => { | |
| 341 | const stage = playing(); | |
| 342 | stage.lock(true); | |
| 343 | stage.step(FRAME); | |
| 344 | const world = worldOf(stage); | |
| 345 | if (world === null) throw new Error("the first frame drew nothing"); | |
| 346 | stage.press("KeyB"); | |
| 347 | const shelf = stage.by("buyshelf"); | |
| 348 | const tiles = () => shelf.querySelector(".grid")?.children ?? []; | |
| 349 | // The fifth tile is the AWP at $4750 and the freeze-time wallet is $800: the | |
| 350 | // key for it is a key that does nothing, which is what a dead tile is. | |
| 351 | expect(tiles()[4]?.textContent).toContain("5 · "); | |
| 352 | const before = world.me.money; | |
| 353 | stage.press("Digit5"); | |
| 354 | expect(world.me.money).toBe(before); | |
| 355 | expect(world.me.primary).toBeNull(); | |
| 356 | ||
| 357 | // The first is this side's pistol, and the number buys exactly it: the same | |
| 358 | // money, the same kit and the same beep a click on that tile costs. | |
| 359 | const [key, name] = (tiles()[0]?.textContent ?? "").split(" · "); | |
| 360 | expect(key).toBe("1"); | |
| 361 | stage.press("Digit1"); | |
| 362 | expect(world.me.money).toBeLessThan(before); | |
| 363 | expect(stage.by("buysays").textContent).toBe(`Bought ${name}. $${world.me.money} left.`); | |
| 364 | expect(stage.sounds).toContain("beep"); | |
| 365 | ||
| 366 | // And the key is the panel's only while the panel is up: with it closed the | |
| 367 | // same key is the weapon pocket it has always been, and buys nothing. | |
| 368 | const spent = world.me.money; | |
| 369 | stage.press("KeyB"); | |
| 370 | expect(stage.by("buy").hidden).toBe(true); | |
| 371 | stage.press("Digit1"); | |
| 372 | expect(world.me.money).toBe(spent); | |
| 373 | }); | |
| 374 | ||
| 375 | test("X buys last round's loadout again, in order, and stops when the money runs out", () => { | |
| 376 | const stage = playing(); | |
| 377 | stage.lock(true); | |
| 378 | stage.step(FRAME); | |
| 379 | const world = worldOf(stage); | |
| 380 | if (world === null) throw new Error("the first frame drew nothing"); | |
| 381 | const me = world.me; | |
| 382 | // Round one: a rifle and a vest, bought with the keys, in that order. | |
| 383 | me.money = 10_000; | |
| 384 | stage.press("KeyB"); | |
| 385 | const rifle = me.team === "t" ? "ak47" : "m4a4"; | |
| 386 | stage.press("Digit4"); | |
| 387 | stage.press("Digit9"); | |
| 388 | expect(me.primary).toBe(rifle); | |
| 389 | expect(me.armor).toBeGreaterThan(0); | |
| 390 | stage.press("KeyB"); | |
| 391 | ||
| 392 | // A round later, with nothing left of it: the same list, in the same order. | |
| 393 | // The round is scored through the match's own rules and the frames after it | |
| 394 | // carry the end delay, so the world under test sees a real next freeze time. | |
| 395 | endRound(world.match, me.team === "t" ? "ct" : "t", "elimination"); | |
| 396 | for (let frame = 0; frame < 60 && world.match.round === 1; frame++) stage.step(250); | |
| 397 | expect(world.match.round).toBe(2); | |
| 398 | expect(world.match.phase).toBe("freeze"); | |
| 399 | me.primary = null; | |
| 400 | me.armor = 0; | |
| 401 | me.money = WEAPONS[rifle].price + 100; | |
| 402 | stage.press("KeyX"); | |
| 403 | expect(world.me.primary).toBe(rifle); | |
| 404 | // The vest was next and the wallet could not reach it, so the line says so — | |
| 405 | // in the HUD's message line, where a player who never opened the panel is | |
| 406 | // looking, and in the panel's own line. | |
| 407 | expect(me.armor).toBe(0); | |
| 408 | const said = world.messages[world.messages.length - 1]?.text ?? ""; | |
| 409 | expect(said).toContain("Rebought"); | |
| 410 | expect(said).toContain("not enough for Kevlar"); | |
| 411 | expect(stage.by("buysays").textContent).toBe(said); | |
| 412 | expect(stage.sounds).toContain("beep"); | |
| 413 | ||
| 414 | // And a rebuy outside the freeze time is not a rebuy: `canBuy` is the one | |
| 415 | // gate, whether the panel is open or the key was pressed on its own. | |
| 416 | world.match.phase = "live"; | |
| 417 | const left = me.money; | |
| 418 | stage.press("KeyX"); | |
| 419 | expect(me.money).toBe(left); | |
| 326 | 420 | }); |
| 327 | 421 | |
| 328 | 422 | test("the buy overlay open suppresses fire, but not movement", () => { |
| @@ -353,7 +447,7 @@ test("the buy overlay open suppresses fire, but not movement", () => { | ||
| 353 | 447 | expect(stage.sounds.some((s) => s.startsWith("shot:"))).toBe(true); |
| 354 | 448 | }); |
| 355 | 449 | |
| 356 | test("losing the pointer pauses a running match, and does not while the buy panel is open", () => { | |
| 450 | test("losing the pointer pauses a running match, buy panel open or not", () => { | |
| 357 | 451 | const stage = playing(); |
| 358 | 452 | stage.lock(true); |
| 359 | 453 | stage.lock(false); |
| @@ -364,12 +458,16 @@ test("losing the pointer pauses a running match, and does not while the buy pane | ||
| 364 | 458 | expect(stage.by("pause").hidden).toBe(true); |
| 365 | 459 | expect(stage.scene.locks).toBe(2); |
| 366 | 460 | |
| 367 | // Opening the buy menu drops the lock on purpose: that is not a pause. | |
| 461 | // The buy menu no longer drops the lock, so a lock that goes while it is open | |
| 462 | // is a real loss — alt-tab, a browser policy — and the match stops behind the | |
| 463 | // panel, which closes rather than sitting over the pause. | |
| 368 | 464 | stage.lock(true); |
| 369 | 465 | stage.press("KeyB"); |
| 370 | stage.lock(false); | |
| 371 | 466 | expect(stage.by("buy").hidden).toBe(false); |
| 372 | expect(stage.by("pause").hidden).toBe(true); | |
| 467 | expect(stage.document.pointerLockElement).toBe(stage.scene); | |
| 468 | stage.lock(false); | |
| 469 | expect(stage.by("buy").hidden).toBe(true); | |
| 470 | expect(stage.by("pause").hidden).toBe(false); | |
| 373 | 471 | }); |
| 374 | 472 | |
| 375 | 473 | test("Escape pauses and keeps the screen where the browser hands the key over", async () => { |
src/client/main.ts +111 −16
| @@ -7,7 +7,10 @@ | ||
| 7 | 7 | * plain DOM, so they are reachable with a keyboard and big enough for a thumb. |
| 8 | 8 | * The buy panel is one of them and is not in here: `buy.ts` draws the shelf out |
| 9 | 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. | |
| 10 | * that open and close it, and what a press costs the world. It opens without | |
| 11 | * letting the pointer go — the number row buys the tile it is printed on and X | |
| 12 | * buys last round's loadout again (`loadout.ts`), so a round is bought blind, | |
| 13 | * walking, with the mouse still on the game. | |
| 11 | 14 | * |
| 12 | 15 | * A match ends on a scoreboard and not on a menu. The frame that sees |
| 13 | 16 | * `match_over` stops the run and, out of the world's final state before |
| @@ -64,7 +67,7 @@ | ||
| 64 | 67 | */ |
| 65 | 68 | |
| 66 | 69 | import { createAudio, type Audio } from "./audio.ts"; |
| 67 | import { CS_KEYS, CS_MOUSE, CS_TAP, readIntent } from "./binding.ts"; | |
| 70 | import { CS_KEYS, CS_MOUSE, CS_TAP, readIntent, slotIndex } from "./binding.ts"; | |
| 68 | 71 | import { drawHud, type Paint } from "./hud.ts"; |
| 69 | 72 | import { |
| 70 | 73 | attachButtons, clamp, createControls, createEscape, createFullscreen, createLoop, createRenderer, |
| @@ -92,8 +95,9 @@ import { buildScene, createScene, SKY, type Scene } from "./scene.ts"; | ||
| 92 | 95 | import { usePlan, usePolicy } from "./bots.ts"; |
| 93 | 96 | import { createSpectator, cycle, follow } from "./spectate.ts"; |
| 94 | 97 | import { WEAPONS } from "./weapons.ts"; |
| 95 | import { drawBuy } from "./buy.ts"; | |
| 96 | import { buy, canBuy, type BuyItem } from "./economy.ts"; | |
| 98 | import { drawBuy, HINT, labelOf, offers } from "./buy.ts"; | |
| 99 | import { buy, canBuy, priceOf, type BuyItem } from "./economy.ts"; | |
| 100 | import { atRound, createLoadout, noteBuy, rebuy, saidRebuy } from "./loadout.ts"; | |
| 97 | 101 | import { createWorld, eyePos, say as showMessage, update, type Player, type World } from "./world.ts"; |
| 98 | 102 | import { unpack } from "../engine/index.ts"; |
| 99 | 103 | |
| @@ -313,6 +317,14 @@ export function boot(host: Host): Frame { | ||
| 313 | 317 | const menu = by("menu"); |
| 314 | 318 | const pause = by("pause"); |
| 315 | 319 | const buyPanel = by("buy"); |
| 320 | // The two halves of that panel the page renders and the client fills: the | |
| 321 | // element `buy.ts` draws the shelf into — which takes the focus when the | |
| 322 | // panel opens, so Tab from there is the first tile — and the line under it, | |
| 323 | // which is a live region and so is *announced*: with the pointer kept on the | |
| 324 | // game there is no cursor to move into the panel, and this is what a screen | |
| 325 | // reader hears instead. | |
| 326 | const buyShelf = by("buyshelf"); | |
| 327 | const buySays = by("buysays"); | |
| 316 | 328 | const touchPanel = by("touch"); |
| 317 | 329 | const stats = by("stats"); |
| 318 | 330 | // The one line about whether what you played is on that board. It is a live |
| @@ -557,6 +569,14 @@ export function boot(host: Host): Frame { | ||
| 557 | 569 | } |
| 558 | 570 | |
| 559 | 571 | // --- the buy menu, rebuilt whenever it opens so the prices are honest |
| 572 | /** | |
| 573 | * What you bought this round and the round before, which is what X buys back. | |
| 574 | * It is per match — `start` below hands out a fresh one — and the frame rolls | |
| 575 | * it over on the round, because a round nobody bought anything in is a rebuy | |
| 576 | * of nothing rather than a rebuy of two rounds ago. | |
| 577 | */ | |
| 578 | let loadout = createLoadout(); | |
| 579 | ||
| 560 | 580 | /** |
| 561 | 581 | * The shelf, the prices and what a wallet can reach are `buy.ts`'s. What is |
| 562 | 582 | * here is the wiring: the element it draws into, the button factory, and what |
| @@ -566,26 +586,80 @@ export function boot(host: Host): Frame { | ||
| 566 | 586 | */ |
| 567 | 587 | const renderBuy = (): void => { |
| 568 | 588 | if (world === null) return; |
| 569 | drawBuy({ panel: buyPanel, button: () => doc.createElement("button"), press: pressBuy }, world, world.me); | |
| 589 | drawBuy( | |
| 590 | { panel: buyShelf, button: () => doc.createElement("button"), press: pressBuy, again: pressRebuy }, | |
| 591 | world, | |
| 592 | world.me, | |
| 593 | loadout.last, | |
| 594 | ); | |
| 570 | 595 | }; |
| 571 | 596 | |
| 597 | /** What the panel's live region says, which is the only announcement it makes. */ | |
| 598 | const sayBuy = (line: string): void => void (buySays.textContent = line); | |
| 599 | ||
| 600 | /** | |
| 601 | * One purchase, however it was asked for: a tile pressed, a key of the number | |
| 602 | * row, or a rebuy stepping through last round's list. `buy()` is the one place | |
| 603 | * money is spent, and this is the one place that calls it. | |
| 604 | */ | |
| 572 | 605 | function pressBuy(item: BuyItem): void { |
| 573 | 606 | if (world === null) return; |
| 574 | if (buy(world, world.me, item)) audio.beep(700, 0.05); | |
| 607 | const me = world.me; | |
| 608 | if (buy(world, me, item)) { | |
| 609 | noteBuy(loadout, item); | |
| 610 | audio.beep(700, 0.05); | |
| 611 | sayBuy(`Bought ${labelOf(item, me.team)}. $${me.money} left.`); | |
| 612 | } else { | |
| 613 | // A key for a tile the wallet cannot reach buys nothing and costs nothing: | |
| 614 | // the panel says why, the way a dead tile says it by being dead. | |
| 615 | sayBuy(`No ${labelOf(item, me.team)}: it costs $${priceOf(item, me)} and you have $${me.money}.`); | |
| 616 | } | |
| 575 | 617 | renderBuy(); |
| 576 | 618 | } |
| 577 | 619 | |
| 620 | /** | |
| 621 | * A key of the number row while the panel is open: the tile it is printed on. | |
| 622 | * The index is read back out of the same `offers` the panel drew itself from, | |
| 623 | * so the number on the screen and the item bought are one list. | |
| 624 | */ | |
| 625 | function buyTile(index: number): void { | |
| 626 | if (world === null || buyPanel.hidden) return; | |
| 627 | const offer = offers(world.me.team)[index]; | |
| 628 | if (offer !== undefined) pressBuy(offer.item); | |
| 629 | } | |
| 630 | ||
| 631 | /** | |
| 632 | * X: last round's loadout again, as far as the money goes. It is the same key | |
| 633 | * whether the panel is open or not — buying blind is the point — and it says | |
| 634 | * what it did in the HUD's message line, where a player who never opened the | |
| 635 | * panel is looking, and in the panel's live region for a screen reader. | |
| 636 | */ | |
| 637 | function pressRebuy(): void { | |
| 638 | if (world === null || !running || !canBuy(world, world.me)) return; | |
| 639 | const done = rebuy(world, world.me, loadout); | |
| 640 | if (done.bought.length > 0) audio.beep(700, 0.05); | |
| 641 | const line = saidRebuy(done, world.me.team); | |
| 642 | showMessage(world, line); | |
| 643 | sayBuy(line); | |
| 644 | if (!buyPanel.hidden) renderBuy(); | |
| 645 | } | |
| 646 | ||
| 647 | /** | |
| 648 | * Open or close the shop. The pointer is not touched either way, which is the | |
| 649 | * whole of #153: the mouse stays on the game, the keys do the buying, and a | |
| 650 | * round no longer costs two trips to the desktop. The focus goes to the shelf | |
| 651 | * rather than to a tile — a focused tile is a tile that Space and Enter press, | |
| 652 | * and Space is jump — so a screen reader is told what opened by the live | |
| 653 | * region under it, and Tab from there is the first thing on the shelf. | |
| 654 | */ | |
| 578 | 655 | function openBuy(open: boolean): void { |
| 579 | 656 | if (world === null) return; |
| 580 | 657 | if (open && !canBuy(world, world.me)) return; |
| 581 | 658 | show(buyPanel, open); |
| 582 | if (open) { | |
| 583 | renderBuy(); | |
| 584 | doc.exitPointerLock(); | |
| 585 | (buyPanel.querySelector("button") as Panel | null)?.focus(); | |
| 586 | } else if (running) { | |
| 587 | void sceneCanvas.requestPointerLock(); | |
| 588 | } | |
| 659 | if (!open) return; | |
| 660 | renderBuy(); | |
| 661 | sayBuy(`Buy menu: $${world.me.money} to spend. ${HINT}`); | |
| 662 | buyShelf.focus(); | |
| 589 | 663 | } |
| 590 | 664 | |
| 591 | 665 | /** |
| @@ -624,8 +698,15 @@ export function boot(host: Host): Frame { | ||
| 624 | 698 | controls.onAction = (action, down) => { |
| 625 | 699 | if (!down) return; |
| 626 | 700 | if (action === "buy") return openBuy(buyPanel.hidden); |
| 701 | if (action === "rebuy") return pressRebuy(); | |
| 627 | 702 | if (action === "pause") return pauseKey(); |
| 628 | 703 | if (action === "fullscreen") return askFull(); |
| 704 | // The number row, while the shop is open: one key, one tile. The same keys | |
| 705 | // are the weapon pockets with the panel closed, and `readIntent` is what | |
| 706 | // does that — the frame suppresses the switch while the panel is up, so a | |
| 707 | // key that buys the fourth tile is not also a hand reaching for a gun. | |
| 708 | const tile = slotIndex(action); | |
| 709 | if (tile !== null && !buyPanel.hidden) return buyTile(tile); | |
| 629 | 710 | // Dead, the trigger is the only thing left to press: it walks the camera |
| 630 | 711 | // along your side, and the aim button walks it back. The touch pad's fire |
| 631 | 712 | // is the same action, so a phone gets it without a button of its own. |
| @@ -635,9 +716,13 @@ export function boot(host: Host): Frame { | ||
| 635 | 716 | }; |
| 636 | 717 | // The pointer went away and we are still in a match: however it went — Escape |
| 637 | 718 | // on a browser that keeps the key, a window losing focus, a browser policy — |
| 638 | // the match stops rather than playing on without a mouse. | |
| 719 | // the match stops rather than playing on without a mouse. The buy panel is no | |
| 720 | // longer one of the ways it goes, so a lock lost while the shop is open is a | |
| 721 | // real loss: the shop closes and the match stops behind it. | |
| 639 | 722 | controls.onUnlock = () => { |
| 640 | if (running && buyPanel.hidden) pauseMatch(); | |
| 723 | if (!running) return; | |
| 724 | show(buyPanel, false); | |
| 725 | pauseMatch(); | |
| 641 | 726 | }; |
| 642 | 727 | |
| 643 | 728 | // --- results, sent as the match goes rather than only at the end |
| @@ -864,6 +949,9 @@ export function boot(host: Host): Frame { | ||
| 864 | 949 | pauseId.textContent = `Match ${matchId} on ${level.name}.`; |
| 865 | 950 | share.setAttribute("href", `/?match=${matchId}&map=${choice}`); |
| 866 | 951 | world = createWorld(level, rules, name, team, 5, seed); |
| 952 | // A fresh match buys from scratch: the last match's loadout is not this | |
| 953 | // match's first rebuy. | |
| 954 | loadout = createLoadout(); | |
| 867 | 955 | plannedRound = 0; |
| 868 | 956 | posted = -1; |
| 869 | 957 | postedFinal = false; |
| @@ -1068,6 +1156,9 @@ export function boot(host: Host): Frame { | ||
| 1068 | 1156 | // A round nobody has planned yet — the first of a match, or the one that |
| 1069 | 1157 | // just started — is planned here, on the frame that noticed it. |
| 1070 | 1158 | if (world.match.round !== plannedRound) planFor(world); |
| 1159 | // And the same frame rolls the wallet's memory over: what was bought in the | |
| 1160 | // round that just ended is what X buys back in this one. | |
| 1161 | atRound(loadout, world.match.round); | |
| 1071 | 1162 | |
| 1072 | 1163 | const me = world.me; |
| 1073 | 1164 | const [dx, dy] = controls.takeLook(); |
| @@ -1089,8 +1180,12 @@ export function boot(host: Host): Frame { | ||
| 1089 | 1180 | slotWanted === 1 ? me.primary : null; |
| 1090 | 1181 | |
| 1091 | 1182 | const steps = loop.advance(elapsed); |
| 1183 | // Buying keeps the pointer, so the keys have to be kept off the player: with | |
| 1184 | // the panel open the trigger is dead and the number row buys instead of | |
| 1185 | // switching weapons. Movement is not suppressed, and never was — walking out | |
| 1186 | // of spawn while you buy is the point. | |
| 1092 | 1187 | for (let step = 0; step < steps; step++) |
| 1093 | update(world, TICK, buyPanel.hidden ? input : { ...input, fire: false }); | |
| 1188 | update(world, TICK, buyPanel.hidden ? input : { ...input, fire: false, switchTo: null }); | |
| 1094 | 1189 | |
| 1095 | 1190 | // The camera, and with it whose eyes this frame is drawn and heard from. |
| 1096 | 1191 | // It is moved after the steps and before anything reads it, and it is the |
src/client/stage.test-helper.ts +1 −1
| @@ -207,7 +207,7 @@ export type FakeStage = { | ||
| 207 | 207 | * contract between the page and this file, so `src/server.test.ts` checks the |
| 208 | 208 | * served page against this same list rather than a second copy of it. |
| 209 | 209 | */ |
| 210 | export const PAGE_IDS = ["weights", "stage", "scene", "hud", "menu", "pause", "summary", "buy", "touch", "stats", "saved", "name", "length", "side", "sens", "map", "mapseed", "matchid", "onid", "onmap", "today", "ontoday", "willplay", "plan", "planalt", "copymap", "play", "prep", "resume", "quit", "esc", "pauseid", "sumtitle", "sumscore", "sumyou", "sumboard", "sumsaved", "again", "leave", "playing", "mapname", "share", "fps", "full"]; | |
| 210 | export const PAGE_IDS = ["weights", "stage", "scene", "hud", "menu", "pause", "summary", "buy", "buyshelf", "buysays", "touch", "stats", "saved", "name", "length", "side", "sens", "map", "mapseed", "matchid", "onid", "onmap", "today", "ontoday", "willplay", "plan", "planalt", "copymap", "play", "prep", "resume", "quit", "esc", "pauseid", "sumtitle", "sumscore", "sumyou", "sumboard", "sumsaved", "again", "leave", "playing", "mapname", "share", "fps", "full"]; | |
| 211 | 211 | |
| 212 | 212 | /** The ones the server renders with `hidden` on them: the page starts at the menu. */ |
| 213 | 213 | const HIDDEN = ["pause", "summary", "buy", "touch", "full"]; |
src/games.ts +8 −2
| @@ -97,8 +97,14 @@ export const GAMES: readonly Game[] = [ | ||
| 97 | 97 | href: "/", |
| 98 | 98 | label: "cs", |
| 99 | 99 | entry: "src/client/main.ts", |
| 100 | /** Raised by #165, which put a smoke grenade — and a cloud a bot cannot see through — in the game. */ | |
| 101 | ceiling: { raw: 103_000, gzip: 41_000 }, | |
| 100 | /** | |
| 101 | * Raised by #153, which made the buy panel keyboard-driven: the number row | |
| 102 | * on the tiles, the rebuy and the loadout it remembers. 103,334 raw and | |
| 103 | * 40,719 gzipped, measured by `bun run scripts/build.ts` — 334 bytes over | |
| 104 | * the ceiling #165 set for the smoke grenade, and worth it for a round that | |
| 105 | * is bought without the pointer leaving the game. | |
| 106 | */ | |
| 107 | ceiling: { raw: 110_000, gzip: 43_500 }, | |
| 102 | 108 | assets: [ |
| 103 | 109 | { |
| 104 | 110 | name: "weights", |
src/pages/actions.test.ts +10 −2
| @@ -169,8 +169,16 @@ const SURFACES: readonly Surface[] = [ | ||
| 169 | 169 | { |
| 170 | 170 | why: "a phone plays the weapon it bought: `src/client/economy.ts` equips a weapon the moment " + |
| 171 | 171 | "it is bought, and Buy is on the pad. Three slot buttons beside the six already there is a " + |
| 172 | "trade nobody has made.", | |
| 173 | actions: ["slot1", "slot2", "slot3"], | |
| 172 | "trade nobody has made. The rest of the number row is the buy panel's — with the panel open " + | |
| 173 | "each key presses the tile it is printed on — and those tiles are buttons a thumb taps, so a " + | |
| 174 | "phone reaches every one of them without a key.", | |
| 175 | actions: ["slot1", "slot2", "slot3", "slot4", "slot5", "slot6", "slot7", "slot8", "slot9", "slot10", "slot11"], | |
| 176 | }, | |
| 177 | { | |
| 178 | why: "the buy panel draws its own Rebuy control beside the tiles (`src/client/buy.ts`), which " + | |
| 179 | "is the button a thumb presses; X is that same call from the keyboard, for a hand that is " + | |
| 180 | "already walking out of spawn.", | |
| 181 | actions: ["rebuy"], | |
| 174 | 182 | }, |
| 175 | 183 | { |
| 176 | 184 | why: "the right mouse button, and the pad has no second trigger: a phone fires from the hip. " + |
src/pages/game.ts +27 −2
| @@ -205,6 +205,13 @@ a.share:hover, a.share:focus-visible { background:#27303f; text-decoration:under | ||
| 205 | 205 | ul.recent a.map { color:var(--dim); text-decoration:none; padding:.35rem .4rem; margin:-.35rem 0; |
| 206 | 206 | border-radius:.3rem; background:#161b24; } |
| 207 | 207 | ul.recent a.map:hover, ul.recent a.map:focus-visible { color:var(--ct); text-decoration:underline; } |
| 208 | /* The buy panel's shelf: the column src/client/buy.ts fills, sized here so the | |
| 209 | grid inside it has a width to be a fraction of. It takes the focus when the | |
| 210 | panel opens and is not a control, so the ring is drawn on it and nothing | |
| 211 | else — a focused tile would be a tile Space presses, and Space is jump. */ | |
| 212 | #buyshelf { display:flex; flex-direction:column; align-items:center; gap:.75rem; width:min(40rem,100%); } | |
| 213 | #buyshelf:focus { outline:none; } | |
| 214 | #buyshelf:focus-visible { outline:2px solid var(--ct); outline-offset:.35rem; border-radius:.45rem; } | |
| 208 | 215 | ${WEAPONS_STYLE}`; |
| 209 | 216 | |
| 210 | 217 | /** What the menu's hint under the id field says when the link carried junk. */ |
| @@ -348,7 +355,8 @@ const menu = (visitor: Who, asked: Asked, daily: Daily): string => ` | ||
| 348 | 355 | <p class="hint" id="prep">Preparing textures…</p> |
| 349 | 356 | <p class="hint"><kbd>WASD</kbd> move · <kbd>Space</kbd> jump · <kbd>Ctrl</kbd> crouch · <kbd>Shift</kbd> walk · |
| 350 | 357 | mouse to aim and fire · <kbd>R</kbd> reload · <kbd>G</kbd> throw an HE · <kbd>T</kbd> throw a flashbang · <kbd>V</kbd> throw a smoke · |
| 351 | <kbd>E</kbd> plant or defuse · <kbd>B</kbd> buy · | |
| 358 | <kbd>E</kbd> plant or defuse · <kbd>B</kbd> buy — the buy menu keeps the mouse on the game, the | |
| 359 | number row buys the tile it is printed on and <kbd>X</kbd> buys last round's loadout again · | |
| 352 | 360 | <kbd>1</kbd><kbd>2</kbd><kbd>3</kbd> guns · <kbd>Tab</kbd> scores · <kbd>F</kbd> full screen · |
| 353 | 361 | <kbd>Esc</kbd> or <kbd>P</kbd> pause</p> |
| 354 | 362 | <p class="hint">Full screen keeps the screen through a pause where a browser lets it: |
| @@ -424,6 +432,23 @@ export const planHtml = (planning: boolean): string => | ||
| 424 | 432 | * every pause with the one that is true for the browser it turned out to be: |
| 425 | 433 | * whether Escape kept the screen, or took it and Resume will put it back. |
| 426 | 434 | */ |
| 435 | /** | |
| 436 | * The buy panel's frame, and the only part of it this page renders: the shelf | |
| 437 | * `src/client/buy.ts` draws into, and the line under it. | |
| 438 | * | |
| 439 | * The shelf takes the focus when the panel opens and is a group with a name, so | |
| 440 | * what opened is announced and Tab from it is the first tile — the panel keeps | |
| 441 | * the pointer lock now, so there is no cursor to move into it and the focus is | |
| 442 | * the whole of the way in. The line is a live region, rendered here rather than | |
| 443 | * written by the client into markup it also replaces: a region a redraw | |
| 444 | * destroys is a region a screen reader never hears change, so it is outside | |
| 445 | * everything `drawBuy` rewrites and it is server-rendered empty. | |
| 446 | */ | |
| 447 | const BUY = ` | |
| 448 | <div id="buyshelf" tabindex="-1" role="group" aria-label="Buy"></div> | |
| 449 | <p class="hint" id="buysays" role="status" aria-live="polite"></p> | |
| 450 | `; | |
| 451 | ||
| 427 | 452 | const PAUSE = ` |
| 428 | 453 | <h2>Paused</h2> |
| 429 | 454 | <div class="row"><button type="button" class="primary" id="resume">Resume</button><button type="button" id="quit">Leave match</button></div> |
| @@ -508,7 +533,7 @@ export function gamePage( | ||
| 508 | 533 | // After the pause, so a match that ends while it is up is read over it |
| 509 | 534 | // rather than under it. `src/client/main.ts` hides the pause anyway. |
| 510 | 535 | { id: "summary", html: summary(visitor), hidden: true }, |
| 511 | { id: "buy", html: "", hidden: true }, | |
| 536 | { id: "buy", html: BUY, hidden: true }, | |
| 512 | 537 | ], |
| 513 | 538 | }), |
| 514 | 539 | sectionsHtml: `${thisMatch(asked)} |
new above
offline