diff --git a/web/scripts/camo-pick.test.mjs b/web/scripts/camo-pick.test.mjs index 9dc1fda..52ff2b7 100644 --- a/web/scripts/camo-pick.test.mjs +++ b/web/scripts/camo-pick.test.mjs @@ -1,11 +1,16 @@ /** - * The camo picker on the challenge screen, when there is nothing to pick. + * The camo picker, when there is nothing to pick. * * A player with no camo gets a sentence saying so and a tile that goes to the * editor. Both halves are easy to lose by accident: the empty branch is three * lines long and returns early, and the editor's address is a hash that any * refactor could write down again by hand and then forget when the route moves. * + * `camo-picker.ts` is shared between challenge.ts and lobby.ts (its own + * module doc says why); this reads the shared module rather than either + * screen, since the picker itself is what these checks are about, not + * either place it is mounted. + * * Structural, like the editor's own checks. There is no DOM under `npm test`, * and nothing here fails at runtime — a picker that offers no way out still * renders, and a link to a hash that means nothing lands the player on Home. @@ -21,7 +26,7 @@ import { fileURLToPath } from "node:url"; import { DESTINATIONS } from "../src/destinations.ts"; const web = fileURLToPath(new URL("../", import.meta.url)); -const source = await readFile(join(web, "src/screens/challenge.ts"), "utf8"); +const source = await readFile(join(web, "src/screens/camo-picker.ts"), "utf8"); test("an empty collection is offered the editor", () => { const branch = source.match(/if \(!camos\.length\) \{([\s\S]*?)\n {6}\}/); diff --git a/web/src/screens/camo-picker.ts b/web/src/screens/camo-picker.ts new file mode 100644 index 0000000..5120dcd --- /dev/null +++ b/web/src/screens/camo-picker.ts @@ -0,0 +1,232 @@ +/** + * The camo strip: challenge.ts's own picker, factored out once lobby.ts + * needed the identical thing rather than a second copy of it. + * + * This is the one piece of match setup that is not "duplicate the + * rendering, don't force it to share" the way lobby.ts's own module doc + * calls out for the rest of the two screens' overlap (seat/lineup rows, the + * list markup) — `scenarioPool`/`ChallengeMode` was already the one thing + * genuinely shared between them, and this is the second: the strip, its + * three-state selection (`undefined`/`null`/a record key), and the network + * read behind it are identical on both screens down to the copy, so sharing + * it is the same call `scenario-pool.ts` already made, not a new one. + * + * What differs between the two screens is what a selection *means*, and + * that is deliberately left to each caller through `onChange`, not decided + * here: challenge.ts sends `picker.rkey` once, at launch, as that match's + * own seat plan and nothing else. lobby.ts has no launch-time seat plan for + * a human seat to carry a camo on — `claim_seat` takes no camo field, and + * `MatchPlayer` has none — so it instead treats a *user-initiated* change + * as "make this my account default", the same `setDefaultCamo` write the + * camo editor's own "Set as default" already makes, since that is exactly + * what `lobby_seats_to_players` reads at Deploy time. `onChange`'s + * `userInitiated` flag is what tells the two apart: an auto-preselect (the + * strip choosing the account's existing default, or None when there is + * nothing to choose) must not itself trigger a write, or opening a lobby + * would silently set a default that was never asked for. + */ + +import { type Session } from "../api"; +import { DESTINATIONS } from "../destinations"; +import { el } from "../dom"; + +// Where the editor lives, read off the same list the masthead is built from +// rather than written down a second time. A hash on this page, so following it +// is a screen change and not a page load. +const CAMO_HREF = DESTINATIONS.find((d) => d.id === "camo")!.href; + +/** + * The picker's block, and the choice standing in it. + * + * `rkey` is read at the moment a caller needs it rather than captured once: + * the list arrives after the screen does, and the selection changes under + * it. + * + * Three states, not two. `undefined` is "I did not choose", which is what a + * caller that launches or claims before the list arrives sees. `null` is + * "no camo this match", which a player picking None means and which must + * not be confused with the first. A string is that camo. + */ +export type Picker = { + rkey: string | undefined | null; + node: Node; + /** Where each choosable camo's picture lives, filled as the list arrives. */ + images: Map; + /** + * Told after every selection change, whether the player made it + * (`true`) or the strip made it for them while nothing was chosen yet + * (`false`) — see the module doc for why the distinction exists. + */ + onChange: ((userInitiated: boolean) => void) | null; +}; + +/** + * Which camo to fight in. + * + * Read here, in the browser, straight from the player's own repository — the + * same public read the editor's collection does, and for the same reason. What + * a caller does with the chosen record key is its own decision — see the + * module doc — but the read, the strip and the three-state selection are the + * same on every screen that shows this. + * + * What arrives selected is the account's default if it has one, and the newest + * camo otherwise. A player who has marked one in the editor gets it every + * time without thinking about it; a player who has not gets the one they most + * likely just finished. Either way the choice is one click to change and zero + * to accept. + */ +export function camoPicker(session: Session): Picker { + const picker: Picker = { + rkey: undefined, + node: el("div"), + images: new Map(), + onChange: null, + }; + const strip = el("div", { className: "camo-choices" }); + const note = el("p", { + className: "hint", + textContent: "Reading your camo…", + }); + const block = el("div", { className: "choice camo-pick" }, [ + el("strong", { textContent: "Camo" }), + note, + strip, + ]); + picker.node = block; + + // The none option is always there and always first. A player who wants the + // plain colours should not have to own a camo to say so. + const buttons: HTMLButtonElement[] = []; + /** + * Whether the player has chosen for themselves yet. + * + * The strip is drawn before the collection arrives, so None is clickable + * during the fetch. Without this, the list landing a moment later would + * overwrite that click and launch the match in a camo they had just + * explicitly declined. + */ + let chosenByPlayer = false; + + function choose( + btn: HTMLButtonElement, + rkey: string | null, + userInitiated: boolean, + ): void { + picker.rkey = rkey; + for (const other of buttons) { + other.classList.toggle("active", other === btn); + other.setAttribute("aria-pressed", String(other === btn)); + } + picker.onChange?.(userInitiated); + } + + /** Select something only if the player has not already. */ + function preselect(btn: HTMLButtonElement, rkey: string | null): void { + if (chosenByPlayer) return; + choose(btn, rkey, false); + } + + function option(label: string, rkey: string | null, image?: string) { + const btn = el("button", { type: "button", className: "camo-choice" }); + btn.append( + image + ? el("img", { src: image, alt: "", width: 42, height: 36 }) + : el("span", { className: "camo-choice-none", textContent: "—" }), + el("span", { className: "camo-choice-name", textContent: label }), + ); + btn.addEventListener("click", () => { + chosenByPlayer = true; + choose(btn, rkey, true); + }); + buttons.push(btn); + strip.append(btn); + return btn; + } + + const plain = option("None", null); + + /** + * The way out of an empty strip. + * + * A player with nothing to choose between was being told so and left there; + * the editor is in the masthead, but reading a sentence about camo and then + * going looking for where camo is made is a step this screen can save. A + * tile rather than a line of text, so it sits in the strip beside None at + * the size of the camo that will replace it — dashed, and with no image, + * because it goes somewhere rather than being something to fight in. + */ + function makeOne(): void { + const link = el("a", { className: "camo-make", href: CAMO_HREF }); + // The plus stands in for the picture the other tiles have. Hidden from the + // accessibility tree so the link is named "Design one" and not "+ Design + // one". + const mark = el("span", { className: "camo-make-mark", textContent: "+" }); + mark.setAttribute("aria-hidden", "true"); + link.append( + mark, + el("span", { className: "camo-choice-name", textContent: "Design one" }), + ); + strip.append(link); + } + + // Lazily, and never fatally: this is the only thing on the screen that + // needs another host, and a match must still be startable when it is down. + void import("../camo/repo") + .then((repo) => repo.list(session.did)) + .then((camos) => { + if (!camos.length) { + note.textContent = "You have not made a camo yet."; + makeOne(); + preselect(plain, null); + return; + } + note.textContent = "Your units will be painted in this."; + // list() is newest first, so index 0 is the newest camo. + for (const camo of camos) { + if (camo.imageUrl) picker.images.set(camo.rkey, camo.imageUrl); + } + const made = camos.map((camo) => + option(camo.name, camo.rkey, camo.imageUrl ?? undefined), + ); + const marked = camos.findIndex( + (camo) => camo.rkey === session.defaultCamo, + ); + const start = marked === -1 ? 0 : marked; + preselect(made[start]!, camos[start]!.rkey); + }) + .catch((error: unknown) => { + // The picker stays on "not chosen", so a caller that reads it for a + // launch or a claim still applies whatever default the account has. + // Failing to read the list is no reason to launch in grey. + console.warn("camo picker: camo could not be listed", error); + note.textContent = + "Your camo could not be read; the match can still start."; + }); + + return picker; +} + +/** + * A camo image from the player's own PDS, as pixels — for a preview to + * paint a unit in, the way `scenario/preview.ts`'s compositor wants it. + * + * `crossOrigin` is what keeps the canvas readable: the PDS sends the CORS + * header (the camo editor depends on the same thing), and without the + * attribute the draw would taint the canvas and getImageData would throw. + */ +export function remoteCamoPixels(url: string): Promise { + return new Promise((resolve, reject) => { + const image = new Image(); + image.crossOrigin = "anonymous"; + image.onload = () => { + const canvas = document.createElement("canvas"); + canvas.width = 84; + canvas.height = 72; + const ctx = canvas.getContext("2d")!; + ctx.drawImage(image, 0, 0, 84, 72); + resolve(ctx.getImageData(0, 0, 84, 72)); + }; + image.onerror = () => reject(new Error("camo image did not load")); + image.src = url; + }); +} diff --git a/web/src/screens/challenge.ts b/web/src/screens/challenge.ts index dbf89bc..eee9bde 100644 --- a/web/src/screens/challenge.ts +++ b/web/src/screens/challenge.ts @@ -10,7 +10,6 @@ import { } from "../api"; import { showAvatar } from "../avatars"; import { footer } from "../chrome"; -import { DESTINATIONS } from "../destinations"; import { el, render } from "../dom"; import { withHandleTypeahead } from "../handle-typeahead"; import { nav } from "../nav"; @@ -18,181 +17,15 @@ import { nav } from "../nav"; // sprites — is fetched dynamically below, off the eager bundle the // analytics test keeps a ceiling on. import type { ScenarioPreview } from "../scenario/preview"; +import { camoPicker, remoteCamoPixels } from "./camo-picker"; import { scenarioPool, type ChallengeMode } from "./scenario-pool"; import { waitingScreen } from "./waiting"; export type { ChallengeMode } from "./scenario-pool"; -// Where the editor lives, read off the same list the masthead is built from -// rather than written down a second time. A hash on this page, so following it -// is a screen change and not a page load. -const CAMO_HREF = DESTINATIONS.find((d) => d.id === "camo")!.href; - /** What a seat select's value means: the player, a bot, or `p:`. */ type SeatChoice = "self" | "bot" | `p:${string}`; -/** - * The picker's block, and the choice standing in it. - * - * `rkey` is read at the moment Deploy is clicked rather than captured when the - * screen is built: the list arrives after the screen does, and the selection - * changes under it. - * - * Three states, not two. `undefined` is "I did not choose", which lets the API - * apply the account's default and is what a launch before the list arrives - * sends. `null` is "no camo this match", which a player picking None means and - * which must not be confused with the first. A string is that camo. - */ -type Picker = { - rkey: string | undefined | null; - node: Node; - /** Where each choosable camo's picture lives, filled as the list arrives. */ - images: Map; - /** Told after every selection change, the preview's cue to redraw. */ - onChange: (() => void) | null; -}; - -/** - * Which camo to fight in. - * - * Read here, in the browser, straight from the player's own repository — the - * same public read the editor's collection does, and for the same reason. The - * launch request then sends the record key rather than the picture, so what - * gets painted is what they published. - * - * What arrives selected is the account's default if it has one, and the newest - * camo otherwise. A player who has marked one in the editor gets it every - * time without thinking about it; a player who has not gets the one they most - * likely just finished. Either way the choice is one click to change and zero - * to accept, and changing it here changes this match only. - */ -function camoPicker(session: Session): Picker { - const picker: Picker = { - rkey: undefined, - node: el("div"), - images: new Map(), - onChange: null, - }; - const strip = el("div", { className: "camo-choices" }); - const note = el("p", { - className: "hint", - textContent: "Reading your camo…", - }); - const block = el("div", { className: "choice camo-pick" }, [ - el("strong", { textContent: "Camo" }), - note, - strip, - ]); - picker.node = block; - - // The none option is always there and always first. A player who wants the - // plain colours should not have to own a camo to say so. - const buttons: HTMLButtonElement[] = []; - /** - * Whether the player has chosen for themselves yet. - * - * The strip is drawn before the collection arrives, so None is clickable - * during the fetch. Without this, the list landing a moment later would - * overwrite that click and launch the match in a camo they had just - * explicitly declined. - */ - let chosenByPlayer = false; - - function choose(btn: HTMLButtonElement, rkey: string | null): void { - picker.rkey = rkey; - for (const other of buttons) { - other.classList.toggle("active", other === btn); - other.setAttribute("aria-pressed", String(other === btn)); - } - picker.onChange?.(); - } - - /** Select something only if the player has not already. */ - function preselect(btn: HTMLButtonElement, rkey: string | null): void { - if (chosenByPlayer) return; - choose(btn, rkey); - } - - function option(label: string, rkey: string | null, image?: string) { - const btn = el("button", { type: "button", className: "camo-choice" }); - btn.append( - image - ? el("img", { src: image, alt: "", width: 42, height: 36 }) - : el("span", { className: "camo-choice-none", textContent: "—" }), - el("span", { className: "camo-choice-name", textContent: label }), - ); - btn.addEventListener("click", () => { - chosenByPlayer = true; - choose(btn, rkey); - }); - buttons.push(btn); - strip.append(btn); - return btn; - } - - const plain = option("None", null); - - /** - * The way out of an empty strip. - * - * A player with nothing to choose between was being told so and left there; - * the editor is in the masthead, but reading a sentence about camo and then - * going looking for where camo is made is a step this screen can save. A - * tile rather than a line of text, so it sits in the strip beside None at - * the size of the camo that will replace it — dashed, and with no image, - * because it goes somewhere rather than being something to fight in. - */ - function makeOne(): void { - const link = el("a", { className: "camo-make", href: CAMO_HREF }); - // The plus stands in for the picture the other tiles have. Hidden from the - // accessibility tree so the link is named "Design one" and not "+ Design - // one". - const mark = el("span", { className: "camo-make-mark", textContent: "+" }); - mark.setAttribute("aria-hidden", "true"); - link.append( - mark, - el("span", { className: "camo-choice-name", textContent: "Design one" }), - ); - strip.append(link); - } - - // Lazily, and never fatally: this is the only thing on the screen that - // needs another host, and a match must still be startable when it is down. - void import("../camo/repo") - .then((repo) => repo.list(session.did)) - .then((camos) => { - if (!camos.length) { - note.textContent = "You have not made a camo yet."; - makeOne(); - preselect(plain, null); - return; - } - note.textContent = "Your units will be painted in this."; - // list() is newest first, so index 0 is the newest camo. - for (const camo of camos) { - if (camo.imageUrl) picker.images.set(camo.rkey, camo.imageUrl); - } - const made = camos.map((camo) => - option(camo.name, camo.rkey, camo.imageUrl ?? undefined), - ); - const marked = camos.findIndex( - (camo) => camo.rkey === session.defaultCamo, - ); - const start = marked === -1 ? 0 : marked; - preselect(made[start]!, camos[start]!.rkey); - }) - .catch((error: unknown) => { - // The picker stays on "not chosen", so the API still applies whatever - // default the account has. Failing to read the list is no reason to - // launch in grey. - console.warn("challenge: camo could not be listed", error); - note.textContent = - "Your camo could not be read; the match can still start."; - }); - - return picker; -} - /** * A camo for a bot seat, rolled here and rolled again next match. * @@ -233,30 +66,6 @@ async function blobPixels(blob: Blob): Promise { return ctx.getImageData(0, 0, 84, 72); } -/** - * A camo image from the player's own PDS, as pixels. - * - * `crossOrigin` is what keeps the canvas readable: the PDS sends the CORS - * header (the camo editor depends on the same thing), and without the - * attribute the draw would taint the canvas and getImageData would throw. - */ -function remoteCamoPixels(url: string): Promise { - return new Promise((resolve, reject) => { - const image = new Image(); - image.crossOrigin = "anonymous"; - image.onload = () => { - const canvas = document.createElement("canvas"); - canvas.width = 84; - canvas.height = 72; - const ctx = canvas.getContext("2d")!; - ctx.drawImage(image, 0, 0, 84, 72); - resolve(ctx.getImageData(0, 0, 84, 72)); - }; - image.onerror = () => reject(new Error("camo image did not load")); - image.src = url; - }); -} - /** "@handle", for chips and options; handles are stored bare. */ function at(handle: string): string { return `@${handle}`;