diff --git a/src/client/render/gl/RenderSettings.ts b/src/client/render/gl/RenderSettings.ts index f1a6561ad4..a42ed9d1f7 100644 --- a/src/client/render/gl/RenderSettings.ts +++ b/src/client/render/gl/RenderSettings.ts @@ -22,6 +22,20 @@ export interface ThemeSettings { botColors: string[]; /** Used when the primary palettes are exhausted. */ fallbackColors: string[]; + /** + * Vision models player colours must stay distinct under. Names come from the + * theme module's Observer type ("normal", "protan", "deutan", "tritan"); they + * are validated when the theme is built, so a typo fails loudly rather than + * silently disabling an accessibility check. + */ + observers: string[]; + /** + * Minimum ΔE2000 (0–100) a curated palette colour must reach against the + * colours already in play. Below this the allocator generates a colour + * instead. Raising it favours separation over the curated palette; lowering + * it favours the curated palette. + */ + distinctnessFloor: number; /** Border = territory color darkened by this absolute amount. */ borderDarken: number; /** diff --git a/src/client/render/gl/colorblind-theme.json b/src/client/render/gl/colorblind-theme.json index 0d60fbbfd2..0efeecceb8 100644 --- a/src/client/render/gl/colorblind-theme.json +++ b/src/client/render/gl/colorblind-theme.json @@ -395,7 +395,6 @@ "#f0bef5", "#f5c3fa", "#fac8ff", - "#ffcdff", "#ffd2ff", "#ffd2fa", "#ffcdf5", @@ -420,6 +419,8 @@ "#fff5d2", "#fff0dc" ], + "observers": ["normal", "deutan", "protan", "tritan"], + "distinctnessFloor": 5, "borderDarken": 0, "borderLightnessScale": 0.6, "defendedBorderDarkenLight": 0.2, diff --git a/src/client/render/gl/default-theme.json b/src/client/render/gl/default-theme.json index fd0b97ec96..7a1ce7a07a 100644 --- a/src/client/render/gl/default-theme.json +++ b/src/client/render/gl/default-theme.json @@ -460,7 +460,6 @@ "#f0bef5", "#f5c3fa", "#fac8ff", - "#ffcdff", "#ffd2ff", "#ffd2fa", "#ffcdf5", @@ -485,6 +484,8 @@ "#fff5d2", "#fff0dc" ], + "observers": ["normal", "deutan", "protan"], + "distinctnessFloor": 5, "borderDarken": 0.125, "borderLightnessScale": 1, "defendedBorderDarkenLight": 0.2, diff --git a/src/client/theme/ColorAllocator.ts b/src/client/theme/ColorAllocator.ts index c55301a233..8af7443261 100644 --- a/src/client/theme/ColorAllocator.ts +++ b/src/client/theme/ColorAllocator.ts @@ -3,62 +3,250 @@ import labPlugin from "colord/plugins/lab"; import lchPlugin from "colord/plugins/lch"; import { PseudoRandom } from "../../core/PseudoRandom"; import { simpleHash } from "../../core/Util"; -extend([lchPlugin]); -extend([labPlugin]); +import { + ColorEnvelope, + paletteEnvelope, + sequenceColor, +} from "./ColorGenerator"; +import { Candidate, ColorRegistry } from "./ColorRegistry"; +import { Observer } from "./ColorVision"; /** - * Assigns a stable, visually distinct color to each id from a pool, falling - * back to a larger list once the pool is exhausted. Theme-agnostic: it knows - * nothing about teams or palettes — a theme supplies the pool and owns any - * team-color logic. + * How many synthesised colours to keep available at a time. + * + * Sized against the largest public lobby (125) plus nations, with room to + * discard crowded candidates. Scored once on creation and maintained + * incrementally afterwards, so widening this costs a scan per allocation. + */ +const GENERATED_POOL_SIZE = 2048; + +extend([lchPlugin, labPlugin]); + +/** How a pool of players competes for colours. */ +export type AllocationPolicy = "distinct" | "shared"; + +export interface ColorAllocatorOptions { + /** + * Vision models a colour must stay distinct under. A candidate is scored by + * its *worst* separation across all of them. Defaults to normal vision only. + * Ignored when `registry` is supplied — the registry owns these. + */ + observers?: Observer[]; + /** + * Minimum ΔE2000 (0–100) a palette colour must reach before the allocator + * stops trusting the palettes and synthesises one instead. Ignored when + * `registry` is supplied. + */ + distinctnessFloor?: number; + /** + * `"distinct"` (default) gives every id its own colour, competing with every + * other allocator sharing the registry. `"shared"` hands out colours by + * stable hash without reserving them — for pools where hundreds of players + * are expected to share a small palette by design. + */ + policy?: AllocationPolicy; + /** + * Distinctness state shared with other allocators. Supply one registry to + * every allocator in a game so their colours cannot collide. Omitted, the + * allocator gets a private registry and competes with nobody. + */ + registry?: ColorRegistry; +} + +/** + * Assigns a stable colour to each id. + * + * Colours come from the primary palette first, then the fallback palette, and + * finally — only when neither holds one far enough from the colours already in + * play — from a colour synthesised by the registry. Assignments are stable for + * the allocator's lifetime. + * + * Theme-agnostic: it knows nothing about teams or palettes. A theme supplies + * the pools and owns any team-colour logic. */ export class ColorAllocator { - private availableColors: Colord[]; - private fallbackColors: Colord[]; + private readonly registry: ColorRegistry; + private readonly policy: AllocationPolicy; + private readonly primary: Candidate[]; + private readonly fallback: Candidate[]; + /** + * The LCH region this allocator's synthesised colours are drawn from, taken + * from its primary palette so they stay in character with it. + */ + private readonly envelope: ColorEnvelope; + /** Synthesised colours, built on first need so ordinary lobbies never pay. */ + private generated: Candidate[] | null = null; private assigned = new Map(); - constructor(colors: Colord[], fallback: Colord[]) { - this.availableColors = [...colors]; - this.fallbackColors = [...colors, ...fallback]; + constructor( + colors: Colord[], + fallback: Colord[], + options: ColorAllocatorOptions = {}, + ) { + this.registry = + options.registry ?? + new ColorRegistry( + options.observers ?? ["normal"], + options.distinctnessFloor ?? 0, + ); + this.policy = options.policy ?? "distinct"; + this.envelope = paletteEnvelope(colors); + this.primary = colors.map((color) => this.registry.candidate(color)); + this.fallback = fallback.map((color) => this.registry.candidate(color)); + if (this.policy === "distinct") { + this.registry.registerPool(this.primary); + this.registry.registerPool(this.fallback); + } else { + // Nothing reserves these, but they will be on the map, so everyone else + // has to keep away from them. + this.registry.reserve(colors); + } } /** - * Return the color assigned to `id`, allocating one on first request. New - * colors are chosen to be as visually distinct as possible from those already - * handed out (falling back to random selection once the pool is large or - * exhausted, for performance). Assignments are stable for the allocator's - * lifetime. + * Return the colour assigned to `id`, allocating one on first request. + * Assignments are stable for the allocator's lifetime. */ assignColor(id: string): Colord { - if (this.assigned.has(id)) { - return this.assigned.get(id)!; + const existing = this.assigned.get(id); + if (existing !== undefined) { + return existing; } + const color = this.policy === "shared" ? this.share(id) : this.allocate(id); + this.assigned.set(id, color); + return color; + } + + /** + * Stable hash into the primary palette, reserving nothing. Hundreds of bots + * sharing a handful of colours is intended: giving each one a colour of its + * own would crowd out the players it matters most to tell apart, and cost + * far more than it is worth. + */ + private share(id: string): Colord { + return this.primary[simpleHash(id) % this.primary.length].color; + } + + private allocate(id: string): Colord { + const candidate = this.select(id); + this.registry.commit(candidate); + return candidate.color; + } - if (this.availableColors.length === 0) { - this.availableColors = [...this.fallbackColors]; + private select(id: string): Candidate { + if (this.registry.size === 0) { + return this.seed(id); } - let selectedIndex: number; + // Prefer palette colours, primary before fallback, while one is good enough. + for (const pool of [this.primary, this.fallback]) { + const best = bestUnused(pool); + if (best !== null && best.nearest >= this.registry.distinctnessFloor) { + return best; + } + } - if (this.assigned.size === 0 || this.assigned.size > 50) { - // Randomly pick the first color if no colors have been assigned yet. - // - // Or if more than 50 colors assigned just pick a random one for perf reasons, - // as selecting a distinct color is O(n^2), and the color palette is mostly exhausted anyways. - const rand = new PseudoRandom(simpleHash(id)); - selectedIndex = rand.nextInt(0, this.availableColors.length); - } else { - const assignedColors = Array.from(this.assigned.values()); - selectedIndex = selectDistinctColorIndex( - this.availableColors, - assignedColors, - ); + // Nothing clears the floor, so take the roomiest colour available. Both + // palettes have to be weighed here: picking the primary's best whenever the + // primary still holds anything would ignore a better fallback colour and + // could synthesise one that is no improvement on it. + const curated = roomiest( + bestUnused(this.primary), + bestUnused(this.fallback), + ); + const generated = this.generate(); + if (curated !== null && curated.nearest >= generated.nearest) { + return curated; } + return generated; + } - const color = this.availableColors.splice(selectedIndex, 1)[0]; - this.assigned.set(id, color); - return color; + /** + * The roomiest synthesised colour available, drawn from this allocator's own + * envelope so it stays in character with its palette. + * + * The pool is built once, on first need, and registered with the registry so + * its scores are maintained incrementally — the same colour is never + * re-scored against the same neighbour twice. + */ + private generate(): Candidate { + if (this.generated === null) { + this.generated = []; + for (let index = 0; index < GENERATED_POOL_SIZE; index++) { + this.generated.push( + this.registry.candidate(sequenceColor(index, this.envelope)), + ); + } + this.registry.registerPool(this.generated); + } + let best: Candidate | null = null; + for (const candidate of this.generated) { + // Never hand back a colour already in play: that would put two players in + // the same colour, the defect this exists to prevent. Sequence colours + // round to 8-bit sRGB, so exact repeats are possible. + if (candidate.used || candidate.nearest <= 0) { + continue; + } + if (best === null || candidate.nearest > best.nearest) { + best = candidate; + } + } + if (best !== null) { + return best; + } + // Pool wholly used or wholly crowded — extend it and take the newcomer. + const grown = this.registry.candidate( + sequenceColor(this.generated.length, this.envelope), + ); + grown.nearest = this.registry.distanceToInPlay(grown.labs); + this.generated.push(grown); + return grown; + } + + /** + * First colour of a game: chosen pseudo-randomly from the primary palette so + * a given id lands on the same colour every time. + */ + private seed(id: string): Candidate { + const available = this.primary.filter((candidate) => !candidate.used); + const source = + available.length > 0 + ? available + : this.fallback.filter((candidate) => !candidate.used); + if (source.length === 0) { + return this.generate(); + } + const random = new PseudoRandom(simpleHash(id)); + return source[random.nextInt(0, source.length)]; + } +} + +/** Whichever candidate sits furthest from the colours already in play. */ +function roomiest( + first: Candidate | null, + second: Candidate | null, +): Candidate | null { + if (first === null) { + return second; + } + if (second === null) { + return first; + } + return first.nearest >= second.nearest ? first : second; +} + +/** Highest-scoring unused candidate in a pool, or null if none remain. */ +function bestUnused(pool: Candidate[]): Candidate | null { + let best: Candidate | null = null; + for (const candidate of pool) { + if (candidate.used) { + continue; + } + if (best === null || candidate.nearest > best.nearest) { + best = candidate; + } } + return best; } /** @@ -78,21 +266,16 @@ export function selectDistinctColorIndex( let maxIndex = 0; for (let i = 0; i < availableColors.length; i++) { - const color = availableColors[i]; - const deltaE = minDeltaE(color, assignedColors); - if (deltaE > maxDeltaE) { - maxDeltaE = deltaE; + let nearest = Infinity; + for (const assigned of assignedColors) { + // colord's lab plugin .delta() is CIEDE2000 normalized to 0..1; only + // relative magnitudes matter here. + nearest = Math.min(nearest, availableColors[i].delta(assigned)); + } + if (nearest > maxDeltaE) { + maxDeltaE = nearest; maxIndex = i; } } return maxIndex; } - -/** Smallest delta-E 2000 distance from `color` to any of the assigned colors. */ -function minDeltaE(color: Colord, assignedColors: Colord[]) { - return assignedColors.reduce((min, assigned) => { - // colord's lab plugin .delta() is CIEDE2000 normalized to 0..1; only - // relative magnitudes matter here. - return Math.min(min, color.delta(assigned)); - }, Infinity); -} diff --git a/src/client/theme/ColorDistance.ts b/src/client/theme/ColorDistance.ts new file mode 100644 index 0000000000..ab825798dc --- /dev/null +++ b/src/client/theme/ColorDistance.ts @@ -0,0 +1,96 @@ +import { LabaColor } from "colord"; + +const DEG = 180 / Math.PI; +const RAD = Math.PI / 180; +/** 25^7, the constant the chroma-weighting terms are scaled against. */ +const POW_25_7 = 6103515625; + +/** `value ** 7` by multiplication — this runs millions of times per lobby. */ +function pow7(value: number): number { + const cube = value * value * value; + return cube * cube * value; +} + +/** + * CIEDE2000 colour difference between two LAB colours, on the usual 0–100 + * scale. + * + * colord's lab plugin offers the same metric via `Colord.delta()`, but it + * converts both operands from sRGB to LAB on every call. Colour allocation + * compares one new colour against thousands of fixed candidates, so those + * conversions dominate. Taking LAB directly lets callers convert once and + * reuse the result. + * + * Validated against the Sharma, Wu & Dalal (2005) reference dataset in + * tests/Colors.test.ts. That matters beyond performance: `Colord.delta()` + * disagrees with the reference formula by up to ~2.5 on some near-neutral + * pairs, so this is also the more accurate of the two. + */ +export function deltaE2000(first: LabaColor, second: LabaColor): number { + const rad = RAD; + const deg = DEG; + + const c1 = Math.hypot(first.a, first.b); + const c2 = Math.hypot(second.a, second.b); + const meanC = (c1 + c2) / 2; + const meanC7 = pow7(meanC); + const g = 0.5 * (1 - Math.sqrt(meanC7 / (meanC7 + POW_25_7))); + + const a1 = first.a * (1 + g); + const a2 = second.a * (1 + g); + const cp1 = Math.hypot(a1, first.b); + const cp2 = Math.hypot(a2, second.b); + + const hue = (b: number, a: number): number => { + if (b === 0 && a === 0) return 0; + const h = Math.atan2(b, a) * deg; + return h >= 0 ? h : h + 360; + }; + const hp1 = hue(first.b, a1); + const hp2 = hue(second.b, a2); + + const deltaL = second.l - first.l; + const deltaC = cp2 - cp1; + + let deltaHue = 0; + if (cp1 * cp2 !== 0) { + deltaHue = hp2 - hp1; + if (deltaHue > 180) deltaHue -= 360; + else if (deltaHue < -180) deltaHue += 360; + } + const deltaH = 2 * Math.sqrt(cp1 * cp2) * Math.sin((deltaHue / 2) * rad); + + const meanL = (first.l + second.l) / 2; + const meanCp = (cp1 + cp2) / 2; + + let meanHp = hp1 + hp2; + if (cp1 * cp2 !== 0) { + if (Math.abs(hp1 - hp2) > 180) meanHp += hp1 + hp2 < 360 ? 360 : -360; + meanHp /= 2; + } + + const t = + 1 - + 0.17 * Math.cos((meanHp - 30) * rad) + + 0.24 * Math.cos(2 * meanHp * rad) + + 0.32 * Math.cos((3 * meanHp + 6) * rad) - + 0.2 * Math.cos((4 * meanHp - 63) * rad); + + const meanCp7 = pow7(meanCp); + const dl = meanL - 50; + const dl2 = dl * dl; + const hueOffset = (meanHp - 275) / 25; + const sl = 1 + (0.015 * dl2) / Math.sqrt(20 + dl2); + const sc = 1 + 0.045 * meanCp; + const sh = 1 + 0.015 * meanCp * t; + const rt = + -2 * + Math.sqrt(meanCp7 / (meanCp7 + POW_25_7)) * + Math.sin(60 * Math.exp(-(hueOffset * hueOffset)) * rad); + + const lTerm = deltaL / sl; + const cTerm = deltaC / sc; + const hTerm = deltaH / sh; + + return Math.sqrt(lTerm ** 2 + cTerm ** 2 + hTerm ** 2 + rt * cTerm * hTerm); +} diff --git a/src/client/theme/ColorGenerator.ts b/src/client/theme/ColorGenerator.ts new file mode 100644 index 0000000000..9400f78559 --- /dev/null +++ b/src/client/theme/ColorGenerator.ts @@ -0,0 +1,104 @@ +import { Colord, colord, extend } from "colord"; +import lchPlugin from "colord/plugins/lch"; + +extend([lchPlugin]); + +/** + * The region of LCH space a palette occupies. + * + * Each player-type palette has its own character, and that character carries + * meaning: human colours are bright and saturated, nation colours noticeably + * more restrained, bot colours nearly grey. A player reads those differences + * without being told. Synthesised colours therefore have to be drawn from the + * same region as the palette they extend, or a nation starts looking like a + * player. + */ +export interface ColorEnvelope { + lightnessMin: number; + lightnessMax: number; + chromaMin: number; + chromaMax: number; +} + +/** + * Absolute bounds applied on top of any palette's own range. Near-black and + * near-white fills read poorly against terrain and against the borders derived + * from them, whatever the palette does. + */ +const LIGHTNESS_FLOOR = 30; +const LIGHTNESS_CEILING = 88; + +/** Ignore this proportion at each end, so one outlier cannot stretch a range. */ +const TRIM = 0.05; + +const clamp = (value: number, low: number, high: number) => + Math.min(high, Math.max(low, value)); + +function trimmedRange(values: number[]): [number, number] { + const sorted = [...values].sort((a, b) => a - b); + const drop = Math.floor(sorted.length * TRIM); + return [sorted[drop], sorted[Math.max(drop, sorted.length - 1 - drop)]]; +} + +/** + * The LCH region a palette occupies, used to keep synthesised colours in + * character with it. Falls back to a broad range for palettes too small to + * describe a region. + */ +export function paletteEnvelope(colors: Colord[]): ColorEnvelope { + if (colors.length < 4) { + return { + lightnessMin: 35, + lightnessMax: 80, + chromaMin: 25, + chromaMax: 110, + }; + } + const lch = colors.map((color) => color.toLch()); + const [lLow, lHigh] = trimmedRange(lch.map((x) => x.l)); + const [cLow, cHigh] = trimmedRange(lch.map((x) => x.c)); + return { + lightnessMin: clamp(lLow, LIGHTNESS_FLOOR, LIGHTNESS_CEILING), + lightnessMax: clamp(lHigh, LIGHTNESS_FLOOR, LIGHTNESS_CEILING), + // Never collapse to a single value: a palette of near-identical chroma + // still needs somewhere to put a new colour. + chromaMin: Math.max(0, cLow), + chromaMax: Math.max(cHigh, cLow + 12), + }; +} + +/** + * Plastic number, the 3-dimensional analogue of the golden ratio. Successive + * multiples of its reciprocal powers fill a volume evenly at every prefix + * length — the R3 low-discrepancy sequence (Roberts, 2018). + */ +const PLASTIC = 1.2207440846057596; +const ALPHA_HUE = 1 / PLASTIC; +const ALPHA_LIGHTNESS = 1 / (PLASTIC * PLASTIC); +const ALPHA_CHROMA = 1 / (PLASTIC * PLASTIC * PLASTIC); + +const fraction = (value: number): number => value - Math.floor(value); + +/** + * The `index`-th colour of a deterministic sweep through `envelope`. + * + * The sequence is chosen so that any prefix is already well spread: colour 5 is + * far from colours 0–4 without anyone having searched for it. A pool drawn from + * it therefore covers the region with far fewer entries than a regular grid of + * comparable quality, which is what keeps the registry's scan cheap enough to + * run on every allocation. + * + * Deterministic and stateless: the same index and envelope always yield the + * same colour. + */ +export function sequenceColor(index: number, envelope: ColorEnvelope): Colord { + const lightnessRange = envelope.lightnessMax - envelope.lightnessMin; + const chromaRange = envelope.chromaMax - envelope.chromaMin; + const h = fraction(0.5 + ALPHA_HUE * index) * 360; + const l = + envelope.lightnessMin + + fraction(0.5 + ALPHA_LIGHTNESS * index) * lightnessRange; + const c = + envelope.chromaMin + fraction(0.5 + ALPHA_CHROMA * index) * chromaRange; + return colord({ l, c, h }); +} diff --git a/src/client/theme/ColorRegistry.ts b/src/client/theme/ColorRegistry.ts new file mode 100644 index 0000000000..6164054f11 --- /dev/null +++ b/src/client/theme/ColorRegistry.ts @@ -0,0 +1,149 @@ +import { Colord, LabaColor } from "colord"; +import { deltaE2000 } from "./ColorDistance"; +import { Observer, observerViews } from "./ColorVision"; + +/** A colour under consideration, with its cached distance to what's in play. */ +export interface Candidate { + color: Colord; + /** The colour as each observer sees it, converted once. */ + labs: LabaColor[]; + /** + * Smallest distance to any colour already in play, across all observers. + * Maintained incrementally so each allocation costs O(candidates) rather + * than O(candidates * assigned). + */ + nearest: number; + used: boolean; +} + +/** + * Worst-case ΔE2000 between two colours across every observer. + * + * Both arrays must come from the same observer list, in the same order — they + * are compared index by index. "Worst" here means the *smallest* separation, + * which is the right reading for a distinctness metric: a pair is only as + * distinguishable as the observer who can least tell them apart. + */ +export function distance(first: LabaColor[], second: LabaColor[]): number { + let worst = Infinity; + for (let i = 0; i < first.length; i++) { + const value = deltaE2000(first[i], second[i]); + if (value < worst) { + worst = value; + } + } + return worst; +} + +/** + * The colours in play for one game, shared by every allocator that draws from + * them. + * + * Players are drawn from separate palettes by type — human, nation, bot — but a + * player looking at the map cannot tell those types apart; they just see + * territories. Distinctness therefore has to be judged across all of them at + * once. Allocators that share a registry compete for the same space, so a + * nation can never be handed a colour that a human is already using. + * + * When no palette colour is far enough from what's in play, the registry + * synthesises one from the LCH sequence in ColorGenerator. + */ +export class ColorRegistry { + private readonly inPlay: Candidate[] = []; + private readonly pools: Candidate[][] = []; + + constructor( + readonly observers: Observer[], + readonly distinctnessFloor: number, + ) {} + + /** How many colours have been handed out. */ + get size(): number { + return this.inPlay.length; + } + + /** Wrap a colour with the per-observer LAB values scoring needs. */ + candidate(color: Colord): Candidate { + return { + color, + labs: observerViews(color, this.observers).map((view) => view.toLab()), + nearest: Infinity, + used: false, + }; + } + + /** + * Track a pool so its scores stay current as colours are handed out. Pools + * joining late are scored against everything already in play. + */ + registerPool(pool: Candidate[]): void { + this.pools.push(pool); + for (const candidate of pool) { + candidate.nearest = Math.min( + candidate.nearest, + this.distanceToInPlay(candidate.labs), + ); + } + } + + /** Lower every tracked pool's score against one colour now in play. */ + private refreshPools(against: Candidate): void { + for (const pool of this.pools) { + for (const other of pool) { + if (other.used) { + continue; + } + const value = distance(other.labs, against.labs); + if (value < other.nearest) { + other.nearest = value; + } + } + } + } + + /** Put a colour into play and refresh every tracked pool against it. */ + commit(candidate: Candidate): void { + candidate.used = true; + this.inPlay.push(candidate); + this.refreshPools(candidate); + } + + /** + * Distance from a colour to the nearest colour already in play. + * + * `abortBelow` lets a caller comparing many candidates give up on one as soon + * as it cannot beat the best so far. The result is then only a lower bound — + * which is all a caller about to discard it needs. A candidate that survives + * without aborting always carries its exact distance. + */ + distanceToInPlay(labs: LabaColor[], abortBelow = -Infinity): number { + let worst = Infinity; + for (const candidate of this.inPlay) { + const value = distance(labs, candidate.labs); + if (value < worst) { + worst = value; + if (worst <= abortBelow) { + return worst; + } + } + } + return worst; + } + + /** + * Treat colours as in play without assigning them to anyone. + * + * Bot palettes are handed out by hash rather than reserved, but bot + * territories are still on the map — so humans and nations have to steer + * clear of those colours even though no bot has claimed them yet. + */ + reserve(colors: Colord[]): void { + for (const color of colors) { + const reserved = this.candidate(color); + this.inPlay.push(reserved); + // Refresh pools here too, so reserving works whatever order the + // allocators happen to be constructed in. + this.refreshPools(reserved); + } + } +} diff --git a/src/client/theme/ColorVision.ts b/src/client/theme/ColorVision.ts new file mode 100644 index 0000000000..bdbe5ef7a5 --- /dev/null +++ b/src/client/theme/ColorVision.ts @@ -0,0 +1,102 @@ +import { Colord, colord } from "colord"; + +/** + * A vision model that colour distinctness is evaluated against. "normal" is + * unimpaired vision; the others are dichromatic colour vision deficiencies. + */ +export type Observer = "normal" | "protan" | "deutan" | "tritan"; + +const OBSERVER_NAMES: readonly string[] = [ + "normal", + "protan", + "deutan", + "tritan", +]; + +/** + * Machado, Oliveira & Fernandes (2009), "A Physiologically-based Model for + * Simulation of Color Vision Deficiency", severity 1.0. Row-major 3x3, applied + * to linear-light RGB — not to gamma-encoded sRGB. + */ +/** Row-major 3x3. Fixed length so a dropped coefficient fails to compile. */ +type Matrix3x3 = readonly [ + number, + number, + number, + number, + number, + number, + number, + number, + number, +]; + +const CVD_MATRICES: Record, Matrix3x3> = { + protan: [ + 0.152286, 1.052583, -0.204868, 0.114503, 0.786281, 0.099216, -0.003882, + -0.048116, 1.051998, + ], + deutan: [ + 0.367322, 0.860646, -0.227968, 0.280085, 0.672501, 0.047413, -0.01182, + 0.04294, 0.968881, + ], + tritan: [ + 1.255528, -0.076749, -0.178779, -0.078411, 0.930809, 0.147602, 0.004733, + 0.691367, 0.3039, + ], +}; + +/** sRGB channel (0–255) to linear light (0–1). */ +function toLinear(channel: number): number { + const v = channel / 255; + return v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; +} + +/** Linear light (0–1) back to an sRGB channel (0–255), clamped to gamut. */ +function toSrgb(linear: number): number { + const v = + linear <= 0.0031308 ? linear * 12.92 : 1.055 * linear ** (1 / 2.4) - 0.055; + return Math.round(Math.min(255, Math.max(0, v * 255))); +} + +/** + * Narrow observer names read from a theme JSON. Throws on an unknown name so a + * typo in theme data fails loudly at startup rather than silently disabling an + * accessibility check. + */ +export function parseObservers(values: readonly string[]): Observer[] { + if (values.length === 0) { + throw new Error("Theme settings must list at least one observer"); + } + return values.map((value) => { + if (!OBSERVER_NAMES.includes(value)) { + throw new Error(`Unknown observer "${value}" in theme settings`); + } + return value as Observer; + }); +} + +/** `color` as seen by `observer`. Normal vision returns it unchanged. */ +export function simulate(color: Colord, observer: Observer): Colord { + if (observer === "normal") { + return color; + } + const m = CVD_MATRICES[observer]; + const { r, g, b } = color.toRgb(); + const lr = toLinear(r); + const lg = toLinear(g); + const lb = toLinear(b); + return colord({ + r: toSrgb(m[0] * lr + m[1] * lg + m[2] * lb), + g: toSrgb(m[3] * lr + m[4] * lg + m[5] * lb), + b: toSrgb(m[6] * lr + m[7] * lg + m[8] * lb), + }); +} + +/** `color` as seen by each observer, in the order given. */ +export function observerViews( + color: Colord, + observers: readonly Observer[], +): Colord[] { + return observers.map((observer) => simulate(color, observer)); +} diff --git a/src/client/theme/ThemeProvider.ts b/src/client/theme/ThemeProvider.ts index cc46725440..6d325038b0 100644 --- a/src/client/theme/ThemeProvider.ts +++ b/src/client/theme/ThemeProvider.ts @@ -10,6 +10,8 @@ import { } from "../render/gl/RenderSettings"; import { PlayerView } from "../view"; import { ColorAllocator } from "./ColorAllocator"; +import { ColorRegistry } from "./ColorRegistry"; +import { parseObservers } from "./ColorVision"; /** * The color surface consumed by PlayerView and HUD components. Built from @@ -95,9 +97,28 @@ export class SettingsTheme implements Theme { const nationColors = settings.nationColors.map(colord); const fallbackColors = settings.fallbackColors.map(colord); - this.humanColorAllocator = new ColorAllocator(humanColors, fallbackColors); - this.botColorAllocator = new ColorAllocator(botColors, botColors); - this.nationColorAllocator = new ColorAllocator(nationColors, nationColors); + // One registry for the whole game. Humans and nations are drawn from + // separate palettes, but a player looking at the map cannot tell the two + // apart — sharing the registry stops a nation being handed a colour a + // human is already using. + const registry = new ColorRegistry( + parseObservers(settings.observers), + settings.distinctnessFloor, + ); + + this.humanColorAllocator = new ColorAllocator(humanColors, fallbackColors, { + registry, + }); + this.nationColorAllocator = new ColorAllocator(nationColors, [], { + registry, + }); + // Bots deliberately share a small palette: a lobby carries hundreds of + // them, and giving each one a colour of its own would crowd out the + // players it matters most to tell apart. + this.botColorAllocator = new ColorAllocator(botColors, [], { + registry, + policy: "shared", + }); this.teamPalettes = buildTeamPalettes(settings); this._focusedBorderColor = colord(settings.focusedBorderColor); diff --git a/tests/Colors.test.ts b/tests/Colors.test.ts index 9e3ed8c12b..e93fc465bb 100644 --- a/tests/Colors.test.ts +++ b/tests/Colors.test.ts @@ -1,12 +1,26 @@ import { colord, Colord } from "colord"; +import colorblindThemeJson from "../src/client/render/gl/colorblind-theme.json"; import defaultTheme from "../src/client/render/gl/default-theme.json"; +import { PALETTE_NAMES } from "../src/client/render/gl/GraphicsOverrides"; import { createThemeSettings } from "../src/client/render/gl/RenderSettings"; import { ColorAllocator, selectDistinctColorIndex, } from "../src/client/theme/ColorAllocator"; +import { deltaE2000 } from "../src/client/theme/ColorDistance"; +import { + paletteEnvelope, + sequenceColor, +} from "../src/client/theme/ColorGenerator"; +import { ColorRegistry } from "../src/client/theme/ColorRegistry"; +import { + observerViews, + parseObservers, + simulate, +} from "../src/client/theme/ColorVision"; import { SettingsTheme } from "../src/client/theme/ThemeProvider"; -import { ColoredTeams } from "../src/core/game/Game"; +import { PlayerView } from "../src/client/view"; +import { ColoredTeams, PlayerType } from "../src/core/game/Game"; const mockColors: Colord[] = [ colord({ r: 255, g: 0, b: 0 }), @@ -156,3 +170,434 @@ describe("selectDistinctColor", () => { ]).toContainEqual(rgb); }); }); + +describe("ColorVision", () => { + test("normal vision returns the colour unchanged", () => { + expect(simulate(colord("#a3e635"), "normal").toHex()).toBe("#a3e635"); + }); + + test("simulates dichromacy against published reference values", () => { + // Machado et al. (2009) severity-1.0 matrices applied to linear-light sRGB. + expect(simulate(colord("#ff0000"), "protan").toHex()).toBe("#6d5f00"); + expect(simulate(colord("#ff0000"), "deutan").toHex()).toBe("#a39000"); + expect(simulate(colord("#0000ff"), "tritan").toHex()).toBe("#006b96"); + }); + + test("achromatic colours are unaffected by any deficiency", () => { + for (const observer of ["protan", "deutan", "tritan"] as const) { + expect(simulate(colord("#ffffff"), observer).toHex()).toBe("#ffffff"); + expect(simulate(colord("#000000"), observer).toHex()).toBe("#000000"); + } + }); + + test("collapses a pair the default palette treats as distinct", () => { + // #a3e635 and #fbbf24 are both in default-theme.json humanColors and are + // clearly different to normal vision, but converge under deuteranopia. + const a = colord("#a3e635"); + const b = colord("#fbbf24"); + expect(a.delta(b) * 100).toBeGreaterThan(20); + expect( + simulate(a, "deutan").delta(simulate(b, "deutan")) * 100, + ).toBeLessThan(5); + }); + + test("parseObservers narrows valid names", () => { + expect(parseObservers(["normal", "deutan"])).toEqual(["normal", "deutan"]); + }); + + test("parseObservers rejects an unknown name", () => { + expect(() => parseObservers(["normal", "deutran"])).toThrow(/deutran/); + }); + + test("parseObservers rejects an empty list", () => { + expect(() => parseObservers([])).toThrow(); + }); + + test("observerViews returns one view per observer, in order", () => { + const views = observerViews(colord("#ff0000"), ["normal", "deutan"]); + expect(views).toHaveLength(2); + expect(views[0].toHex()).toBe("#ff0000"); + expect(views[1].toHex()).toBe("#a39000"); + }); +}); + +describe("ColorDistance", () => { + test("matches the published CIEDE2000 reference dataset", () => { + // Sharma, Wu & Dalal (2005), "The CIEDE2000 color-difference formula: + // implementation notes, supplementary test data, and mathematical + // observations". These pairs exercise the hue-wraparound and near-neutral + // branches that naive implementations get wrong. + const cases: [number[], number[], number][] = [ + [[50, 2.6772, -79.7751], [50, 0, -82.7485], 2.0425], + [[50, 3.1571, -77.2803], [50, 0, -82.7485], 2.8615], + [[50, 2.8361, -74.02], [50, 0, -82.7485], 3.4412], + [[50, -1.3802, -84.2814], [50, 0, -82.7485], 1.0], + [[50, -0.9009, -85.5211], [50, 0, -82.7485], 1.0], + [[50, 0, 0], [50, -1, 2], 2.3669], + [[50, -1, 2], [50, 0, 0], 2.3669], + [[50, 2.49, -0.001], [50, -2.49, 0.0009], 7.1792], + [[50, 2.49, -0.001], [50, -2.49, 0.0011], 7.2195], + [[50, -0.001, 2.49], [50, 0.0009, -2.49], 4.8045], + [[50, 2.5, 0], [50, 0, -2.5], 4.3065], + [[50, 2.5, 0], [73, 25, -18], 27.1492], + [[50, 2.5, 0], [50, 3.1736, 0.5854], 1.0], + [[60.2574, -34.0099, 36.2677], [60.4626, -34.1751, 39.4387], 1.2644], + [[63.0109, -31.0961, -5.8663], [62.8187, -29.7946, -4.0864], 1.263], + [[22.7233, 20.0904, -46.694], [23.0331, 14.973, -42.5619], 2.0373], + [[2.0776, 0.0795, -1.135], [0.9033, -0.0636, -0.5514], 0.9082], + ]; + for (const [first, second, expected] of cases) { + const value = deltaE2000( + { l: first[0], a: first[1], b: first[2], alpha: 1 }, + { l: second[0], a: second[1], b: second[2], alpha: 1 }, + ); + expect(value).toBeCloseTo(expected, 3); + } + }); + + test("agrees with colord's delta on ordinary palette colours", () => { + // Sanity check that swapping the metric did not change the allocator's + // behaviour in the common case. colord rounds delta() to three decimals + // (0.1 once scaled to 0-100) and diverges from the reference formula on + // some near-neutral pairs, so this is a mean check, not a per-pair one. + const palette = defaultTheme.humanColors.map((c) => colord(c)); + let total = 0; + let pairs = 0; + for (let i = 0; i < palette.length; i++) { + for (let j = i + 1; j < palette.length; j++) { + total += Math.abs( + deltaE2000(palette[i].toLab(), palette[j].toLab()) - + palette[i].delta(palette[j]) * 100, + ); + pairs++; + } + } + expect(total / pairs).toBeLessThan(0.1); + }); + + test("is zero for identical colours and symmetric", () => { + const a = colord("#2962ff").toLab(); + const b = colord("#eb3333").toLab(); + expect(deltaE2000(a, a)).toBeCloseTo(0, 10); + expect(deltaE2000(a, b)).toBeCloseTo(deltaE2000(b, a), 10); + }); + + test("handles achromatic colours without dividing by zero", () => { + const black = colord("#000000").toLab(); + const white = colord("#ffffff").toLab(); + const grey = colord("#808080").toLab(); + for (const value of [ + deltaE2000(black, white), + deltaE2000(black, grey), + deltaE2000(grey, white), + ]) { + expect(Number.isFinite(value)).toBe(true); + expect(value).toBeGreaterThan(0); + } + }); +}); + +describe("ColorGenerator", () => { + const wide = paletteEnvelope(defaultTheme.humanColors.map((c) => colord(c))); + + test("is deterministic for a given index and envelope", () => { + expect(sequenceColor(17, wide).toHex()).toBe( + sequenceColor(17, wide).toHex(), + ); + expect(sequenceColor(0, wide).toHex()).not.toBe( + sequenceColor(1, wide).toHex(), + ); + }); + + test("avoids near-black and near-white fills", () => { + for (let i = 0; i < 300; i++) { + const lightness = sequenceColor(i, wide).toLch().l; + expect(lightness).toBeGreaterThan(20); + expect(lightness).toBeLessThan(95); + } + }); + + test("spreads every prefix, not just the whole sequence", () => { + // The point of a low-discrepancy sequence: the first N terms are already + // well distributed for any N, so a modest pool covers the region. + for (const count of [8, 16, 32]) { + const colors = Array.from({ length: count }, (_, i) => + sequenceColor(i, wide), + ); + let worst = Infinity; + for (let i = 0; i < colors.length; i++) { + for (let j = i + 1; j < colors.length; j++) { + const d = deltaE2000(colors[i].toLab(), colors[j].toLab()); + if (d < worst) worst = d; + } + } + // The bound is deliberately loose: the sequence only has to be a good + // starting point, since the allocator still checks every candidate + // against the distinctness floor. + expect(worst).toBeGreaterThan(3); + } + }); + + test("keeps synthesised colours inside their palette's character", () => { + // Each player type's palette has its own look — humans vivid, nations + // restrained, bots nearly grey — and that difference is information. A + // synthesised nation colour must not arrive looking like a human's. + const nationEnvelope = paletteEnvelope( + defaultTheme.nationColors.map((c) => colord(c)), + ); + const nationChroma = defaultTheme.nationColors.map( + (c) => colord(c).toLch().c, + ); + const ceiling = Math.max(...nationChroma); + for (let i = 0; i < 400; i++) { + const lch = sequenceColor(i, nationEnvelope).toLch(); + expect(lch.c).toBeLessThanOrEqual(ceiling + 1); + } + }); + + test("a muted palette yields a muted envelope", () => { + const bots = paletteEnvelope(defaultTheme.botColors.map((c) => colord(c))); + const humans = paletteEnvelope( + defaultTheme.humanColors.map((c) => colord(c)), + ); + expect(bots.chromaMax).toBeLessThan(humans.chromaMax); + }); +}); + +describe("ColorAllocator distinctness guarantees", () => { + const humanColors = defaultTheme.humanColors.map((c) => colord(c)); + const fallbackPalette = defaultTheme.fallbackColors.map((c) => colord(c)); + const observers = ["normal", "deutan", "protan"] as const; + + const allocate = (count: number, floor: number) => { + const allocator = new ColorAllocator(humanColors, fallbackPalette, { + observers: [...observers], + distinctnessFloor: floor, + }); + return Array.from({ length: count }, (_, i) => + allocator.assignColor(`player_${i}`), + ); + }; + + const worstSeparation = (colors: Colord[]) => { + let worst = Infinity; + for (let i = 0; i < colors.length; i++) { + for (let j = i + 1; j < colors.length; j++) { + for (const observer of observers) { + const d = + simulate(colors[i], observer).delta(simulate(colors[j], observer)) * + 100; + if (d < worst) worst = d; + } + } + } + return worst; + }; + + test("never issues the same colour twice in a full lobby", () => { + // MAX_PLAYER_COUNT is 125 (src/server/MapPlaylist.ts) against a 63-colour + // pool, so a full public lobby exhausts the palette by design. + const colors = allocate(125, 5); + expect(new Set(colors.map((c) => c.toHex())).size).toBe(125); + }); + + test("honours the distinctness floor while candidates remain", () => { + expect(worstSeparation(allocate(48, 5))).toBeGreaterThanOrEqual(5); + }); + + test("generates no new colours for a 48-player game", () => { + // Everything up to this size comes from palette data shipped in the theme + // JSON, so lobbies of ordinary size keep the game's existing look — only + // which colour a given player receives changes. + const shipped = new Set([ + ...defaultTheme.humanColors.map((c) => colord(c).toHex()), + ...defaultTheme.fallbackColors.map((c) => colord(c).toHex()), + ]); + for (const color of allocate(48, 5)) { + expect(shipped.has(color.toHex())).toBe(true); + } + }); + + test("separates a full lobby well past the just-noticeable threshold", () => { + // The shipped allocator scores 0.00 here — two players share a colour. + expect(worstSeparation(allocate(125, 5))).toBeGreaterThan(2.3); + }); + + test("is deterministic for the same id sequence", () => { + expect(allocate(40, 5).map((c) => c.toHex())).toEqual( + allocate(40, 5).map((c) => c.toHex()), + ); + }); + + test("never settles for less separation than a palette could give", () => { + // Five near-identical reds, one distant blue in the fallback, and a floor + // neither palette can reach — so the allocator drops through to "take the + // roomiest available". Whatever it picks must be at least as far from the + // first colour as the fallback would have been; choosing the primary's best + // just because the primary is non-empty would fail this. + const primary = ["#ff0000", "#fb0202", "#f70404", "#f30606", "#ef0808"].map( + (c) => colord(c), + ); + const fallback = [colord("#0000ff")]; + const allocator = new ColorAllocator(primary, fallback, { + observers: ["normal"], + distinctnessFloor: 95, + }); + const first = allocator.assignColor("player_0"); + const second = allocator.assignColor("player_1"); + const fallbackWouldGive = deltaE2000(first.toLab(), fallback[0].toLab()); + expect(deltaE2000(first.toLab(), second.toLab())).toBeGreaterThanOrEqual( + fallbackWouldGive, + ); + }); + + test("shared policy stays inside the palette", () => { + const pool = [colord("#ff0000"), colord("#00ff00"), colord("#0000ff")]; + const allocator = new ColorAllocator(pool, [], { policy: "shared" }); + const palette = new Set(pool.map((c) => c.toHex())); + for (let i = 0; i < 20; i++) { + expect(palette.has(allocator.assignColor(`bot_${i}`).toHex())).toBe(true); + } + }); + + test("distinct policy leaves the palette once it is exhausted", () => { + const pool = [colord("#ff0000"), colord("#00ff00"), colord("#0000ff")]; + const allocator = new ColorAllocator(pool, [], {}); + const assigned = Array.from({ length: 6 }, (_, i) => + allocator.assignColor(`player_${i}`), + ); + expect(new Set(assigned.map((c) => c.toHex())).size).toBe(6); + }); +}); + +describe("cross-pool distinctness", () => { + const observers = ["normal", "deutan", "protan"] as const; + const separation = (a: Colord, b: Colord) => { + let worst = Infinity; + for (const observer of observers) { + const d = deltaE2000( + simulate(a, observer).toLab(), + simulate(b, observer).toLab(), + ); + if (d < worst) worst = d; + } + return worst; + }; + + test("a shared registry keeps humans and nations apart", () => { + // Without a shared registry these pools allocate blind to each other, and + // a nation can land within 0.61 of a human — far below the ~2.3 + // just-noticeable threshold. On the map they are indistinguishable. + const registry = new ColorRegistry([...observers], 5); + const humans = new ColorAllocator( + defaultTheme.humanColors.map((c) => colord(c)), + defaultTheme.fallbackColors.map((c) => colord(c)), + { registry }, + ); + const nations = new ColorAllocator( + defaultTheme.nationColors.map((c) => colord(c)), + [], + { registry }, + ); + + const humanColors = Array.from({ length: 8 }, (_, i) => + humans.assignColor(`human_${i}`), + ); + const nationColors = Array.from({ length: 72 }, (_, i) => + nations.assignColor(`nation_${i}`), + ); + + let worst = Infinity; + for (const human of humanColors) { + for (const nation of nationColors) { + const d = separation(human, nation); + if (d < worst) worst = d; + } + } + expect(worst).toBeGreaterThan(2.3); + }); + + test("a shared registry never issues one colour to two players", () => { + const registry = new ColorRegistry([...observers], 5); + const humans = new ColorAllocator( + defaultTheme.humanColors.map((c) => colord(c)), + defaultTheme.fallbackColors.map((c) => colord(c)), + { registry }, + ); + const nations = new ColorAllocator( + defaultTheme.nationColors.map((c) => colord(c)), + [], + { registry }, + ); + const all = [ + ...Array.from({ length: 8 }, (_, i) => humans.assignColor(`h${i}`)), + ...Array.from({ length: 72 }, (_, i) => nations.assignColor(`n${i}`)), + ]; + expect(new Set(all.map((c) => c.toHex())).size).toBe(80); + }); +}); + +describe("theme colour settings", () => { + test("both themes declare observers and a distinctness floor", () => { + for (const theme of [defaultTheme, colorblindThemeJson]) { + expect(parseObservers(theme.observers)).toEqual(theme.observers); + expect(theme.distinctnessFloor).toBeGreaterThan(0); + } + }); + + test("the colorblind theme checks tritanopia and the default theme does not", () => { + expect(colorblindThemeJson.observers).toContain("tritan"); + expect(defaultTheme.observers).not.toContain("tritan"); + }); + + test("no palette contains a duplicate colour", () => { + for (const theme of [defaultTheme, colorblindThemeJson]) { + for (const palette of [ + theme.humanColors, + theme.nationColors, + theme.botColors, + theme.fallbackColors, + ]) { + const normalised = palette.map((c) => colord(c).toHex()); + expect(new Set(normalised).size).toBe(normalised.length); + } + } + }); +}); + +describe("SettingsTheme allocator wiring", () => { + const playerStub = (id: string, type: PlayerType) => + ({ + id: () => id, + team: () => null, + type: () => type, + }) as unknown as PlayerView; + + for (const name of PALETTE_NAMES) { + test(`${name}: bots reuse their palette rather than generating new colours`, () => { + const theme = new SettingsTheme(createThemeSettings(name)); + const palette = new Set( + createThemeSettings(name).botColors.map((c) => colord(c).toHex()), + ); + for (let i = 0; i < 120; i++) { + const color = theme.territoryColor( + playerStub(`bot_${i}`, PlayerType.Bot), + ); + expect(palette.has(color.toHex())).toBe(true); + } + }); + + test(`${name}: humans in a full lobby all receive different colours`, () => { + const theme = new SettingsTheme(createThemeSettings(name)); + const seen = new Set(); + for (let i = 0; i < 125; i++) { + seen.add( + theme + .territoryColor(playerStub(`human_${i}`, PlayerType.Human)) + .toHex(), + ); + } + expect(seen.size).toBe(125); + }); + } +});