Public: anyone can read this; the crew can change it.
forked from trevato/cs
A first visit is one click from a round: Play first, and the seven controls fold behind a summary that says what they are #410
Check · Review · Land · Live done — Landed and live in production.
torque/cs-engine:torque/255-a-first-visit-is-one-click-from-a-round → main 3 commits fork
9 files
changed +315
−15
README.md +11
| @@ -239,6 +239,17 @@ yet. | ||
| 239 | 239 | when a match starts, and put back in the menu on your next visit. A stored |
| 240 | 240 | value that is not one of the menu's own answers is ignored, and the page's |
| 241 | 241 | default stands. |
| 242 | - **Play is the first thing.** The menu opens on your name, one sentence that | |
| 243 | says what Play gives you — the length, the side and the map, and whether it | |
| 244 | is the round of a match id — the plan of that map, and Play. Every other | |
| 245 | control (length, side, map, map name, sensitivity, motion, match id, today's | |
| 246 | match and rooms) is in one fold under Play, a `<details>` that opens in place | |
| 247 | with no script and from the keyboard. The sentence is written by one function | |
| 248 | (`src/client/offer.ts`) from the values the controls hold, on the server and | |
| 249 | again whenever a control changes, so your stored side and length are what it | |
| 250 | says. A link that asks for anything — `?match=`, `?map=`, `?room=` — opens | |
| 251 | the fold, so what it filled in is on the screen. A new setting for the menu | |
| 252 | goes inside that fold, not beside Play. | |
| 242 | 253 | |
| 243 | 254 | ## A match you can play again |
| 244 | 255 |
src/client/main.test.ts +48 −1
| @@ -37,7 +37,7 @@ import type { Socket } from "./session.ts"; | ||
| 37 | 37 | import { NO_PICTURE } from "../engine/index.ts"; |
| 38 | 38 | import { HINT } from "./buy.ts"; |
| 39 | 39 | import { DE_FORGE } from "./map.ts"; |
| 40 | import { endRound, SHORT } from "./match.ts"; | |
| 40 | import { COMPETITIVE, endRound, SHORT } from "./match.ts"; | |
| 41 | 41 | import { LAYERS, materialTextures } from "./materials.ts"; |
| 42 | 42 | import { recorder } from "./paint.test-helper.ts"; |
| 43 | 43 | import { drawPreview, previewText } from "./preview.ts"; |
| @@ -210,6 +210,53 @@ test("the side and the match length are remembered beside the name, and junk is | ||
| 210 | 210 | expect(junk.by("length").value).toBe("short"); |
| 211 | 211 | }); |
| 212 | 212 | |
| 213 | test("the sentence beside Play says what the folded controls hold, and moves when they do", () => { | |
| 214 | const stage = painted(start(fakeStage())); | |
| 215 | stage.by("length").value = "short"; | |
| 216 | stage.by("side").value = "ct"; | |
| 217 | stage.by("length").fire("change"); | |
| 218 | expect(stage.by("willgive").textContent) | |
| 219 | .toBe(`A short match — first to ${SHORT.winScore} — as the Counter-Terrorists on de_forge.`); | |
| 220 | ||
| 221 | // A second set of values, every one of them different: the sentence is the | |
| 222 | // controls', so it is theirs and not the first paint's. | |
| 223 | stage.by("length").value = "long"; | |
| 224 | stage.by("length").fire("change"); | |
| 225 | stage.by("side").value = "t"; | |
| 226 | stage.by("side").fire("change"); | |
| 227 | stage.by("mapseed").value = MAP; | |
| 228 | stage.by("mapseed").fire("input"); | |
| 229 | stage.by("matchid").value = "0f8fad5b-d9cb-469f-a165-70867728950e"; | |
| 230 | stage.by("matchid").fire("input"); | |
| 231 | expect(stage.by("willgive").textContent) | |
| 232 | .toBe(`A competitive match — first to ${COMPETITIVE.winScore} — as the Terrorists on ${MAP}, the round of the match id below.`); | |
| 233 | // And the match Play starts is the one the sentence promised. | |
| 234 | stage.by("play").fire("click"); | |
| 235 | expect(worldOf(stage)?.me.team).toBe("t"); | |
| 236 | expect(worldOf(stage)?.match.rules).toBe(COMPETITIVE); | |
| 237 | expect(worldOf(stage)?.level.name).toBe(MAP); | |
| 238 | ||
| 239 | // A generated map is named by the id drawn for it, in the sentence as above Play. | |
| 240 | stage.by("quit").fire("click"); | |
| 241 | stage.by("mapseed").value = ""; | |
| 242 | stage.by("matchid").value = ""; | |
| 243 | stage.by("map").value = "generated"; | |
| 244 | stage.by("map").fire("change"); | |
| 245 | expect(stage.by("willgive").textContent).toContain(`on ${stage.by("willplay").textContent}.`); | |
| 246 | }); | |
| 247 | ||
| 248 | test("a player with settings stored reads their own in the sentence, not the defaults", () => { | |
| 249 | const again = fakeStage(); | |
| 250 | again.store.set("cs.side", "t"); | |
| 251 | again.store.set("cs.length", "long"); | |
| 252 | start(again); | |
| 253 | expect(again.by("willgive").textContent) | |
| 254 | .toBe(`A competitive match — first to ${COMPETITIVE.winScore} — as the Terrorists on de_forge.`); | |
| 255 | // Today's match sets the length in code, where no event fires, and says so too. | |
| 256 | again.by("today").fire("click"); | |
| 257 | expect(again.by("willgive").textContent).toContain(", the round of the match id below."); | |
| 258 | }); | |
| 259 | ||
| 213 | 260 | test("clicking play starts a match: the menu goes, the pointer is asked for, the frame draws", () => { |
| 214 | 261 | const stage = playing(); |
| 215 | 262 | expect(stage.by("menu").hidden).toBe(true); |
src/client/main.ts +31 −3
| @@ -85,13 +85,14 @@ import { | ||
| 85 | 85 | badName, chosenMap, levelOf, nameMap, NOT_A_MAP, NOT_AN_ID, planDue, planLevel, playedLink, readId, type Controls, |
| 86 | 86 | } from "./chooser.ts"; |
| 87 | 87 | import { dailyOn, DAILY_LENGTH, saidToday } from "../daily.ts"; |
| 88 | import { playKey } from "../matchid.ts"; | |
| 88 | import { matchFrom, playKey } from "../matchid.ts"; | |
| 89 | 89 | import { describe, localPlan, parsePlan, type RoundPlan } from "../plan/plan.ts"; |
| 90 | 90 | import type { Situation } from "../plan/situation.ts"; |
| 91 | 91 | import { DE_FORGE, levelNamed } from "./map.ts"; |
| 92 | 92 | import type { Level } from "./level.ts"; |
| 93 | 93 | import { FOV, fovFor, lookAt } from "./look.ts"; |
| 94 | 94 | import { COMPETITIVE, SHORT, type Rules, type Team } from "./match.ts"; |
| 95 | import { offerSaid } from "./offer.ts"; | |
| 95 | 96 | import { motionFor, parseMotion, REDUCED_MOTION_QUERY, type Motion } from "./motion.ts"; |
| 96 | 97 | import { parseSens, parseSound, readSettings, SENS, SETTING_KEYS, settingStrings, type Settings, withSetting } from "./settings.ts"; |
| 97 | 98 | import { LAYERS, MATERIALS, TEX_SIZE } from "./materials.ts"; |
| @@ -430,6 +431,9 @@ export function boot(host: Host): Frame { | ||
| 430 | 431 | // The name of the map Play is about to give you, the plan of it, the line |
| 431 | 432 | // that says in words what the plan says in pixels, and the link that keeps it. |
| 432 | 433 | const willPlay = by("willplay"); |
| 434 | // The sentence beside Play that says what it gives — length, side and map — | |
| 435 | // while the controls that decide it are folded away. See `sayOffer`. | |
| 436 | const willGive = by("willgive"); | |
| 433 | 437 | const planCanvas = by<Sheet>("plan"); |
| 434 | 438 | const planAlt = by("planalt"); |
| 435 | 439 | const copyMap = by<Toggle>("copymap"); |
| @@ -1112,12 +1116,34 @@ export function boot(host: Host): Frame { | ||
| 1112 | 1116 | const next = nameMap(asked(), win.performance.now(), draw); |
| 1113 | 1117 | keeping = next.link; |
| 1114 | 1118 | willPlay.textContent = next.name; |
| 1119 | nextName = next.name; | |
| 1120 | sayOffer(); | |
| 1115 | 1121 | copyMap.setAttribute("href", keeping); |
| 1116 | 1122 | copyMap.textContent = copyLabel; |
| 1117 | 1123 | onMap.textContent = next.bad ? NOT_A_MAP : aboutMap; |
| 1118 | 1124 | planAt = next.planAt; |
| 1119 | 1125 | } |
| 1120 | 1126 | |
| 1127 | /** The map `nameNextMap` last named, which is the map in the sentence below. */ | |
| 1128 | let nextName = "de_forge"; | |
| 1129 | ||
| 1130 | /** | |
| 1131 | * The sentence beside Play, out of the controls it summarises — the same | |
| 1132 | * reading `start` gives them, through `offerSaid` in `./offer.ts`, which is | |
| 1133 | * also what the server rendered it with. Called when any of them changes, | |
| 1134 | * and once more after the stored side and length are put back, so a player | |
| 1135 | * with settings of their own reads theirs and not the defaults. | |
| 1136 | */ | |
| 1137 | function sayOffer(): void { | |
| 1138 | willGive.textContent = offerSaid({ | |
| 1139 | length: lengthField.value, | |
| 1140 | side: sideField.value, | |
| 1141 | map: nextName, | |
| 1142 | replay: matchFrom(idField.value.trim()) !== null, | |
| 1143 | }); | |
| 1144 | } | |
| 1145 | for (const field of [lengthField, sideField]) field.addEventListener("change", sayOffer); | |
| 1146 | ||
| 1121 | 1147 | /** The plan of the map Play would start, and the sentence beside it. */ |
| 1122 | 1148 | function drawMapPlan(): void { |
| 1123 | 1149 | const level = planLevel(asked(), draw); |
| @@ -1165,8 +1191,9 @@ export function boot(host: Host): Frame { | ||
| 1165 | 1191 | seedField.value = ""; |
| 1166 | 1192 | lengthField.value = DAILY_LENGTH; |
| 1167 | 1193 | onToday.textContent = saidToday(daily); |
| 1168 | // The id changed without a keystroke, so the line before Play and the plan | |
| 1169 | // beside it are named again here: no event fires for a value set in code. | |
| 1194 | // The id and the length changed without a keystroke, so the line before | |
| 1195 | // Play, the plan beside it and the sentence are named again here: no event | |
| 1196 | // fires for a value set in code. | |
| 1170 | 1197 | nameNextMap(); |
| 1171 | 1198 | } |
| 1172 | 1199 | todayButton.addEventListener("click", pickToday); |
| @@ -1502,6 +1529,7 @@ export function boot(host: Host): Frame { | ||
| 1502 | 1529 | if (savedSide === "t" || savedSide === "ct") sideField.value = savedSide; |
| 1503 | 1530 | const savedLength = host.storage.getItem("cs.length"); |
| 1504 | 1531 | if (savedLength === "short" || savedLength === "long") lengthField.value = savedLength; |
| 1532 | sayOffer(); | |
| 1505 | 1533 | |
| 1506 | 1534 | /** |
| 1507 | 1535 | * Sound for whatever happened in the ticks we just ran, heard from `ears` — |
src/client/offer.test.ts added +27
| @@ -0,0 +1,27 @@ | ||
| 1 | /** | |
| 2 | * The sentence beside Play, checked against sentences written out by hand: the | |
| 3 | * defaults, and a second set that changes every one of the four things it says. | |
| 4 | */ | |
| 5 | ||
| 6 | import { expect, test } from "bun:test"; | |
| 7 | import { COMPETITIVE, SHORT } from "./match.ts"; | |
| 8 | import { A_GENERATED_MAP, offerSaid } from "./offer.ts"; | |
| 9 | ||
| 10 | test("the defaults say a short match as the Counter-Terrorists on de_forge", () => { | |
| 11 | expect(offerSaid({ length: "short", side: "ct", map: "de_forge", replay: false })) | |
| 12 | .toBe(`A short match — first to ${SHORT.winScore} — as the Counter-Terrorists on de_forge.`); | |
| 13 | // Five rounds today; the sentence reads the rules rather than typing the digit. | |
| 14 | expect(SHORT.winScore).toBe(5); | |
| 15 | }); | |
| 16 | ||
| 17 | test("every other value says itself: competitive, the Terrorists, a named map and a given id", () => { | |
| 18 | expect(offerSaid({ length: "long", side: "t", map: "gen_14u5j6g", replay: true })) | |
| 19 | .toBe(`A competitive match — first to ${COMPETITIVE.winScore} — as the Terrorists on gen_14u5j6g, ` + | |
| 20 | `the round of the match id below.`); | |
| 21 | expect(offerSaid({ length: "short", side: "ct", map: null, replay: false })).toContain(`on ${A_GENERATED_MAP}.`); | |
| 22 | }); | |
| 23 | ||
| 24 | test("a value the selects never offer reads the way the match reads it: short, Counter-Terrorists", () => { | |
| 25 | expect(offerSaid({ length: "forever", side: "spectator", map: "de_forge", replay: false })) | |
| 26 | .toBe(offerSaid({ length: "short", side: "ct", map: "de_forge", replay: false })); | |
| 27 | }); |
src/client/offer.ts added +41
| @@ -0,0 +1,41 @@ | ||
| 1 | /** | |
| 2 | * What pressing Play gives you, in one sentence: the length, the side and the | |
| 3 | * map — and, when there is one, that it is a match somebody gave you an id for. | |
| 4 | * | |
| 5 | * It is the line the menu shows beside Play while every control that decides | |
| 6 | * it is folded away (#255), so it has one job, which is to be true. It is | |
| 7 | * written by one function out of the same values the controls hold: the page | |
| 8 | * renders it from the values it renders the controls with, and | |
| 9 | * `src/client/main.ts` writes it again, through `textContent`, whenever one of | |
| 10 | * those controls changes. Plain data in, a string out; the caller escapes. | |
| 11 | * | |
| 12 | * The words for the length and the side are the words the two selects already | |
| 13 | * use, and the number is `winScore` off the rules the match will be played to, | |
| 14 | * so a tuning that moves the score moves the sentence. | |
| 15 | */ | |
| 16 | ||
| 17 | import { COMPETITIVE, SHORT } from "./match.ts"; | |
| 18 | ||
| 19 | /** What the sentence is made of: the four things the folded controls decide. */ | |
| 20 | export type Offer = { | |
| 21 | /** The Length select: `long` is Competitive, anything else is Short, as `start` reads it. */ | |
| 22 | length: string; | |
| 23 | /** The Side select: `t` is the Terrorists, anything else the Counter-Terrorists. */ | |
| 24 | side: string; | |
| 25 | /** The map by name, or null for a generated map nobody has drawn yet. */ | |
| 26 | map: string | null; | |
| 27 | /** Whether the Match id field holds an id — the round is somebody's, not a fresh one. */ | |
| 28 | replay: boolean; | |
| 29 | }; | |
| 30 | ||
| 31 | /** What a map the page has yet to draw is called in the sentence. */ | |
| 32 | export const A_GENERATED_MAP = "a generated map"; | |
| 33 | ||
| 34 | /** The sentence. `offerSaid({ length: "short", side: "ct", map: "de_forge", replay: false })` is the defaults'. */ | |
| 35 | export function offerSaid(offer: Offer): string { | |
| 36 | const long = offer.length === "long"; | |
| 37 | const rules = long ? COMPETITIVE : SHORT; | |
| 38 | const side = offer.side === "t" ? "the Terrorists" : "the Counter-Terrorists"; | |
| 39 | return `${long ? "A competitive" : "A short"} match — first to ${rules.winScore} — as ${side} on ` + | |
| 40 | `${offer.map ?? A_GENERATED_MAP}${offer.replay ? ", the round of the match id below" : ""}.`; | |
| 41 | } |
src/client/stage.test-helper.ts +1 −1
| @@ -165,7 +165,7 @@ export type FakeStage = { | ||
| 165 | 165 | * contract between the page and this file, so `src/server.test.ts` checks the |
| 166 | 166 | * served page against this same list rather than a second copy of it. |
| 167 | 167 | */ |
| 168 | export const PAGE_IDS = ["weights", "stage", "scene", "hud", "menu", "pause", "summary", "buy", "buycursor", "buyshelf", "buysays", "touch", "stats", "saved", "name", "length", "side", "sens", "touchsens", "motion", "sound", "map", "mapseed", "matchid", "onid", "onmap", "today", "ontoday", "willplay", "plan", "planalt", "copymap", "play", "room", "roomside", "joinroom", "makeroom", "copyroom", "roomsaid", "prep", "resume", "quit", "psens", "ptouchsens", "pmotion", "psound", "esc", "pauseid", "sumtitle", "sumscore", "sumyou", "sumboard", "sumsaved", "again", "leave", "playing", "mapname", "share", "fps", "full", "support", "supportline", "copysupport"]; | |
| 168 | export const PAGE_IDS = ["weights", "stage", "scene", "hud", "menu", "pause", "summary", "buy", "buycursor", "buyshelf", "buysays", "touch", "stats", "saved", "name", "length", "side", "sens", "touchsens", "motion", "sound", "map", "mapseed", "matchid", "onid", "onmap", "today", "ontoday", "willplay", "willgive", "options", "plan", "planalt", "copymap", "play", "room", "roomside", "joinroom", "makeroom", "copyroom", "roomsaid", "prep", "resume", "quit", "psens", "ptouchsens", "pmotion", "psound", "esc", "pauseid", "sumtitle", "sumscore", "sumyou", "sumboard", "sumsaved", "again", "leave", "playing", "mapname", "share", "fps", "full", "support", "supportline", "copysupport"]; | |
| 169 | 169 | |
| 170 | 170 | /** The ones the server renders with `hidden` on them: the page starts at the menu. */ |
| 171 | 171 | const HIDDEN = ["pause", "summary", "buy", "buycursor", "touch", "full"]; |
src/pages/game.test.ts +93
| @@ -37,7 +37,9 @@ import { GAMES } from "../games.ts"; | ||
| 37 | 37 | import { digitsIn, proseOf, unsourced } from "./digits.test-helper.ts"; |
| 38 | 38 | import { |
| 39 | 39 | gamePage, planHtml, PLANNED_ID, policyHtml, shareHref, statsHtml, STYLE, BAD_ID, BAD_MAP, COPY_ROOM, NOTHING_ASKED, type PageOptions, |
| 40 | foldOpen, offerOf, OPTIONS, type Asked, | |
| 40 | 41 | } from "./game.ts"; |
| 42 | import { A_GENERATED_MAP, offerSaid } from "../client/offer.ts"; | |
| 41 | 43 | import { roomLink } from "../roomlink.ts"; |
| 42 | 44 | import { WEAPONS_HREF, WEAPONS_SECTION, WEAPONS_SUMMARY } from "./weapons.ts"; |
| 43 | 45 | import { escape, SUPPORT } from "./shell.ts"; |
| @@ -487,3 +489,94 @@ test("where a model plans the rounds, the id's sentence says what an id keeps of | ||
| 487 | 489 | expect(planHtml(true).replace(/\s+/g, " ")).toContain("kept with the match it was asked for"); |
| 488 | 490 | expect(planHtml(false)).not.toContain("kept with the match"); |
| 489 | 491 | }); |
| 492 | ||
| 493 | // --- a first visit is one click from a round (#255) ------------------------ | |
| 494 | ||
| 495 | /** The menu overlay's own markup, from its opening tag to the next overlay. */ | |
| 496 | const menuOf = (html: string): string => html.slice(html.indexOf(`id="menu"`), html.indexOf(`id="pause"`)); | |
| 497 | ||
| 498 | /** What is inside the fold, and what is before it. */ | |
| 499 | const foldOf = (html: string): { fold: string; before: string } => { | |
| 500 | const menu = menuOf(html); | |
| 501 | const at = menu.indexOf(`<details id="options"`); | |
| 502 | expect(at).toBeGreaterThan(-1); | |
| 503 | return { before: menu.slice(0, at), fold: menu.slice(at, menu.indexOf("</details>", at)) }; | |
| 504 | }; | |
| 505 | ||
| 506 | test("Play is the first thing: the name, the sentence, the plan and the button, and the rest folded", () => { | |
| 507 | const html = gamePage(db(), options); | |
| 508 | const { before, fold } = foldOf(html); | |
| 509 | // Before the fold: what a first visit wants, and the plan that says what it is. | |
| 510 | for (const id of ["name", "willgive", "willmap", "plan", "willplay", "copymap", "play"]) | |
| 511 | expect(before).toContain(`id="${id}"`); | |
| 512 | // In it: every other control, with nothing removed. | |
| 513 | for (const id of ["length", "side", "map", "mapseed", "sens", "touchsens", "motion", "matchid", "today", "room", "roomside", "joinroom", "makeroom", "copyroom"]) { | |
| 514 | expect(fold).toContain(`id="${id}"`); | |
| 515 | expect(before).not.toContain(`id="${id}"`); | |
| 516 | } | |
| 517 | // It says what is inside it, and it is closed on a link that asked for nothing. | |
| 518 | expect(fold).toContain(`<summary>${escape(OPTIONS)}</summary>`); | |
| 519 | expect(fold).toStartWith(`<details id="options">`); | |
| 520 | // With no stored settings the sentence is the defaults', written by hand. | |
| 521 | expect(html).toContain(`<p id="willgive">A short match — first to ${SHORT.winScore} — as the Counter-Terrorists on de_forge.</p>`); | |
| 522 | }); | |
| 523 | ||
| 524 | test("the sentence is rendered from what the controls are rendered with", () => { | |
| 525 | const name = "gen_14u5j6g"; | |
| 526 | const id = "0f8fad5b-d9cb-469f-a165-70867728950e"; | |
| 527 | const html = gamePage(db(), { ...options, asked: { ...NOTHING_ASKED, match: id, map: name } }); | |
| 528 | expect(html).toContain(`<p id="willgive">${escape(offerSaid(offerOf({ ...NOTHING_ASKED, match: id, map: name })))}</p>`); | |
| 529 | expect(html).toContain(`on ${name}, the round of the match id below.</p>`); | |
| 530 | // The controls hold the same two values the sentence names. | |
| 531 | expect(html).toContain(`value="${name}"`); | |
| 532 | expect(html).toContain(`value="${id}"`); | |
| 533 | // A generated map is one the page has yet to draw, and the sentence says so. | |
| 534 | const generated = gamePage(db(), { ...options, asked: { ...NOTHING_ASKED, map: "generated" } }); | |
| 535 | expect(generated).toContain(`<option value="generated" selected>`); | |
| 536 | expect(generated).toContain(`as the Counter-Terrorists on ${A_GENERATED_MAP}.</p>`); | |
| 537 | }); | |
| 538 | ||
| 539 | test("a link that asked for something opens the fold, so what it filled in is on the screen", () => { | |
| 540 | const id = "0f8fad5b-d9cb-469f-a165-70867728950e"; | |
| 541 | const asks: Asked[] = [ | |
| 542 | { ...NOTHING_ASKED, match: id }, | |
| 543 | { ...NOTHING_ASKED, map: "gen_14u5j6g" }, | |
| 544 | { ...NOTHING_ASKED, map: "generated" }, | |
| 545 | { ...NOTHING_ASKED, badMatch: true }, | |
| 546 | { ...NOTHING_ASKED, badMap: true }, | |
| 547 | { ...NOTHING_ASKED, room: "8UTZQR" }, | |
| 548 | ]; | |
| 549 | for (const asked of asks) { | |
| 550 | expect(foldOpen(asked)).toBe(true); | |
| 551 | expect(foldOf(gamePage(db(), { ...options, asked })).fold).toStartWith(`<details id="options" open>`); | |
| 552 | } | |
| 553 | for (const asked of [NOTHING_ASKED, { ...NOTHING_ASKED, room: null }]) { | |
| 554 | expect(foldOpen(asked)).toBe(false); | |
| 555 | expect(foldOf(gamePage(db(), { ...options, asked })).fold).toStartWith(`<details id="options">`); | |
| 556 | } | |
| 557 | }); | |
| 558 | ||
| 559 | test("every control in the menu is still one the keyboard reaches", () => { | |
| 560 | const menu = menuOf(gamePage(db(), options)); | |
| 561 | // Nothing taken out of the tab order, and the fold is a native `<details>` | |
| 562 | // whose `<summary>` is focusable and opens it with Enter or Space — no script. | |
| 563 | expect(menu).not.toContain("tabindex"); | |
| 564 | expect(menu).not.toContain(`role="button"`); | |
| 565 | expect(menu.match(/<summary>/g)?.length).toBe(2); | |
| 566 | // The only hidden control is the room's link, which is there when a room is. | |
| 567 | const hidden = [...menu.matchAll(/<(?:input|select|button|a)\b[^>]*\bhidden\b[^>]*>/g)].map((m) => m[0]); | |
| 568 | expect(hidden).toEqual([`<a class="share" id="copyroom" href="/" hidden>`, `<button type="button" id="copysupport" hidden>`]); | |
| 569 | // And the summary is a target a thumb can hit. | |
| 570 | expect(STYLE).toContain("#options > summary { min-height:44px;"); | |
| 571 | }); | |
| 572 | ||
| 573 | test("the fold costs `/` nothing over the wire: the style block ships without its comments", () => { | |
| 574 | const html = gamePage(db(), options); | |
| 575 | const style = html.slice(html.indexOf("<style>"), html.indexOf("</style>")); | |
| 576 | expect(style).not.toContain("/*"); | |
| 577 | // The rules the comments sat above are all still there. | |
| 578 | expect(style).toContain("#willgive { min-height:3.1em;"); | |
| 579 | expect(style).toContain("@media not (pointer: coarse) { .thumbonly { display:none; } }"); | |
| 580 | // 9,562 bytes gzipped before #255; the page is held under that. | |
| 581 | expect(Bun.gzipSync(html).byteLength).toBeLessThan(9562); | |
| 582 | }); |
src/pages/game.ts +53 −9
| @@ -26,6 +26,7 @@ import { counted } from "../keep.ts"; | ||
| 26 | 26 | import { COMPETITIVE, SHORT } from "../client/match.ts"; |
| 27 | 27 | import { DEFAULT_MOTION, MOTION_LABELS, MOTIONS } from "../client/motion.ts"; |
| 28 | 28 | import { CS_MOVE } from "../client/movement.ts"; |
| 29 | import { offerSaid, type Offer } from "../client/offer.ts"; | |
| 29 | 30 | import { NOTHING_POSTED } from "../client/pending.ts"; |
| 30 | 31 | import { SENS, SOUND_LABELS, SOUNDS } from "../client/settings.ts"; |
| 31 | 32 | import { FLASH, HE, SMOKE } from "../client/weapons.ts"; |
| @@ -317,6 +318,17 @@ a.share[hidden] { display:none; } | ||
| 317 | 318 | #summary .ct { color:var(--ct); font-weight:600; } |
| 318 | 319 | #sumscore { font-size:1.1rem; color:var(--text); } |
| 319 | 320 | #sumyou { color:var(--text); } |
| 321 | /* The sentence Play is about to keep: one line on a desktop, two on a phone, | |
| 322 | and the same box whichever the browser writes over the server's. */ | |
| 323 | #willgive { min-height:3.1em; color:var(--text); } | |
| 324 | /* The fold: everything but Name and Play, one press away. Full width so the | |
| 325 | rows inside it wrap the way they did outside it; the summary is a target a | |
| 326 | thumb can hit, and the marker is the browser's own. */ | |
| 327 | #options { width:100%; max-width:40rem; } | |
| 328 | #options > summary { min-height:44px; padding:.6rem .5rem; cursor:pointer; color:var(--ct); } | |
| 329 | #options[open] > summary { margin-bottom:.5rem; } | |
| 330 | #options > .row, #options > p { margin-left:auto; margin-right:auto; } | |
| 331 | #options > p { margin-top:.5rem; margin-bottom:.5rem; } | |
| 320 | 332 | #willtext { flex:1 1 13rem; min-width:0; max-width:22rem; text-align:left; } |
| 321 | 333 | #willtext p { margin:.1rem 0; } |
| 322 | 334 | /* Two lines' worth, whether it is holding the sentence the server rendered or |
| @@ -462,6 +474,32 @@ export const PLANNED_ID = | ||
| 462 | 474 | "opinion. A plan is played from the end of the freeze; one not back by then leaves that round to " + |
| 463 | 475 | "the local plan."; |
| 464 | 476 | |
| 477 | /** What the fold's own line says: every control inside it, by the name its label gives it. */ | |
| 478 | export const OPTIONS = | |
| 479 | "Length, side, map, map name, sensitivity, motion, match id, today's match and rooms"; | |
| 480 | ||
| 481 | /** | |
| 482 | * What a first paint says Play will give: the defaults, or what the link asked | |
| 483 | * for. The select's two words and a name are the map as `?map=` gave it, and a | |
| 484 | * generated one is null — the id it is carved from is drawn in the browser. | |
| 485 | * Length and side have no parameter, so they are the selects' first options, | |
| 486 | * and a browser that stored others (#73) writes the sentence again at boot. | |
| 487 | */ | |
| 488 | export const offerOf = (asked: Asked): Offer => ({ | |
| 489 | length: "short", | |
| 490 | side: "ct", | |
| 491 | map: asked.map === "generated" ? null : asked.map, | |
| 492 | replay: asked.match !== null, | |
| 493 | }); | |
| 494 | ||
| 495 | /** | |
| 496 | * Whether the fold is rendered open: whenever the link asked for anything, so | |
| 497 | * a pasted `?match=`, `?map=` or `?room=` shows what it filled in — or the | |
| 498 | * sentence saying it was not one — rather than filling a closed box. | |
| 499 | */ | |
| 500 | export const foldOpen = (asked: Asked): boolean => | |
| 501 | asked.match !== null || asked.badMatch || asked.map !== "de_forge" || asked.badMap || asked.room != null; | |
| 502 | ||
| 465 | 503 | const menu = (visitor: Who, asked: Asked, daily: Daily, planning: boolean): string => ` |
| 466 | 504 | <h2>Defuse or detonate</h2> |
| 467 | 505 | <p>Five against five on <b>de_forge</b>, on a map generated for the match, or on any generated |
| @@ -472,6 +510,20 @@ const menu = (visitor: Who, asked: Asked, daily: Daily, planning: boolean): stri | ||
| 472 | 510 | <label>Name<input id="name" maxlength="24" placeholder="you" autocomplete="nickname"${ |
| 473 | 511 | visitor.role === "anon" ? "" : ` value="${escape(visitor.name)}" readonly` |
| 474 | 512 | }></label> |
| 513 | </div> | |
| 514 | <p id="willgive">${escape(offerSaid(offerOf(asked)))}</p> | |
| 515 | <div id="willmap"> | |
| 516 | <canvas id="plan" width="192" height="192" aria-hidden="true"></canvas> | |
| 517 | <div id="willtext"> | |
| 518 | <p>Map <b id="willplay">${escape(asked.map === "generated" ? UNDRAWN : asked.map)}</b></p> | |
| 519 | <p class="hint" id="planalt">${escape(UNPLANNED)}</p> | |
| 520 | <a class="share" id="copymap" href="${escape(mapLink(asked.map))}">Copy this map's link</a> | |
| 521 | </div> | |
| 522 | </div> | |
| 523 | <div class="row"><button type="button" class="primary" id="play" disabled>Play</button></div> | |
| 524 | <details id="options"${foldOpen(asked) ? " open" : ""}> | |
| 525 | <summary>${escape(OPTIONS)}</summary> | |
| 526 | <div class="row"> | |
| 475 | 527 | <label>Length<select id="length"><option value="short">Short — first to ${num(SHORT.winScore)}</option><option value="long">Competitive — first to ${num(COMPETITIVE.winScore)}</option></select></label> |
| 476 | 528 | <label>Side<select id="side"><option value="ct">Counter-Terrorists</option><option value="t">Terrorists</option></select></label> |
| 477 | 529 | <label>Map<select id="map"><option value="de_forge">de_forge</option><option value="generated"${ |
| @@ -504,15 +556,6 @@ ${settingsHtml("")} | ||
| 504 | 556 | <button type="button" id="today">Pick today's match</button> |
| 505 | 557 | </div> |
| 506 | 558 | <p class="hint" id="ontoday">${escape(saidToday(daily))}</p> |
| 507 | <div id="willmap"> | |
| 508 | <canvas id="plan" width="192" height="192" aria-hidden="true"></canvas> | |
| 509 | <div id="willtext"> | |
| 510 | <p>Map <b id="willplay">${escape(asked.map === "generated" ? UNDRAWN : asked.map)}</b></p> | |
| 511 | <p class="hint" id="planalt">${escape(UNPLANNED)}</p> | |
| 512 | <a class="share" id="copymap" href="${escape(mapLink(asked.map))}">Copy this map's link</a> | |
| 513 | </div> | |
| 514 | </div> | |
| 515 | <div class="row"><button type="button" class="primary" id="play" disabled>Play</button></div> | |
| 516 | 559 | <div class="row"> |
| 517 | 560 | <label>Room code<input id="room" maxlength="6" placeholder="ABCDEF" autocapitalize="characters" |
| 518 | 561 | autocomplete="off" spellcheck="false"${asked.room == null ? "" : ` value="${escape(asked.room)}"`}></label> |
| @@ -529,6 +572,7 @@ ${settingsHtml("")} | ||
| 529 | 572 | When a room's match ends, the server puts it on the leaderboard for everyone |
| 530 | 573 | still seated — leave before the end and it is not counted for you. Rooms live in this process: a deploy |
| 531 | 574 | ends them, and an empty one is forgotten after ninety seconds.</p> |
| 575 | </details> | |
| 532 | 576 | <p class="hint" id="prep">Preparing textures…</p> |
| 533 | 577 | <p class="hint"><kbd>WASD</kbd> move · <kbd>Space</kbd> jump · <kbd>Ctrl</kbd> crouch · <kbd>Shift</kbd> walk · |
| 534 | 578 | 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 · |
src/pages/shell.ts +10 −1
| @@ -128,6 +128,15 @@ code { background:#141821; padding:.1rem .35rem; border-radius:.25rem; } | ||
| 128 | 128 | @media (max-width: 640px) { #stage { aspect-ratio:4/3; } h1 { font-size:1.2rem; } } |
| 129 | 129 | `; |
| 130 | 130 | |
| 131 | /** | |
| 132 | * The style block without its comments. They are this file's documentation — | |
| 133 | * why a box is the size it is — and a browser reads none of them, so they stay | |
| 134 | * in the source and never go over the wire: on `/` that is a few hundred bytes | |
| 135 | * of every first paint (#255). No rule here writes `/*` inside a string or a | |
| 136 | * `url()`, which is the one place this could cut into a rule. | |
| 137 | */ | |
| 138 | const uncommented = (css: string): string => css.replace(/[ \t]*\/\*[\s\S]*?\*\/[ \t]*\n?/g, ""); | |
| 139 | ||
| 131 | 140 | /** |
| 132 | 141 | * Exactly what a page with this extra CSS puts between its `<style>` tags: the |
| 133 | 142 | * shared block, then that page's own, escaped the way the document escapes it. |
| @@ -141,7 +150,7 @@ code { background:#141821; padding:.1rem .35rem; border-radius:.25rem; } | ||
| 141 | 150 | * which is why `extraStyle` is a constant in every page that has one. |
| 142 | 151 | */ |
| 143 | 152 | export const styleFor = (extraStyle?: string): string => |
| 144 | `${STYLE}${extraStyle === undefined ? "" : cssSafe(extraStyle)}`; | |
| 153 | uncommented(`${STYLE}${extraStyle === undefined ? "" : cssSafe(extraStyle)}`); | |
| 145 | 154 | |
| 146 | 155 | /** |
| 147 | 156 | * The icon, in the document rather than beside it: a crosshair on the page's |