diff --git a/web/scripts/daily-play.test.mjs b/web/scripts/daily-play.test.mjs index 4f84e75..8e9e3aa 100644 --- a/web/scripts/daily-play.test.mjs +++ b/web/scripts/daily-play.test.mjs @@ -42,6 +42,11 @@ beforeEach(() => { globalThis.localStorage.store.clear(); }); +const sidesSource = await readFile( + new URL("../src/content/sides.ts", import.meta.url), + "utf8", +); + test("nothing is pending before anything stashes", () => { assert.equal(hasPendingDailyPlay(), false); assert.equal(takePendingDailyPlay(), null); @@ -262,42 +267,36 @@ test("a signed-out press comes back into the fight, not onto the front page", () ); }); -test("the two forces are one list, and the opposition is red rather than orange", () => { - const colours = daily.match(/export const SIDE_COLOURS[\s\S]*?\n\];/); +test("the team palette is blue for the player and warm for everyone else", () => { + const sides = sidesSource; + const colours = sides.match(/export const TEAM_COLOURS[\s\S]*?\n\];/); assert.ok( colours, - "SIDE_COLOURS moved, was renamed, or stopped being exported", + "TEAM_COLOURS moved, was renamed, or stopped being exported", ); const triples = [...colours[0].matchAll(/\[(\d+), (\d+), (\d+)\]/g)].map( (m) => m.slice(1).map(Number), ); - // At least two: the player's, and one for the opposition. More than two is - // how a challenge fields several opposing factions - Bug Company runs three - // pirate lances - and every one of those has to read as opposition. + // One per team, not one per force: a force is a shade of its team's colour + // (sideRgb), so this is how many *sides* can be told apart, not how many + // lances. Two is the floor - the player and an opposition. assert.ok( triples.length >= 2, - `SIDE_COLOURS has ${triples.length} colours; the player needs one and the opposition at least one`, + `TEAM_COLOURS has ${triples.length}; the player needs one and the ` + + `opposition at least one`, ); const [player, ...opposition] = triples; assert.ok( player[2] > player[0] && player[2] > player[1], - `the player's side is [${player}], which is not blue-dominant`, + `the player's team is [${player}], which is not blue-dominant`, ); - for (const opponent of opposition) { - assert.ok( - opponent[0] > opponent[2], - `an opposition colour is [${opponent}], which is not warm against the player's blue`, - ); - } // What separates a red from the burnt orange this replaced: an orange has a - // green channel well clear of its blue one. #b05c48 was 92 against 72. Only - // the first opposition colour is held to it - the extra lance colours are - // deliberately spread away from it so three enemies can be told apart. + // green channel well clear of its blue one. #b05c48 was 92 against 72. const first = opposition[0]; assert.ok( first[0] > first[1] && first[0] > first[2], - `the first opposition colour is [${first}], which is not red-dominant`, + `the first opposition team is [${first}], which is not red-dominant`, ); assert.ok( first[1] - first[2] <= 12, @@ -305,3 +304,21 @@ test("the two forces are one list, and the opposition is red rather than orange" `above blue, which reads as orange rather than red`, ); }); + +test("a force is a shade of its team, not a colour of its own", () => { + const sides = sidesSource; + const fn = sides.match(/export function sideRgb\([\s\S]*?\n\}/); + assert.ok(fn, "sideRgb moved or was renamed"); + assert.match( + fn[0], + /if \(slot <= 0\) return base;/, + "the first force of a team no longer wears its team's own colour, so a " + + "one-force side would be shaded away from the colour it is named by", + ); + assert.match( + fn[0], + /Math\.ceil\(slot \/ 2\) \* \(slot % 2 === 1 \? 1 : -1\)/, + "the shades no longer step outwards from the base - walking one way runs " + + "four lances into the background or into the labels", + ); +}); diff --git a/web/scripts/lobby-screen.test.mjs b/web/scripts/lobby-screen.test.mjs index 119abed..10d87a5 100644 --- a/web/scripts/lobby-screen.test.mjs +++ b/web/scripts/lobby-screen.test.mjs @@ -245,14 +245,14 @@ test("a card's colour is the side's own, so blue is always yours", () => { assert.ok(card, "forceCard moved or was renamed"); assert.match( card[0], - /card\.style\.setProperty\(\s*"--force-colour",\s*SIDE_CSS\[colourOf\(slot\.name\)\]/, + /card\.style\.setProperty\("--force-colour", sideCss\(ranks\.team, ranks\.slot\)\)/, "the card's colour no longer comes from the side it is on", ); - const pick = source.match(/function colourOf\([\s\S]*?\n \}/); - assert.ok(pick, "colourOf moved or was renamed"); + const pick = source.match(/function ranksOf\([\s\S]*?\n \}/); + assert.ok(pick, "ranksOf moved or was renamed"); assert.match( pick[0], - /sideColourIndex\(daily,/, + /sideRanks\(daily,/, "a daily no longer groups its forces by MegaMek team - three lances on " + "one team must not read as three separate opponents", ); @@ -605,13 +605,13 @@ test("launch() deploys a lobby, and starts a daily through its own endpoint", () assert.ok(fn, "launch moved, was renamed, or was never added"); assert.match( fn[0], - /startDaily\(daily\.slug, rolls, own\)/, + /startDaily\(daily\.slug, all, own\)/, "a daily no longer launches through POST /api/daily/ - the lobby " + "path would open a lobby row for a fight that has no seats to arrange", ); assert.match( fn[0], - /deployLobby\(id, rolls, own\)/, + /deployLobby\(id, all, own\)/, "an ordinary lobby no longer deploys through deployLobby", ); assert.match( @@ -847,9 +847,9 @@ test("a daily paints its two forces in the card's own colours", () => { assert.ok(side, "sideCamo moved, was renamed, or was never added"); assert.match( side[0], - /SIDE_COLOURS\[/, - "the lobby's side colours are no longer daily.ts's SIDE_COLOURS - two " + - "lists for the same two forces is one list that can be wrong", + /sideRgb\(team, slot\)/, + "the lobby's colours are no longer the shared team palette - two lists " + + "for the same forces is one list that can be wrong", ); }); diff --git a/web/src/content/sides.ts b/web/src/content/sides.ts new file mode 100644 index 0000000..cd0c76c --- /dev/null +++ b/web/src/content/sides.ts @@ -0,0 +1,109 @@ +/** + * What colour a force wears: its team's, shaded by which force of that team it + * is. + * + * Two questions in one answer, and the order matters. A reader has to see + * *which side* first and *which force of that side* second, so the team picks + * the colour and the force only varies it. Bug Company's three pirate lances + * are three shades of the same red rather than three unrelated hues, which is + * what says they are one side; the player's own force is blue, always. + * + * The variants are stepped wide enough to tell four lances apart on an icon + * the size of a unit sprite, which rules out the subtle end: a team that reads + * as one solid block is a team whose lances cannot be told from each other on + * the board, and the board is where it matters. + * + * These are also what a bot fights in. A lobby-assigned bot brings no camo of + * its own, and MegaMek's default is close enough to white that a scenario full + * of them is a screen full of identical pale machines — see `soloCamo` in + * screens/lobby.ts, which paints one of these instead. + */ + +/** Red, green, blue, each 0..255. */ +export type RGB = readonly [number, number, number]; + +/** + * One base colour per team, in the order teams are met — the player's team is + * always first, so blue is always yours. + * + * Blue, red, green, amber. The first two carry almost every fight there is and + * are the pair the daily card has always used; the second two exist for a + * scenario with three or four sides and are far enough apart in hue to survive + * being shaded. + */ +export const TEAM_COLOURS: readonly RGB[] = [ + [86, 132, 199], + // Green *below* blue, which puts the hue just past 0 into the cool half of + // red. Green above blue reads as orange once MegaMek's warm sprite art is + // multiplied through it. + [201, 42, 58], + [76, 158, 96], + [214, 152, 58], +]; + +/** RGB to HSL, both 0..1 except hue in degrees. */ +function toHsl([r, g, b]: RGB): [number, number, number] { + const [rr, gg, bb] = [r / 255, g / 255, b / 255]; + const max = Math.max(rr, gg, bb); + const min = Math.min(rr, gg, bb); + const l = (max + min) / 2; + const d = max - min; + if (d === 0) return [0, 0, l]; + const s = l > 0.5 ? d / (2 - max - min) : d / (max + min); + let h: number; + if (max === rr) h = ((gg - bb) / d + (gg < bb ? 6 : 0)) * 60; + else if (max === gg) h = ((bb - rr) / d + 2) * 60; + else h = ((rr - gg) / d + 4) * 60; + return [h, s, l]; +} + +/** HSL back to RGB, rounded to whole channels. */ +function toRgb([h, s, l]: [number, number, number]): RGB { + const c = (1 - Math.abs(2 * l - 1)) * s; + const x = c * (1 - Math.abs(((h / 60) % 2) - 1)); + const m = l - c / 2; + const seg = Math.floor((((h % 360) + 360) % 360) / 60); + const [r, g, b] = ( + [ + [c, x, 0], + [x, c, 0], + [0, c, x], + [0, x, c], + [x, 0, c], + [c, 0, x], + ] as const + )[seg]!; + return [ + Math.round((r + m) * 255), + Math.round((g + m) * 255), + Math.round((b + m) * 255), + ]; +} + +/** + * The colour of the `slot`th force on the `team`th team. + * + * `slot` 0 is the team's own base colour, so a side with one force — which is + * most of them — wears exactly the colour the team is named by, and the + * shading only appears when there is something to tell apart. + * + * Lightness carries the variation, alternating above and below the base so + * four forces spread across the range rather than fading in one direction into + * either the background or the labels. The small hue drift stops two steps of + * the same lightness reading as the same colour on a 40px icon. + */ +export function sideRgb(team: number, slot = 0): RGB { + const base = TEAM_COLOURS[team % TEAM_COLOURS.length]!; + if (slot <= 0) return base; + const [h, s, l] = toHsl(base); + // 1 -> +1, 2 -> -1, 3 -> +2 … so the steps walk outwards from the base. + const step = Math.ceil(slot / 2) * (slot % 2 === 1 ? 1 : -1); + const lightness = Math.min(0.78, Math.max(0.24, l + step * 0.11)); + return toRgb([h + step * 7, s, lightness]); +} + +/** The same colour as CSS, for text and rules beside the board. */ +export function sideCss(team: number, slot = 0): string { + const [r, g, b] = sideRgb(team, slot); + return `rgb(${r} ${g} ${b})`; +} diff --git a/web/src/screens/daily.ts b/web/src/screens/daily.ts index 6fdb174..40fc375 100644 --- a/web/src/screens/daily.ts +++ b/web/src/screens/daily.ts @@ -46,6 +46,7 @@ import { } from "../content/overrides"; import { ERA_ICONS } from "../content/trivia"; import { drawMinimap, haveBoards, minimapSize } from "./minimap"; +import { sideCss, sideRgb, type RGB } from "../content/sides"; /** * A unit on the schematic board: which machine, which row it stands in, and @@ -601,8 +602,7 @@ function drawMap(canvas: HTMLCanvasElement, challenge: Challenge): void { const stage = staging.getContext("2d")!; for (const [index, side] of challenge.sides.entries()) { - const colour = sideColourIndex(challenge, index); - const camo = flatCamo(SIDE_COLOURS[colour] ?? SIDE_COLOURS[0]!); + const camo = flatCamo(sideColour(challenge, index)); for (const { unit, x, y } of spotsFor(challenge, side)) { const sprite = await paintUnitSprite(unit.name, camo); if (!sprite) continue; @@ -642,46 +642,55 @@ function drawMap(canvas: HTMLCanvasElement, challenge: Challenge): void { * orange it replaces: orange and the blue both read as "a colour", where red * and blue read as two sides. */ -export const SIDE_COLOURS: readonly (readonly [number, number, number])[] = [ - [86, 132, 199], - // Green *below* blue, which puts the hue just past 0 into the cool half of - // red. The previous value had green above blue - hue about 4 degrees, on - // the orange side - and MegaMek's sprite art is warm enough that it read as - // orange once the camo was multiplied through it. - [201, 42, 58], - // Two more for the opposition, because a challenge may field several - // factions against the player and each needs to be told apart on the board. - // Both sit on the warm side with the red above: a reader has to see "one of - // theirs" first and "which of theirs" second, so these vary in lightness - // rather than running off around the wheel into the player's blue. - [230, 122, 74], - [166, 58, 106], -]; - /** - * The colour a faction paints in: the player's blue for anyone on the player's - * team, and one of the opposition colours for everyone else, in file order. + * Which team a side is on and which force of that team it is, as ranks. * - * By team and not by position, because a card with three enemy lances has the - * player's force first and three opponents after it, and all three of those - * have to read as opposition rather than as a third party. + * The player's team is always rank 0, so blue is always yours. Every other + * team ranks in the order it first appears, and a force ranks within its own + * team by the same order - so Bug Company's three pirate lances come out as + * team 1, forces 0, 1 and 2, which `sideRgb` turns into three shades of one + * red rather than three unrelated colours. */ -export function sideColourIndex(challenge: Challenge, index: number): number { +export function sideRanks( + challenge: Challenge, + index: number, +): { team: number; slot: number } { const sides = challenge.sides; - const mine = sides[0]?.team; const side = sides[index]; - if (!side) return 0; - const isMine = mine === undefined ? index === 0 : side.team === mine; - if (isMine) return 0; - // 1, 2, 3 … in the order the opposing factions appear. - const rank = sides + if (!side) return { team: 0, slot: 0 }; + const mine = sides[0]?.team; + // A file that names no teams is a free-for-all, and then a faction's own + // position is the only thing that can stand in for one. + const teamOf = (s: Side, i: number): string => + s.team === undefined ? `slot:${i}` : `team:${s.team}`; + const key = teamOf(side, index); + const mineKey = mine === undefined ? "slot:0" : `team:${mine}`; + + const seen: string[] = []; + for (const [i, s] of sides.entries()) { + const k = teamOf(s, i); + if (!seen.includes(k)) seen.push(k); + } + const others = seen.filter((k) => k !== mineKey); + const team = key === mineKey ? 0 : 1 + others.indexOf(key); + + const slot = sides .slice(0, index) - .filter((s) => (mine === undefined ? false : s.team !== mine)).length; - return 1 + (rank % (SIDE_COLOURS.length - 1)); + .filter((s, i) => teamOf(s, i) === key).length; + return { team, slot }; +} + +/** A side's colour, as the pixels a flat camo is made of. */ +export function sideColour(challenge: Challenge, index: number): RGB { + const { team, slot } = sideRanks(challenge, index); + return sideRgb(team, slot); } -/** The same two colours as CSS, for the figures beside the board. */ -export const SIDE_CSS = SIDE_COLOURS.map(([r, g, b]) => `rgb(${r} ${g} ${b})`); +/** The same colour as CSS, for text and rules beside the board. */ +export function sideCssFor(challenge: Challenge, index: number): string { + const { team, slot } = sideRanks(challenge, index); + return sideCss(team, slot); +} /** A whole sprite in one colour, for a force with no camo of its own. */ export function flatCamo([r, g, b]: readonly [ @@ -759,7 +768,11 @@ export function boardFigure(challenge: Challenge): HTMLElement { // The colour slot, not the faction's position: three opposing lances // each take their own opposition colour, and the tag has to agree with // the sprite above it. - tag.dataset.side = String(sideColourIndex(challenge, index)); + // 0 is the player's, and everything else is opposition - that is all the + // stylesheet needs to know. The exact shade rides alongside it so a tag + // matches the sprite above it even when three lances share one team. + tag.dataset.side = String(sideRanks(challenge, index).team); + tag.style.setProperty("--side-colour", sideCssFor(challenge, index)); tags.push(tag); } } @@ -953,13 +966,13 @@ function bvHeadToHead(challenge: Challenge): HTMLElement { : `${theirs.total} lances`; return el("div", { className: "daily-bv" }, [ - block(mine, SIDE_CSS[0] ?? "", "daily-bv-left", mine.name ?? "You"), + block(mine, sideCss(0), "daily-bv-left", mine.name ?? "You"), el("span", { className: "daily-bv-vs" }, [ el("span", { className: "daily-bv-system", textContent: "BV 2.0" }), // Rounded, so a hair of difference does not read as a real edge. gap < 0.015 ? "even" : `${Math.round(gap * 100)}%`, ]), - block(theirs, SIDE_CSS[1] ?? "", "daily-bv-right", theirLabel), + block(theirs, sideCss(1), "daily-bv-right", theirLabel), ]); } @@ -1101,10 +1114,7 @@ function terrainTile(challenge: Challenge): HTMLElement[] { .filter((side) => side.start) .map((side) => ({ start: side.start!, - colour: - SIDE_CSS[sideColourIndex(challenge, challenge.sides.indexOf(side))] ?? - SIDE_CSS[0] ?? - "", + colour: sideCssFor(challenge, challenge.sides.indexOf(side)), })); queueMicrotask(() => { diff --git a/web/src/screens/lobby.ts b/web/src/screens/lobby.ts index b08ddcc..78f7dc0 100644 --- a/web/src/screens/lobby.ts +++ b/web/src/screens/lobby.ts @@ -26,11 +26,10 @@ import { dailyScenario, flatCamo, scenarioBrief, - SIDE_COLOURS, - SIDE_CSS, - sideColourIndex, + sideRanks, type Challenge, } from "./daily"; +import { sideCss, sideRgb } from "../content/sides"; import { presetNamed } from "../content/megamek-text"; import { costText, forceTotals, statsFor, tonsText } from "../content/units"; import { scenarioPool, type ChallengeMode } from "./scenario-pool"; @@ -427,6 +426,34 @@ export function lobbyScreen(session: Session, entry: LobbyEntry): Node[] { return button; } + /** + * A force's own colour as a PNG, for a bot that brought nothing. + * + * A lobby-assigned bot has no repository to read a camo from, and MegaMek's + * default is close enough to white that a scenario full of them is a screen + * of identical pale machines. This paints it in the colour its card and its + * icons already use, so the board matches the setup screen. + * + * Rolled camo wins: pressing Shuffle is asking for something else. + */ + async function soloCamo(slotName: string): Promise { + const { team, slot } = ranksOf(slotName); + const [r, g, b] = sideRgb(team, slot); + const canvas = document.createElement("canvas"); + canvas.width = 84; + canvas.height = 72; + const ctx = canvas.getContext("2d")!; + ctx.fillStyle = `rgb(${r} ${g} ${b})`; + ctx.fillRect(0, 0, canvas.width, canvas.height); + return await new Promise((resolve, reject) => { + canvas.toBlob( + (blob) => + blob ? resolve(blob) : reject(new Error("camo did not rasterise")), + "image/png", + ); + }); + } + /** Decode a rolled camo to sprite-sized pixels for the compositor. */ async function blobPixels(blob: Blob): Promise { const bitmap = await createImageBitmap(blob); @@ -452,9 +479,10 @@ export function lobbyScreen(session: Session, entry: LobbyEntry): Node[] { return url ? remoteCamoPixels(url) : null; } - /** A daily force's flat colour, off the same list that colours the card. */ + /** A force's flat colour, the same one that colours its card. */ function sideCamo(slotName: string): ImageData { - return flatCamo(SIDE_COLOURS[colourOf(slotName)] ?? SIDE_COLOURS[0]!); + const { team, slot } = ranksOf(slotName); + return flatCamo(sideRgb(team, slot)); } /** @@ -472,28 +500,39 @@ export function lobbyScreen(session: Session, entry: LobbyEntry): Node[] { * side, and colouring them as three opponents would say the wrong thing * about who is fighting whom. */ - function colourOf(slotName: string): number { + function ranksOf(slotName: string): { team: number; slot: number } { if (daily) { const index = daily.sides.findIndex((side) => side.name === slotName); - return sideColourIndex(daily, index === -1 ? 0 : index); + return sideRanks(daily, index === -1 ? 0 : index); } const slots = current?.slots ?? []; - const mine = seats.find((seat) => seat.did === session.did)?.slot; - const position = slots.findIndex((slot) => slot.name === slotName); - if (mine === undefined) - return position <= 0 ? 0 : rankAmongOthers(position); - if (slotName === mine) return 0; - return rankAmongOthers(position); - } + const index = slots.findIndex((s) => s.name === slotName); + if (index === -1) return { team: 0, slot: 0 }; + const mineSlot = seats.find((seat) => seat.did === session.did)?.slot; + const keyOf = (s: ScenarioSlot, i: number): string => + s.team === undefined || s.team === null ? `slot:${i}` : `team:${s.team}`; + const key = keyOf(slots[index]!, index); + // Whichever team holds the seat you took is team 0. Before anybody has + // claimed anything there is no "yours", so the first force stands in. + const mineIndex = mineSlot + ? slots.findIndex((s) => s.name === mineSlot) + : 0; + const mineKey = keyOf( + slots[mineIndex] ?? slots[0]!, + Math.max(0, mineIndex), + ); - /** 1, 2, 3 … in slot order, wrapping rather than running off the list. */ - function rankAmongOthers(position: number): number { - const slots = current?.slots ?? []; - const mine = seats.find((seat) => seat.did === session.did)?.slot; - const before = slots - .slice(0, Math.max(0, position)) - .filter((slot) => slot.name !== (mine ?? slots[0]?.name)).length; - return 1 + (before % Math.max(1, SIDE_COLOURS.length - 1)); + const seen: string[] = []; + for (const [i, s] of slots.entries()) { + const k = keyOf(s, i); + if (!seen.includes(k)) seen.push(k); + } + const others = seen.filter((k) => k !== mineKey); + const team = key === mineKey ? 0 : 1 + others.indexOf(key); + const slot = slots + .slice(0, index) + .filter((s, i) => keyOf(s, i) === key).length; + return { team, slot }; } /** @@ -799,11 +838,9 @@ export function lobbyScreen(session: Session, entry: LobbyEntry): Node[] { // The side's own colour rather than a hue off the slot's position: yours // is blue and the opposition is red, the same two the daily card paints // its board in, so a force reads the same on both screens. - card.style.setProperty( - "--force-colour", - SIDE_CSS[colourOf(slot.name)] ?? SIDE_CSS[0]!, - ); - if (colourOf(slot.name) === 0) card.classList.add("force-card-mine"); + const ranks = ranksOf(slot.name); + card.style.setProperty("--force-colour", sideCss(ranks.team, ranks.slot)); + if (ranks.team === 0) card.classList.add("force-card-mine"); card.append( el("div", { className: "force-card-head" }, [ el("strong", { className: "force-card-name", textContent: slot.name }), @@ -1020,11 +1057,28 @@ export function lobbyScreen(session: Session, entry: LobbyEntry): Node[] { // 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); + return rolls; + }); if (daily) { - return startDaily(daily.slug, rolls, own).then((s) => s.matchId); + return filled + .then((all) => startDaily(daily.slug, all, own)) + .then((s) => s.matchId); } const id = matchId; - return id ? deployLobby(id, rolls, own) : null; + return id ? filled.then((all) => deployLobby(id, all, own)) : null; } deploy.addEventListener("click", () => { diff --git a/web/src/styles.css b/web/src/styles.css index d50e079..d0d3dc9 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -1683,9 +1683,14 @@ body:has(.hero) { /* Every side but the player's is opposition, however many of them there are. Keyed on "not the player" rather than on side 1, which left the second and - third enemy lances reading in the player's own blue. */ + third enemy lances reading in the player's own blue. + + `--side-colour` is that force's exact shade, set inline beside data-side, so + three lances of one team read as three shades of their team's red rather + than as one flat opposition colour. The literal is the fallback for a tag + drawn before the variable lands. */ .daily-unit-tag:not([data-side="0"]) .daily-unit-model { - color: rgb(240 150 158); + color: var(--side-colour, rgb(240 150 158)); } /* Tonnage and price under each force's battle value: the two figures a player