diff --git a/plan/complete/camo.md b/plan/complete/camo.md index c6071e6..de4f620 100644 --- a/plan/complete/camo.md +++ b/plan/complete/camo.md @@ -90,9 +90,8 @@ still durable server state, so it moves when the rest of the table does: stages a camo for any slot the manifest gives one, and `MatchHost.applyCamo` paints whichever slots the index names. - It is no longer on the live path. `rollCamoPng` is called only from - `web/src/screens/challenge.ts`, and Duel and Scenario now open the lobby - instead — where a bot seat gets no camo at all. The generator, the - re-encode and the manifest slot all still work; nothing reaches them. - Restoring it is [lobby](../lobby.md)'s deferred per-seat camo work, not a - change to anything here. + A roll is now only what a bot's camo *opens* on. Every bot seat carries + the same picker a human seat does — the challenge screen's and the + lobby's alike — so a player can put a camo of their own on the + opposition, or None for the force's flat colour. See + [lobby](../lobby.md). diff --git a/plan/lobby.md b/plan/lobby.md index 969f0c1..02d16d1 100644 --- a/plan/lobby.md +++ b/plan/lobby.md @@ -77,14 +77,16 @@ with `default_camo`, the soft-fail lookup an invited seat already uses, never caller's* request body and has no meaning for a "deploy whoever is seated" trigger. -Bot camo is no longer deferred: a bot handed an open slot is rolled one on the -spot, the Forces block has a Shuffle beside every bot force for a different -one, and `startDaily`/`deployLobby` carry the rolls in an optional `botCamo` -map so the camo shown is the camo run. - -What the roll is not is shared. Each client rolls its own, so two players in +Bot camo is no longer deferred, and it is no longer only a roll: a bot handed +an open slot gets the same picker a human seat has, opening on a roll because +a force with no account has no default of its own. The player can put any camo +of theirs on it, or None for the force's own colour, and +`startDaily`/`deployLobby` carry what was chosen in an optional `botCamo` map +so the camo shown is the camo run. + +What the choice is not is shared. Each client makes its own, so two players in one lobby see different camo on the same bot force, and whoever presses Deploy -sends theirs. Settling it for real means the server rolling and broadcasting +sends theirs. Settling it for real means the server deciding and broadcasting it, which is a backend slice nobody has cut. "Any seated player may deploy" is deferred; owner-only was the first slice. diff --git a/web/scripts/lobby-screen.test.mjs b/web/scripts/lobby-screen.test.mjs index 8bc0bf5..21b2862 100644 --- a/web/scripts/lobby-screen.test.mjs +++ b/web/scripts/lobby-screen.test.mjs @@ -903,7 +903,7 @@ test("launch() deploys a lobby, and starts a daily through its own endpoint", () ); assert.match( fn[0], - /\[\.\.\.botCamo\]\.map\(\(\[slot, held\]\) => \[slot, held\.blob\]/, + /pickedBlob\(entry\[1\]\.picker\)/, "the launch no longer carries the camo the player was actually shown - a " + "second roll on the way out would make the preview a lie", ); @@ -1149,20 +1149,22 @@ test("a daily paints its two forces in the card's own colours", () => { test("a camo change repaints one force, not every force on the screen", () => { // Monte Diablo fields twenty-four machines across four forces. Rebuilding - // all of them because one bot lance rerolled re-composited every sprite on - // the screen, which was slow enough to feel. - const roll = source.match(/async function rollFor\([\s\S]*?\n \}/); - assert.ok(roll, "rollFor moved or was renamed"); + // all of them because one bot lance changed camo re-composited every sprite + // on the screen, which was slow enough to feel. + const bot = source.match( + /function botCamoFor\(slotName: string\) \{[\s\S]*?\n \}/, + ); + assert.ok(bot, "botCamoFor moved or was renamed"); assert.match( - roll[0], - /repaint\.get\(slotName\)\?\.\(\)/, - "a roll no longer repaints just the force it rolled for", + bot[0], + /picker\.onChange = \(\) => repaint\.get\(slotName\)\?\.\(\)/, + "a bot's camo change no longer repaints just the force it was chosen for", ); assert.doesNotMatch( - roll[0], + bot[0], /renderSeats\(\)/, - "a roll rebuilds every force card again - that is the slow path this " + - "replaced", + "a camo change rebuilds every force card again - that is the slow path " + + "this replaced", ); const mine = source.match(/function repaintMine\([\s\S]*?\n \}/); assert.ok(mine, "repaintMine moved or was renamed"); @@ -1173,22 +1175,22 @@ test("a camo change repaints one force, not every force on the screen", () => { ); }); -test("a bot handed an open slot is rolled a camo, once", () => { - // challenge.ts rolls every AI slot without being asked. A lobby's bot wore - // nothing until somebody found Shuffle, so the two screens disagreed about - // what the same opposition looked like. - const fn = source.match(/function rollForNewBots\([\s\S]*?\n \}/); - assert.ok(fn, "rollForNewBots moved or was renamed"); +test("a bot handed an open slot is dressed, once", () => { + // challenge.ts dresses every AI slot without being asked. A lobby's bot + // wore nothing until somebody found Shuffle, so the two screens disagreed + // about what the same opposition looked like. + const fn = source.match(/function dressNewBots\([\s\S]*?\n \}/); + assert.ok(fn, "dressNewBots moved or was renamed"); assert.match( fn[0], /if \(seat\.control !== "bot"\) continue;/, - "a human seat is being rolled a camo it did not ask for", + "a human seat is being given a camo it did not ask for", ); assert.match( fn[0], /if \(botCamo\.has\(seat\.slot\)\) continue;/, - "a force that already has a roll is rerolled - Shuffle's result must " + - "survive the next seat update", + "a force that already has a picker is given a new one - what the player " + + "chose for it must survive the next seat update", ); assert.match( fn[0], @@ -1199,8 +1201,8 @@ test("a bot handed an open slot is rolled a camo, once", () => { // about to replace. assert.match( source, - /seats = next;\s*\n\s*renderSeats\(\);[\s\S]{0,200}?rollForNewBots\(before, next\)/, - "onSeats no longer rolls for a bot arriving, or rolls before the render " + + /seats = next;\s*\n\s*renderSeats\(\);[\s\S]{0,200}?dressNewBots\(before, next\)/, + "onSeats no longer dresses a bot arriving, or does it before the render " + "that builds the card it repaints", ); }); diff --git a/web/scripts/seat-camo.test.mjs b/web/scripts/seat-camo.test.mjs index 2734591..a95921f 100644 --- a/web/scripts/seat-camo.test.mjs +++ b/web/scripts/seat-camo.test.mjs @@ -1,16 +1,16 @@ /** - * The reroll control on a bot seat, on the challenge screen. + * The camo control on a challenge screen's seat rows. * - * A bot seat's camo is rolled once and cached in `rolled`, keyed by - * scenario-path and slot name, so the AI fights in exactly the pattern the - * preview showed. The reroll button's whole job is to clear that one cache - * entry, roll again, and tell the preview to redraw — get any of the three - * wrong and either nothing happens on click, or the preview and the launch - * end up disagreeing about the AI's camo again. + * Every seat that has a camo to choose shows the same swatch: the player's + * own on the seat they take, and the seat's own picker when the AI holds it. + * A bot's camo used to be a roll the screen made and a ⟳ to roll it again, + * which is not a choice — so the thing being checked here is that a bot seat + * gets a real picker, that it is kept per slot rather than rebuilt, and that + * the launch sends what that picker holds rather than a fresh roll. * * Structural, like this screen's other checks. There is no DOM under `npm - * test`, and a wrong wiring here does not throw — the button still renders - * and still looks clickable, it just does not do the one thing it exists for. + * test`, and a wrong wiring here does not throw — the swatch still renders + * and still looks clickable, it just paints something the match will not. * * Run with `npm test`. */ @@ -23,64 +23,67 @@ import { fileURLToPath } from "node:url"; const web = fileURLToPath(new URL("../", import.meta.url)); const source = await readFile(join(web, "src/screens/challenge.ts"), "utf8"); -function shuffleBlock() { - const block = source.match( - /const shuffle = el\("button", \{[\s\S]*?\}\);\s*const setShuffle[\s\S]*?shuffle\.addEventListener\("click", \(\) => \{([\s\S]*?)\}\);/, +test("a bot seat gets its own picker, opened as the same swatch", () => { + assert.match( + source, + /camoPicker\(session, "random", false\)/, + "a bot's picker no longer opens on a roll with the account's default " + + "left out — the AI would arrive wearing the player's marked camo", ); - assert.ok(block, "the reroll button's click handler moved or was renamed"); - return block[1]; -} - -test("reroll clears the cached roll before asking for a new one", () => { - const body = shuffleBlock(); - const deleteAt = body.indexOf("rolled.delete("); - const rerollAt = body.indexOf("rolledFor(slot.name)"); - assert.ok(deleteAt !== -1, "the click handler no longer clears the cache"); - assert.ok(rerollAt !== -1, "the click handler no longer rolls again"); - assert.ok( - deleteAt < rerollAt, - "rolledFor(slot.name) would just hand back the old roll unless the " + - "cache is cleared first", + assert.match( + source, + /camoModal\([\s\S]{0,60}"swatch"\)/, + "a bot's picker is no longer opened from the swatch every other camo " + + "choice on the site is opened from", ); }); -test("reroll clears the same key rolledFor reads", () => { - const keyed = source.match( - /function rolledFor\(slot: string\): Promise \{\s*const key = `([^`]*)`;/, +test("a bot's picker is kept by slot, not rebuilt with the row", () => { + const kept = source.match( + /function botCamoFor\(slot: string\) \{([\s\S]*?)\n \}/, ); - assert.ok(keyed, "rolledFor's cache key moved or was renamed"); - const body = shuffleBlock(); + assert.ok(kept, "botCamoFor moved or was renamed"); assert.match( - body, - /rolled\.delete\(`\$\{scenario\?\.path\}\|\$\{slot\.name\}`\)/, - `the click handler clears a different key than rolledFor reads ` + - `(rolledFor uses \`${keyed[1]}\`); a mismatch here leaves the old ` + - "roll in place", + kept[1], + /botCamo\.get\(slot\)/, + "botCamoFor no longer looks for a picker it already made, so every " + + "renderSlots would roll the AI a new camo", + ); + assert.match( + kept[1], + /botCamo\.set\(slot, held\)/, + "botCamoFor no longer keeps the picker it made", ); }); -test("reroll tells the preview to redraw", () => { - const body = shuffleBlock(); +test("a selection on a bot's picker redraws the preview", () => { + const kept = source.match( + /function botCamoFor\(slot: string\) \{([\s\S]*?)\n \}/, + ); assert.match( - body, - /refreshPreview\(\)/, - "the click handler no longer calls refreshPreview, so the preview " + - "would keep showing the old camo after a reroll", + kept[1], + /picker\.onChange = refreshPreview/, + "the preview would keep showing the camo the AI was wearing before", ); }); -test("the reroll button only shows on a bot seat", () => { +test("the launch sends what the seat's picker holds", () => { + const bot = source.match(/control: "bot",([\s\S]*?)\n \}\);/); + assert.ok(bot, "the launch's bot branch moved"); assert.match( - source, - /const setShuffle = \(choice: string\): void => \{\s*shuffle\.hidden = choice !== "bot";\s*\};/, - "the reroll button is no longer hidden for self/player seats — it has " + - "nothing to reroll for either", + bot[1], + /pickedBlob\(botCamoFor\(slot\.name\)\.picker\)/, + "the launch no longer sends the seat picker's own selection, so the " + + "AI would fight in something the player was never shown", ); +}); + +test("the swatch follows who holds the seat", () => { assert.match( source, - /select\.addEventListener\("change", \(\) => \{[\s\S]*?setShuffle\(select\.value\);[\s\S]*?\}\);/, - "the seat select's change handler no longer updates the reroll " + - "button's visibility, so it would keep showing after switching a " + - "seat away from the AI", + /select\.addEventListener\("change", \(\) => \{[\s\S]*?setCamo\(select\.value\);[\s\S]*?\}\);/, + "the seat select's change handler no longer moves the camo control, " + + "so a seat switched between you and the AI would keep the other's " + + "swatch", ); }); diff --git a/web/src/screens/camo-picker.ts b/web/src/screens/camo-picker.ts index 9444e6e..ee12143 100644 --- a/web/src/screens/camo-picker.ts +++ b/web/src/screens/camo-picker.ts @@ -122,6 +122,13 @@ export type Picker = { * roll, and null both for None and for "not chosen yet". */ imageUrl: string | null; + /** + * What is chosen, named: the camo's own name, "None", or "Random". A + * picture alone does not say whether a square of orange is a saved camo or + * the roll, and a swatch that names what it is holding is the difference + * between a control and a decoration. + */ + label: string; /** Where each choosable camo's picture lives, filled as the list arrives. */ images: Map; /** @@ -148,6 +155,11 @@ export type Picker = { * module doc — but the read, the strip and the three-state selection are the * same on every screen that shows this. * + * `ownDefault` is whether this picker is choosing for the player's own force. + * It is, everywhere the player is picking what *they* fight in; it is not on a + * bot's force, which has no account and no default of its own, and where + * opening on the player's marked camo would say the AI already wears it. + * * What arrives selected is the account's default if it has one, and the newest * camo otherwise. With nothing of the player's to select at all - signed out, * or signed in with an empty collection - `paint` decides: `"none"` stands on @@ -159,12 +171,14 @@ export type Picker = { export function camoPicker( session: Session | null, paint: "none" | "random" = "none", + ownDefault = true, ): Picker { const watchers: (() => void)[] = []; const picker: Picker = { rkey: undefined, rolled: null, imageUrl: null, + label: "None", node: el("div"), images: new Map(), watch: (fn) => { @@ -248,32 +262,37 @@ export function camoPicker( */ let chosenByPlayer = false; - function choose( - btn: HTMLButtonElement, - rkey: string | null, - userInitiated: boolean, - rolled: Blob | null = null, - imageUrl: string | null = null, - ): void { - picker.rkey = rkey; - picker.rolled = rolled; - picker.imageUrl = imageUrl; + /** One selection, named as well as pictured - see `Picker.label`. */ + type Chosen = { + rkey: string | null; + label: string; + userInitiated: boolean; + rolled?: Blob | null; + imageUrl?: string | null; + }; + + function choose(btn: HTMLButtonElement, chosen: Chosen): void { + picker.rkey = chosen.rkey; + picker.rolled = chosen.rolled ?? null; + picker.imageUrl = chosen.imageUrl ?? null; + picker.label = chosen.label; for (const other of buttons) { other.classList.toggle("active", other === btn); other.setAttribute("aria-pressed", String(other === btn)); } for (const watcher of watchers) watcher(); - picker.onChange?.(userInitiated); + picker.onChange?.(chosen.userInitiated); } /** Select something only if the player has not already. */ function preselect( btn: HTMLButtonElement, rkey: string | null, + label: string, imageUrl: string | null = null, ): void { if (chosenByPlayer) return; - choose(btn, rkey, false, null, imageUrl); + choose(btn, { rkey, label, userInitiated: false, imageUrl }); } function option(label: string, rkey: string | null, image?: string) { @@ -293,7 +312,12 @@ export function camoPicker( ); btn.addEventListener("click", () => { chosenByPlayer = true; - choose(btn, rkey, true, null, image ?? null); + choose(btn, { + rkey, + label, + userInitiated: true, + imageUrl: image ?? null, + }); }); buttons.push(btn); return btn; @@ -385,7 +409,13 @@ export function camoPicker( chosenByPlayer ||= userInitiated; // No rkey: there is no record to point at, which is what "ephemeral" // means here and what keeps it out of the account default. - choose(btn, null, userInitiated, blob, url); + choose(btn, { + rkey: null, + label: "Random", + userInitiated, + rolled: blob, + imageUrl: url, + }); })() .catch((error: unknown) => { console.warn("camo picker: a camo could not be rolled", error); @@ -407,7 +437,13 @@ export function camoPicker( return; } chosenByPlayer = true; - choose(btn, null, true, held, url); + choose(btn, { + rkey: null, + label: "Random", + userInitiated: true, + rolled: held, + imageUrl: url, + }); }); return { rollFor: roll }; @@ -427,7 +463,7 @@ export function camoPicker( function fallback(): void { if (chosenByPlayer) return; if (paint === "none") { - preselect(plain, null); + preselect(plain, null, "None"); return; } random.rollFor(false); @@ -480,7 +516,9 @@ export function camoPicker( fallback(); return; } - note.textContent = "Your units will be painted in this."; + note.textContent = ownDefault + ? "Your units will be painted in this." + : "These 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); @@ -492,6 +530,15 @@ export function camoPicker( for (let i = 0; i < made.length; i += PAGE_SIZE) { pages.push(made.slice(i, i + PAGE_SIZE)); } + // A force with no account behind it does not get the account's default: + // the collection is there to choose from, but nothing in it is what + // this force already wears. It opens on `paint` instead - see + // `ownDefault`. + if (!ownDefault) { + showPage(0); + fallback(); + return; + } const marked = camos.findIndex( (camo) => camo.rkey === session.defaultCamo, ); @@ -503,6 +550,7 @@ export function camoPicker( preselect( made[start]!, camos[start]!.rkey, + camos[start]!.name, camos[start]!.imageUrl ?? null, ); }) @@ -536,11 +584,18 @@ export function camoPicker( * `swatchButton`. The hangar takes the second, in its force's own heading. */ /** - * The choice as a square, for a header with no room for a word. + * The choice as a control: what is worn, what it is called, and the fact that + * pressing it opens something. * - * It draws what is chosen rather than naming it, which is the argument the - * strip's own tiles already make, at the size a card's header can hold. It - * follows the selection through `picker.watch` rather than `onChange`, + * A bare square of camo was a picture. It drew what was chosen and said + * nothing else - not that it could be pressed, not that the orange in it was + * a saved camo rather than a roll - so it read as a thumbnail of the force + * beside it. Three things fix that and none of them is a bigger square: the + * word "Camo" over the name of what is chosen, a chevron saying a chooser + * opens, and a frame that lifts under the pointer like every other button on + * the screen. + * + * It follows the selection through `picker.watch` rather than `onChange`, * because the screen it sits on owns that slot - the hangar is repainting * every sprite it has drawn through it. */ @@ -548,17 +603,35 @@ function swatchButton(picker: Picker, label: string): HTMLButtonElement { const button = el("button", { type: "button", className: "secondary camo-open camo-chosen", - title: label, - "aria-label": label, }); - const shown = el("img", { alt: "", width: 42, height: 36 }); + const shown = el("img", { className: "camo-chosen-art", alt: "" }); const plain = el("span", { className: "camo-choice-none", textContent: "—" }); - button.append(shown, plain); + const name = el("span", { className: "camo-chosen-name" }); + const caret = el("span", { + className: "camo-chosen-caret", + textContent: "▾", + }); + caret.setAttribute("aria-hidden", "true"); + button.append( + el("span", { className: "camo-chosen-art-box" }, [shown, plain]), + el("span", { className: "camo-chosen-text" }, [ + el("span", { className: "camo-chosen-kicker", textContent: "Camo" }), + name, + ]), + caret, + ); function paint(): void { const url = picker.imageUrl; if (url) shown.src = url; shown.hidden = !url; plain.hidden = !!url; + name.textContent = picker.label; + // Named in full to a screen reader and on hover, because the name itself + // is one line in a control this size and a camo called "i just liked this + // one" is cut to fit it. + const said = `${label} (currently ${picker.label})`; + button.title = said; + button.setAttribute("aria-label", said); } paint(); picker.watch(paint); @@ -624,6 +697,31 @@ export async function pickedPixels(picker: Picker): Promise { return url ? remoteCamoPixels(url) : null; } +/** + * The selection as bytes, for a launch that carries a picture rather than a + * key. + * + * A bot's force is the case: it has no repository, so there is no record for + * a seat plan to name and the camo rides along as the file itself. A roll is + * already bytes; a saved camo is fetched back from the picture the collection + * handed over. Null is None, and a fetch that fails is None too - a match is + * worth starting in plain colours. + */ +export async function pickedBlob(picker: Picker): Promise { + if (picker.rolled) return picker.rolled; + if (typeof picker.rkey !== "string") return null; + const url = picker.images.get(picker.rkey); + if (!url) return null; + try { + const response = await fetch(url); + if (!response.ok) throw new Error(`camo image: ${String(response.status)}`); + return await response.blob(); + } catch (error: unknown) { + console.warn("camo picker: a chosen camo could not be fetched", error); + return null; + } +} + /** Decode camo bytes to sprite-sized pixels for the compositor. */ async function blobPixels(blob: Blob): Promise { const bitmap = await createImageBitmap(blob); @@ -636,7 +734,7 @@ async function blobPixels(blob: Blob): Promise { return ctx.getImageData(0, 0, 84, 72); } -export function remoteCamoPixels(url: string): Promise { +function remoteCamoPixels(url: string): Promise { return new Promise((resolve, reject) => { const image = new Image(); image.crossOrigin = "anonymous"; diff --git a/web/src/screens/challenge.ts b/web/src/screens/challenge.ts index 9faf1e7..95ca6ca 100644 --- a/web/src/screens/challenge.ts +++ b/web/src/screens/challenge.ts @@ -17,7 +17,13 @@ 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 { camoModal, camoPicker, pickedPixels } from "./camo-picker"; +import { + camoModal, + camoPicker, + pickedBlob, + pickedPixels, + type Picker, +} from "./camo-picker"; import { scenarioPool, type ChallengeMode } from "./scenario-pool"; import { waitingScreen } from "./waiting"; @@ -26,46 +32,6 @@ export type { ChallengeMode } from "./scenario-pool"; /** What a seat select's value means: the player, a bot, or `p:`. */ type SeatChoice = "self" | "bot" | `p:${string}`; -/** - * A camo for a bot seat, rolled here and rolled again next match. - * - * The bot has no account, so there is nothing to read and nothing to pick from - * — the generator that gives the editor its opening hand is asked for one - * picture instead. Nothing is saved: it is not named, it goes in no - * repository, and the next match gets a different one. - * - * Loaded on the click rather than with the screen. The pattern library is the - * largest thing on the site and a player who never launches a match should - * never fetch it. Never fatal, for the same reason the player's own camo is - * not: the match is worth starting in default colours. - */ -async function rollBotCamo(): Promise { - try { - const { rollCamoPng } = await import("../camo/random"); - return await rollCamoPng(); - } catch (error: unknown) { - console.warn("challenge: a bot camo could not be rolled", error); - return undefined; - } -} - -/** A rolled camo held as both halves: the bytes the launch sends, and the - * pixels the preview draws. One roll serving both is what makes the preview - * honest — the AI fights in exactly the pattern it was shown wearing. */ -type Rolled = { blob: Blob; pixels: ImageData }; - -/** Decode a camo blob to sprite-sized pixels for the compositor. */ -async function blobPixels(blob: Blob): Promise { - const bitmap = await createImageBitmap(blob); - const canvas = document.createElement("canvas"); - canvas.width = 84; - canvas.height = 72; - const ctx = canvas.getContext("2d")!; - ctx.drawImage(bitmap, 0, 0, 84, 72); - bitmap.close(); - return ctx.getImageData(0, 0, 84, 72); -} - /** "@handle", for chips and options; handles are stored bare. */ function at(handle: string): string { return `@${handle}`; @@ -157,11 +123,30 @@ export function challengeScreen(session: Session, mode: ChallengeMode): Node[] { const seats = new Map(); /** - * One rolled camo per AI seat per scenario, kept as a promise so every - * hex of a force draws the same roll, and reused at launch so the AI - * fights in exactly what the preview showed. + * One picker per AI seat, kept by slot name so a scenario change or a + * seat rebuild does not throw away what the seat is wearing. + * + * A bot's camo used to be a roll this screen made for it and the player + * could only reroll. It is a choice now, made the same way their own is: + * the same strip, the same dialog, the same swatch - opening on a roll, + * because a force with no account has no default to open on. */ - const rolled = new Map>(); + const botCamo = new Map< + string, + { picker: Picker; button: HTMLButtonElement; dialog: HTMLDialogElement } + >(); + + function botCamoFor(slot: string) { + let held = botCamo.get(slot); + if (!held) { + const picker = camoPicker(session, "random", false); + picker.onChange = refreshPreview; + const opened = camoModal(picker, `Choose ${slot}'s camo`, "swatch"); + held = { picker, ...opened }; + botCamo.set(slot, held); + } + return held; + } /** * The preview, mounted when its chunk lands. Lazily and never fatally, @@ -180,20 +165,6 @@ export function challengeScreen(session: Session, mode: ChallengeMode): Node[] { console.warn("challenge: the scenario preview could not load", error); }); - function rolledFor(slot: string): Promise { - const key = `${scenario?.path}|${slot}`; - let kept = rolled.get(key); - if (!kept) { - kept = (async () => { - const blob = await rollBotCamo(); - if (!blob) return null; - return { blob, pixels: await blobPixels(blob) }; - })(); - rolled.set(key, kept); - } - return kept; - } - /** Who holds a slot, the way the preview captions it. */ function describeSlot(slot: string): string { const choice = seats.get(slot) ?? "bot"; @@ -202,14 +173,15 @@ export function challengeScreen(session: Session, mode: ChallengeMode): Node[] { return at(choice.slice(2)); } - /** The camo a force previews in: yours as picked, the AI's as rolled, an - * invited player's not at all — theirs is theirs to bring. */ + /** The camo a force previews in: yours and the AI's as each is picked, an + * invited player's not at all — theirs is theirs to bring. + * + * Through the picker's own reader rather than its record key: a roll is + * bytes and has no key, so reading the key previewed it as bare grey. */ async function camoForSlot(slot: string): Promise { const choice = seats.get(slot) ?? "bot"; - if (choice === "bot") return (await rolledFor(slot))?.pixels ?? null; + if (choice === "bot") return pickedPixels(botCamoFor(slot).picker); if (choice !== "self") return null; - // Through the picker's own reader rather than its record key: a roll is - // bytes and has no key, so reading the key previewed it as bare grey. return pickedPixels(camo); } @@ -582,38 +554,27 @@ export function challengeScreen(session: Session, mode: ChallengeMode): Node[] { } }; setFace(seats.get(slot.name) ?? "bot"); - // A fresh roll for this seat's bot camo, only meaningful while it is - // actually a bot seat — a self or player seat has nothing here to - // reroll. Hidden rather than absent, so the row does not reflow when - // the select flips it on. - const shuffle = el("button", { - type: "button", - className: "secondary small-btn seat-shuffle", - textContent: "⟳", - "aria-label": `Reroll ${slot.name}'s camo`, - }); - const setShuffle = (choice: string): void => { - shuffle.hidden = choice !== "bot"; - }; - setShuffle(seats.get(slot.name) ?? "bot"); - // The camo, on the seat it paints. One swatch for the screen, moved to - // whichever row the player is holding - there is one selection, and - // exactly one seat can be `self`. A row rebuilt for a new scenario - // takes it back here, which is why this runs on build as well as on - // change. + // The camo, on the seat it paints, whoever is holding it: the player's + // own swatch on the seat they take, and that seat's bot swatch when the + // AI has it. A seat handed to another player shows none - their camo is + // theirs to bring, and this screen has no way to know it. + // + // The player's swatch is one control moved between rows rather than one + // per row: there is one selection, and exactly one seat can be `self`. + // A row rebuilt for a new scenario takes it back, which is why this + // runs on build as well as on change. const setCamo = (choice: string): void => { + for (const child of [...pick.children]) { + if (child instanceof HTMLDialogElement) child.remove(); + else if (child.classList.contains("camo-chosen")) child.remove(); + } if (choice === "self") { pick.append(camoOpen.button, camoOpen.dialog); - } else if (camoOpen.button.parentElement === pick) { - camoOpen.button.remove(); - camoOpen.dialog.remove(); + } else if (choice === "bot") { + const held = botCamoFor(slot.name); + pick.append(held.button, held.dialog); } }; - shuffle.addEventListener("click", () => { - rolled.delete(`${scenario?.path}|${slot.name}`); - void rolledFor(slot.name); - refreshPreview(); - }); const options: [SeatChoice, string][] = [ ["self", `You${session.handle ? ` (${at(session.handle)})` : ""}`], ["bot", "Play against AI"], @@ -622,11 +583,7 @@ export function challengeScreen(session: Session, mode: ChallengeMode): Node[] { at(handle), ]), ]; - const pick = el("div", { className: "seat-pick" }, [ - face, - select, - shuffle, - ]); + const pick = el("div", { className: "seat-pick" }, [face, select]); const current = seats.get(slot.name) ?? "bot"; for (const [value, label] of options) { select.append( @@ -640,7 +597,6 @@ export function challengeScreen(session: Session, mode: ChallengeMode): Node[] { select.addEventListener("change", () => { seats.set(slot.name, select.value as SeatChoice); setFace(select.value); - setShuffle(select.value); setCamo(select.value); updateNext(); // The seat's camo and caption both follow who holds it. @@ -723,14 +679,14 @@ export function challengeScreen(session: Session, mode: ChallengeMode): Node[] { if (choice === "self") { plan.push({ slot: slot.name, control: "self", camo: camo.rkey }); } else if (choice === "bot") { - // The roll the preview drew, not a fresh one: the AI fights in - // exactly the pattern the player was shown. Per seat, so a + // What the seat's own picker holds, not a fresh roll: the AI fights + // in exactly the pattern the player was shown. Per seat, so a // scenario with three of them is not three copies of one camo. - const kept = await rolledFor(slot.name); plan.push({ slot: slot.name, control: "bot", - botCamo: kept?.blob, + botCamo: + (await pickedBlob(botCamoFor(slot.name).picker)) ?? undefined, }); } else { plan.push({ diff --git a/web/src/screens/lobby.ts b/web/src/screens/lobby.ts index 78e1c88..6d552c8 100644 --- a/web/src/screens/lobby.ts +++ b/web/src/screens/lobby.ts @@ -21,7 +21,13 @@ import { footer } from "../chrome"; import { el, onTeardown, render } from "../dom"; import { withHandleTypeahead } from "../handle-typeahead"; import { nav } from "../nav"; -import { camoModal, camoPicker, remoteCamoPixels } from "./camo-picker"; +import { + camoModal, + camoPicker, + pickedBlob, + pickedPixels, + type Picker, +} from "./camo-picker"; import { pilotHover, type PilotFacts } from "./pilot-hover"; import { challengeBySlug, @@ -423,16 +429,37 @@ export function lobbyScreen(session: Session, entry: LobbyEntry): Node[] { let renderSeq = 0; /** - * A rolled camo per bot force, kept so the picture and the match agree. + * A picker per bot force, kept by slot so a re-render does not throw away + * what the force is wearing. * - * Rolled here rather than server-side and held rather than re-rolled at + * Chosen here rather than server-side and held rather than re-rolled at * launch, for the same reason challenge.ts holds its own: the player is * shown what the opposition will be wearing, and a second roll on the way - * out would make that a lie. Filled when a bot takes an open slot and by - * Shuffle after that; a force that never had a bot on it has no entry, and - * keeps the flat side colour it has always had. + * out would make that a lie. + * + * A bot's camo used to be a roll and a Shuffle button, which is not a + * choice - the player could ask for a different one but never for a + * particular one. It is the same picker their own force uses now, opening + * on a roll rather than on their marked default: a force with no account + * has no default of its own. A daily's opposition opens on None instead, + * which is the flat side colour that screen has always drawn. */ - const botCamo = new Map(); + const botCamo = new Map< + string, + { picker: Picker; button: HTMLButtonElement; dialog: HTMLDialogElement } + >(); + + function botCamoFor(slotName: string) { + let held = botCamo.get(slotName); + if (!held) { + const picker = camoPicker(session, daily ? "none" : "random", false); + picker.onChange = () => repaint.get(slotName)?.(); + const opened = camoModal(picker, `Choose ${slotName}'s camo`, "swatch"); + held = { picker, ...opened }; + botCamo.set(slotName, held); + } + return held; + } /** * How to repaint one force's unit icons, keyed by slot, rebuilt by every @@ -465,7 +492,7 @@ export function lobbyScreen(session: Session, entry: LobbyEntry): Node[] { const camoOpen = camoModal(camo, "Choose camo", "swatch"); /** The camo a force's units paint in: yours, as picked, on your own - * claimed seat; a bot's roll on a seat it was handed (see `rollForNewBots`); + * claimed seat; a bot's own on a seat it was handed (see `dressNewBots`); * none on a seat belonging to another player, whose own choice is theirs to * bring, not something this client has any way to know before Deploy * settles it. */ @@ -475,78 +502,39 @@ export function lobbyScreen(session: Session, entry: LobbyEntry): Node[] { // bare: the player's blue and the opposition's red, off the same list the // card's own map paints from (daily.ts's SIDE_COLOURS). A camo the player // has picked still wins on the force they are taking — that is the "full - // camo" half of what this screen is for — and the bot has none to bring. - const rolled = botCamo.get(slotName); - if (rolled) return rolled.pixels; + // camo" half of what this screen is for — and the bot wears whatever the + // player chose for it, its own colour being what None means here. + const bot = botCamo.get(slotName); + const chosen = bot ? await pickedPixels(bot.picker) : null; + if (chosen) return chosen; if (daily) return (await ownCamo(seat)) ?? sideCamo(slotName); if (!seat || seat.did !== session.did) return null; + if (bot) return sideCamo(slotName); return ownCamo(seat); } /** - * Roll one bot force a camo and repaint that force in it. - * - * The same generator that gives the camo editor its opening hand, and the - * same bargain challenge.ts's own bot camo makes: nothing is saved, it is - * not named, it goes in no repository, and the next match rolls again. The - * one rule is that the roll shown is the roll sent — see `botCamo` above. - */ - async function rollFor(slotName: string): Promise { - const { rollCamoPng } = await import("../camo/random"); - const blob = await rollCamoPng(); - botCamo.set(slotName, { blob, pixels: await blobPixels(blob) }); - repaint.get(slotName)?.(); - } - - /** - * A bot landing on an open slot rolls that force a camo, once. + * A bot landing on an open slot gets its own picker, once. * - * challenge.ts has always rolled every AI slot without being asked, and a + * challenge.ts has always dressed every AI slot without being asked, and a * lobby's bot arrived in nothing at all until somebody found Shuffle. The - * roll happens on the seat arriving rather than on the click that sent it, - * so a force the other player hands to the bot is painted here too. + * picker is built on the seat arriving rather than on the click that sent + * it, so a force the other player hands to the bot is painted here too, and + * it opens on a roll - see `botCamo`. * - * Only a slot that nobody held before: a force that already has a roll keeps - * it, and a bot replacing a human keeps whatever that seat was wearing - * rather than rerolling out from under the picture. + * Only a slot that nobody held before: a force that already has a picker + * keeps it, and a bot replacing a human keeps whatever that seat was + * wearing rather than rerolling out from under the picture. */ - function rollForNewBots(before: MatchPlayer[], after: MatchPlayer[]): void { + function dressNewBots(before: MatchPlayer[], after: MatchPlayer[]): void { for (const seat of after) { if (seat.control !== "bot") continue; if (botCamo.has(seat.slot)) continue; if (before.some((held) => held.slot === seat.slot)) continue; - rollFor(seat.slot).catch((error: unknown) => { - // The force falls back to its flat side colour, which is what it wore - // before this rolled anything. Nothing about the match is blocked. - console.warn("lobby: a bot camo could not be rolled", error); - }); + botCamoFor(seat.slot); } } - /** Reroll what a bot force is painted in, on demand. */ - function shuffleFor(slotName: string): HTMLElement { - const button = el("button", { - type: "button", - className: "secondary small-btn camo-open", - textContent: "Shuffle camo", - }); - button.addEventListener("click", () => { - if (button.disabled) return; - button.disabled = true; - void rollFor(slotName) - .catch((error: unknown) => { - // The force keeps whatever it was wearing, which is a camo either - // way. Nothing about the match is blocked on this. - console.warn("lobby: a bot camo could not be rolled", error); - button.disabled = false; - }) - .finally(() => { - button.disabled = false; - }); - }); - return button; - } - /** * A force's own colour as a PNG, for a bot that brought nothing. * @@ -575,29 +563,16 @@ export function lobbyScreen(session: Session, entry: LobbyEntry): Node[] { }); } - /** Decode a rolled camo to sprite-sized pixels for the compositor. */ - async function blobPixels(blob: Blob): Promise { - const bitmap = await createImageBitmap(blob); - const canvas = document.createElement("canvas"); - canvas.width = 84; - canvas.height = 72; - const ctx = canvas.getContext("2d")!; - ctx.drawImage(bitmap, 0, 0, 84, 72); - bitmap.close(); - return ctx.getImageData(0, 0, 84, 72); - } - - /** The camo the player has picked, on their own seat and nowhere else. */ + /** The camo the player has picked, on their own seat and nowhere else. + * + * Through the picker's own reader, which resolves both halves of a + * selection: the player is shown the camo the launch will actually carry, + * a roll included - a roll is bytes and has no record key to read. */ async function ownCamo( seat: MatchPlayer | undefined, ): Promise { if (!seat || seat.did !== session.did) return null; - // The roll, when there is one: the player is shown the camo the launch - // will actually carry, the same rule the bot's Shuffle follows. - if (camo.rolled) return blobPixels(camo.rolled); - const url = - typeof camo.rkey === "string" ? camo.images.get(camo.rkey) : undefined; - return url ? remoteCamoPixels(url) : null; + return pickedPixels(camo); } /** A force's flat colour, the same one that colours its card. */ @@ -1226,7 +1201,10 @@ export function lobbyScreen(session: Session, entry: LobbyEntry): Node[] { ? daily.sides[0]?.name === slotName : seat?.did === session.did; if (mine) return [camoOpen.button, camoOpen.dialog]; - if (daily || seat?.control === "bot") return [shuffleFor(slotName)]; + if (daily || seat?.control === "bot") { + const held = botCamoFor(slotName); + return [held.button, held.dialog]; + } return []; } @@ -1592,27 +1570,34 @@ export function lobbyScreen(session: Session, entry: LobbyEntry): Node[] { * arrived — which is what the click handler checks instead of matchId. */ function launch(): Promise | null { - // The rolls the player was shown, not a fresh set: what the Forces block - // drew is what the match runs. - const rolls = new Map( - [...botCamo].map(([slot, held]) => [slot, held.blob] as const), + // What the player was shown, not a fresh set: what the Forces block drew + // is what the match runs. + const chosen = Promise.all( + [...botCamo].map( + async (entry) => [entry[0], await pickedBlob(entry[1].picker)] as const, + ), ); // The player's own roll, when they took one. Bytes rather than a record // key, because an ephemeral camo has no key - see camo-picker.ts. const own = camo.rolled ?? undefined; - // Every other bot force gets its own colour rather than MegaMek's white. - const plain = (current?.slots ?? []) - .filter((s) => { - if (rolls.has(s.name)) return false; - if (daily) return daily.sides[0]?.name !== s.name; - const seat = seats.find((x) => x.slot === s.name); - return seat?.control === "bot"; - }) - .map((s) => s.name); - const filled = Promise.all( - plain.map(async (slot) => [slot, await soloCamo(slot)] as const), - ).then((pairs) => { - for (const [slot, blob] of pairs) rolls.set(slot, blob); + const filled = chosen.then(async (pairs) => { + const rolls = new Map(); + for (const [slot, blob] of pairs) if (blob) rolls.set(slot, blob); + // Every other bot force gets its own colour rather than MegaMek's + // white - which is also what None means on a bot's picker. + const plain = (current?.slots ?? []) + .filter((s) => { + if (rolls.has(s.name)) return false; + if (daily) return daily.sides[0]?.name !== s.name; + const seat = seats.find((x) => x.slot === s.name); + return seat?.control === "bot"; + }) + .map((s) => s.name); + for (const [slot, blob] of await Promise.all( + plain.map(async (slot) => [slot, await soloCamo(slot)] as const), + )) { + rolls.set(slot, blob); + } return rolls; }); if (daily) { @@ -1691,7 +1676,7 @@ export function lobbyScreen(session: Session, entry: LobbyEntry): Node[] { renderSeats(); // After the render, so a roll that lands repaints the card this call // just built rather than one already replaced. - rollForNewBots(before, next); + dressNewBots(before, next); // Who is seated decides whether this player has a Ready button at all, // and who the rest of the room is still waiting for. renderReady(); diff --git a/web/src/styles.css b/web/src/styles.css index 4e6c5ef..c8bd3bc 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -3580,32 +3580,90 @@ footer .debug { flex: none; } -/* The same button as a square, for a card header with no room for a word: it - draws what is chosen instead of naming it. `.camo-chosen` and not - `.camo-swatch`, which is the camo editor's own preview and is sized in - hundreds of pixels. */ +/* The camo choice as a control rather than a picture: the swatch, the word + "Camo", the name of what is chosen, and a chevron for the chooser it opens. + `.camo-chosen` and not `.camo-swatch`, which is the camo editor's own + preview and is sized in hundreds of pixels. */ .camo-chosen { - display: block; + display: inline-flex; + gap: 0.5rem; + align-items: center; /* `button` sets `align-self: flex-start`, which in a header line would sit - the square above the heading's baseline rather than beside it. */ + the control above the heading's baseline rather than beside it. */ align-self: center; - padding: 0.2rem; - line-height: 0; - border-radius: 4px; + padding: 0.25rem 0.5rem 0.25rem 0.25rem; + line-height: 1; + text-align: left; + border: 1px solid var(--line); + border-radius: var(--radius); cursor: pointer; } -.camo-chosen img { +/* Lit on the way in, the same accent the strip's own tiles take when they are + the one selected - so the control reads as the door to that strip. */ +.camo-chosen:hover, +.camo-chosen:focus-visible { + border-color: var(--accent); + background: color-mix(in srgb, var(--accent) 10%, var(--panel)); +} + +/* Holds the square whichever half of it is showing, so the control keeps its + width when a camo is swapped for None. */ +.camo-chosen-art-box { + display: grid; + place-items: stretch; + width: 42px; + height: 36px; + overflow: hidden; + background: var(--bg); + border-radius: 2px; +} + +.camo-chosen-art { display: block; width: 42px; height: 36px; image-rendering: pixelated; - border-radius: 2px; +} + +.camo-chosen-text { + display: grid; + gap: 0.15rem; + min-width: 4.5rem; + max-width: 8rem; +} + +/* What the control is, said once and quietly. Without it the square named no + subject at all and could as easily have been the force's badge. */ +.camo-chosen-kicker { + font-size: 0.6rem; + letter-spacing: 0.08em; + color: var(--muted); + text-transform: uppercase; +} + +/* What is chosen, named. Cut to the control's width rather than stretching + it: the whole name is in the title and the accessible name. */ +.camo-chosen-name { + overflow: hidden; + font-size: 0.8rem; + /* The player named this camo; the button styling that shouts every other + label would hand it back in capitals. */ + letter-spacing: normal; + text-transform: none; + color: var(--ink); + text-overflow: ellipsis; + white-space: nowrap; +} + +.camo-chosen-caret { + font-size: 0.7rem; + color: var(--muted); } /* A `display` in a stylesheet beats the `hidden` attribute, and both halves of the swatch are hidden in turn - so both have to say so. */ -.camo-chosen img[hidden], +.camo-chosen-art[hidden], .camo-choice-none[hidden] { display: none; } @@ -3754,13 +3812,6 @@ footer .debug { object-fit: cover; } -/* Rerolls a bot seat's camo. Chrome from .small-btn; only the glyph is its - own, and line-height keeps a lone character centred in the padding. */ -.seat-shuffle { - padding: 0.35rem 0.5rem; - line-height: 1; -} - .pilot-tile:not([disabled]):hover { background: color-mix(in srgb, var(--accent) 8%, var(--bg)); } @@ -7344,11 +7395,12 @@ footer .debug { } /* Holds the swatch, and holds its place before the session read answers, so - the heading does not shift when it arrives. */ + the heading does not shift when it arrives. Sized to the control rather + than to the square it used to be. */ .hangar-camo { display: block; - min-width: 48px; - min-height: 42px; + min-width: 10.5rem; + min-height: 44px; } .hangar-force-curve {