From e20a583eea739b4799d28672b1041d9020161e98 Mon Sep 17 00:00:00 2001 From: Noah Schmalenberger Date: Tue, 2 Jun 2026 16:43:47 +0000 Subject: [PATCH 1/9] Add colorblind mode setting (toggle + wiring, no color changes yet) Adds an accessibility.colorblind flag to GraphicsOverridesSchema, a toggle in the main Settings modal (persisted via graphicsOverrides), en.json strings, and a generateRenderSettings hook where the colorblind color overrides will be applied. No rendering changes yet. --- resources/lang/en.json | 2 ++ src/client/UserSettingModal.ts | 26 +++++++++++++++++++++++ src/client/render/gl/GraphicsOverrides.ts | 5 +++++ src/client/render/gl/RenderOverrides.ts | 4 ++++ 4 files changed, 37 insertions(+) diff --git a/resources/lang/en.json b/resources/lang/en.json index 80cb188e49..ec3eb489d3 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -810,6 +810,8 @@ "keybinds_hint": "Click a key to rebind it. You can assign a single key or Shift + key combination.", "dark_mode_label": "Dark Mode", "dark_mode_desc": "Toggle the site’s appearance between light and dark themes", + "colorblind_label": "Colorblind Mode", + "colorblind_desc": "Use colorblind-friendly territory and border colors", "emojis_label": "Emojis", "emojis_desc": "Toggle whether emojis are shown in game", "alert_frame_label": "Alert Frame", diff --git a/src/client/UserSettingModal.ts b/src/client/UserSettingModal.ts index fe52b0a020..4318de7ad6 100644 --- a/src/client/UserSettingModal.ts +++ b/src/client/UserSettingModal.ts @@ -206,6 +206,23 @@ export class UserSettingModal extends BaseModal { console.log("🌙 Dark Mode:", this.userSettings.darkMode() ? "ON" : "OFF"); } + private colorblindMode(): boolean { + return ( + this.userSettings.graphicsOverrides().accessibility?.colorblind ?? false + ); + } + + private toggleColorblindMode() { + const overrides = this.userSettings.graphicsOverrides(); + this.userSettings.setGraphicsOverrides({ + ...overrides, + accessibility: { + ...overrides.accessibility, + colorblind: !this.colorblindMode(), + }, + }); + } + private toggleEmojis() { this.userSettings.toggleEmojis(); @@ -742,6 +759,15 @@ export class UserSettingModal extends BaseModal { @change=${this.toggleDarkMode} > + + + Date: Tue, 2 Jun 2026 17:04:00 +0000 Subject: [PATCH 2/9] Apply colorblind-safe colors when colorblind mode is enabled Fill the generateRenderSettings colorblind hook with an Okabe-Ito blue/orange palette: alt-view affiliation borders (self/ally blue, enemy orange) and normal-view friend/enemy border tints (friendly blue, enemy orange at a stronger ratio). Propagate the affiliation and mapOverlay slices through applyGraphicsOverrides so the renderer picks them up. --- src/client/render/gl/RenderOverrides.ts | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/src/client/render/gl/RenderOverrides.ts b/src/client/render/gl/RenderOverrides.ts index d278b52e8c..14df6bcff1 100644 --- a/src/client/render/gl/RenderOverrides.ts +++ b/src/client/render/gl/RenderOverrides.ts @@ -65,8 +65,28 @@ export function applyGraphicsOverrides( settings.name.outlineB = channel; } if (overrides.accessibility?.colorblind === true) { - // Colorblind-friendly color overrides (affiliation/tint colors, etc.) are - // applied here. Wired now; the actual values land in a follow-up commit. + // Swap the red/green friend-foe encoding (the most common confusion axis) + // for a colorblind-safe blue/orange pairing (Okabe-Ito). + // Alt-view affiliation borders: self/ally in the blue family, enemy orange. + settings.affiliation.selfR = 0; + settings.affiliation.selfG = 0.447; + settings.affiliation.selfB = 0.698; + settings.affiliation.allyR = 0.337; + settings.affiliation.allyG = 0.706; + settings.affiliation.allyB = 0.914; + settings.affiliation.enemyR = 0.835; + settings.affiliation.enemyG = 0.369; + settings.affiliation.enemyB = 0; + // Normal-view relationship border tints: friendly blue, enemy orange, + // applied strongly so the cue doesn't rely on subtle hue. + settings.mapOverlay.friendlyTintR = 0; + settings.mapOverlay.friendlyTintG = 0.447; + settings.mapOverlay.friendlyTintB = 0.698; + settings.mapOverlay.embargoTintR = 0.835; + settings.mapOverlay.embargoTintG = 0.369; + settings.mapOverlay.embargoTintB = 0; + settings.mapOverlay.friendlyTintRatio = 0.6; + settings.mapOverlay.embargoTintRatio = 0.6; } } From 92f94b4d9e921ccedd6cb818623bcb546367dc7b Mon Sep 17 00:00:00 2001 From: Noah Schmalenberger Date: Tue, 2 Jun 2026 19:00:40 +0000 Subject: [PATCH 3/9] Refactor theme system into a BaseTheme abstract class MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce an abstract BaseTheme that owns the color allocators and the territory/team color dispatch (overridable); concrete themes supply only color data (palettes, team-color variations, terrain) via hooks. Move the hardcoded team-color switch out of ColorAllocator — now a pure distinct-pool engine — into the theme, so team colors are theme-based. Add ColorblindTheme. Light/dark render identically; team-color tests relocated onto PastelTheme. --- src/client/theme/BaseTheme.ts | 199 +++++++++++++++++++++++++ src/client/theme/ColorAllocator.ts | 71 +-------- src/client/theme/ColorblindTheme.ts | 58 ++++++++ src/client/theme/Colors.ts | 35 +++++ src/client/theme/PastelTheme.ts | 215 ++++++++-------------------- src/client/theme/ThemeProvider.ts | 8 +- tests/Colors.test.ts | 85 ++++------- 7 files changed, 391 insertions(+), 280 deletions(-) create mode 100644 src/client/theme/BaseTheme.ts create mode 100644 src/client/theme/ColorblindTheme.ts diff --git a/src/client/theme/BaseTheme.ts b/src/client/theme/BaseTheme.ts new file mode 100644 index 0000000000..768534d1c8 --- /dev/null +++ b/src/client/theme/BaseTheme.ts @@ -0,0 +1,199 @@ +import { Colord, colord, LabaColor } from "colord"; +import { PlayerType, Team } from "../../core/game/Game"; +import { GameMap, TileRef } from "../../core/game/GameMap"; +import { PlayerView } from "../../core/game/GameView"; +import { PseudoRandom } from "../../core/PseudoRandom"; +import { simpleHash } from "../../core/Util"; +import { ColorAllocator } from "./ColorAllocator"; +import { Theme } from "./Theme"; + +/** + * Shared theme machinery. Owns the per-pool color allocators and the + * territory/team color dispatch (the greedy allocation), plus the color math + * every theme shares. Concrete themes supply only the color *data* by + * implementing the abstract hooks (palettes, team-color variations, terrain). + * A theme may also override the dispatch methods for fully custom allocation. + */ +export abstract class BaseTheme implements Theme { + private rand = new PseudoRandom(123); + protected humanColorAllocator: ColorAllocator; + protected botColorAllocator: ColorAllocator; + protected nationColorAllocator: ColorAllocator; + private teamPlayerColors = new Map(); + + // Shared "default theme" colors. Override the fields in a subclass to differ. + protected background = colord("rgb(60,60,60)"); + protected falloutColors = [ + colord("rgb(120,255,71)"), + colord("rgb(130,255,85)"), + colord("rgb(110,245,65)"), + colord("rgb(125,255,75)"), + colord("rgb(115,250,68)"), + ]; + protected _spawnHighlightColor = colord("rgb(255,213,79)"); + protected _spawnHighlightSelfColor = colord("rgb(255,255,255)"); + protected _spawnHighlightTeamColor = colord("rgb(0,255,0)"); + protected _spawnHighlightEnemyColor = colord("rgb(255,0,0)"); + + constructor() { + this.humanColorAllocator = new ColorAllocator( + this.humanPalette(), + this.fallbackPalette(), + ); + this.botColorAllocator = new ColorAllocator( + this.botPalette(), + this.botPalette(), + ); + this.nationColorAllocator = new ColorAllocator( + this.nationPalette(), + this.nationPalette(), + ); + } + + // --- Color data: concrete themes provide these --- + protected abstract humanPalette(): Colord[]; + protected abstract botPalette(): Colord[]; + protected abstract nationPalette(): Colord[]; + protected abstract fallbackPalette(): Colord[]; + /** Per-team color variations; index 0 is the team's base color. */ + protected abstract teamColorVariations(team: Team): Colord[]; + abstract terrainColor(gm: GameMap, tile: TileRef): Colord; + + // --- Allocation dispatch (overridable) --- + teamColor(team: Team): Colord { + const rgb = this.teamColorVariations(team)[0].toRgb(); + return colord({ + r: Math.round(rgb.r), + g: Math.round(rgb.g), + b: Math.round(rgb.b), + }); + } + + territoryColor(player: PlayerView): Colord { + const team = player.team(); + if (team !== null) { + return this.teamColorForPlayer(team, player.id()); + } + if (player.type() === PlayerType.Human) { + return this.humanColorAllocator.assignColor(player.id()); + } + if (player.type() === PlayerType.Bot) { + return this.botColorAllocator.assignColor(player.id()); + } + return this.nationColorAllocator.assignColor(player.id()); + } + + /** Stable per-player variation within a team's color set. */ + teamColorForPlayer(team: Team, playerId: string): Colord { + const cached = this.teamPlayerColors.get(playerId); + if (cached !== undefined) { + return cached; + } + const colors = this.teamColorVariations(team); + const color = colors[simpleHash(playerId) % colors.length]; + this.teamPlayerColors.set(playerId, color); + return color; + } + + // --- Shared color math --- + structureColors(territoryColor: Colord): { light: Colord; dark: Colord } { + // Convert territory color to LAB color space. Territory color is rendered in game with alpha = 150/255, use that here. + const lightLAB = territoryColor.alpha(150 / 255).toLab(); + // Get "border color" from territory color & convert to LAB color space + const darkLAB = this.borderColor(territoryColor).toLab(); + // Calculate the contrast of the two provided colors + let contrast = this.contrast(lightLAB, darkLAB); + + // Don't want excessive contrast, so incrementally increase contrast within a loop. + // Define target values, looping limits, and loop counter + const loopLimit = 10; // Switch from darkening border to lightening fill if loopLimit is reached + const maxIterations = 50; // maximum number of loops allowed, throw error above this limit + const contrastTarget = 0.5; + let loopCount = 0; + + // Adjust luminance by 5 in each iteration. This is a balance between speed and not overdoing contrast changes. + const luminanceChange = 5; + + while (contrast < contrastTarget) { + if (loopCount > maxIterations) { + // Prevent runaway loops + console.warn(`Infinite loop detected during structure color calculation. + Light color: ${colord(lightLAB).toRgbString()}, + Dark color: ${colord(darkLAB).toRgbString()}, + Contrast: ${contrast}`); + break; + + // Increase the light color if the "loop limit" has been reach + // (probably due to the dark color already being as dark as it can be) + } else if (loopCount > loopLimit) { + lightLAB.l = this.clamp(lightLAB.l + luminanceChange); + + // Decrease the dark color first to keep the light color as close + // to the territory color as possible + } else { + darkLAB.l = this.clamp(darkLAB.l - luminanceChange); + } + + // re-calculate contrast and increment loop counter + contrast = this.contrast(lightLAB, darkLAB); + loopCount++; + } + return { light: colord(lightLAB), dark: colord(darkLAB) }; + } + + private contrast(first: LabaColor, second: LabaColor): number { + return colord(first).delta(colord(second)); + } + + private clamp(num: number, low: number = 0, high: number = 100): number { + return Math.min(Math.max(low, num), high); + } + + // Don't call directly, use PlayerView + borderColor(territoryColor: Colord): Colord { + return territoryColor.darken(0.125); + } + + defendedBorderColors(territoryColor: Colord): { + light: Colord; + dark: Colord; + } { + return { + light: territoryColor.darken(0.2), + dark: territoryColor.darken(0.4), + }; + } + + focusedBorderColor(): Colord { + return colord("rgb(230,230,230)"); + } + + textColor(player: PlayerView): string { + return player.type() === PlayerType.Human ? "#000000" : "#4D4D4D"; + } + + backgroundColor(): Colord { + return this.background; + } + + falloutColor(): Colord { + return this.rand.randElement(this.falloutColors); + } + + font(): string { + return "Overpass, sans-serif"; + } + + spawnHighlightColor(): Colord { + return this._spawnHighlightColor; + } + spawnHighlightSelfColor(): Colord { + return this._spawnHighlightSelfColor; + } + spawnHighlightTeamColor(): Colord { + return this._spawnHighlightTeamColor; + } + spawnHighlightEnemyColor(): Colord { + return this._spawnHighlightEnemyColor; + } +} diff --git a/src/client/theme/ColorAllocator.ts b/src/client/theme/ColorAllocator.ts index 317b00559a..2a76a6de94 100644 --- a/src/client/theme/ColorAllocator.ts +++ b/src/client/theme/ColorAllocator.ts @@ -1,61 +1,28 @@ -import { colord, Colord, extend } from "colord"; +import { Colord, extend } from "colord"; import labPlugin from "colord/plugins/lab"; import lchPlugin from "colord/plugins/lch"; import Color from "colorjs.io"; -import { ColoredTeams, Team } from "../../core/game/Game"; import { PseudoRandom } from "../../core/PseudoRandom"; import { simpleHash } from "../../core/Util"; -import { - blueTeamColors, - botTeamColors, - greenTeamColors, - orangeTeamColors, - purpleTeamColors, - redTeamColors, - tealTeamColors, - yellowTeamColors, -} from "./Colors"; extend([lchPlugin]); extend([labPlugin]); +/** + * 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. + */ export class ColorAllocator { private availableColors: Colord[]; private fallbackColors: Colord[]; private assigned = new Map(); - private teamPlayerColors = new Map(); constructor(colors: Colord[], fallback: Colord[]) { this.availableColors = [...colors]; this.fallbackColors = [...colors, ...fallback]; } - private getTeamColorVariations(team: Team): Colord[] { - switch (team) { - case ColoredTeams.Blue: - return blueTeamColors; - case ColoredTeams.Red: - return redTeamColors; - case ColoredTeams.Teal: - return tealTeamColors; - case ColoredTeams.Purple: - return purpleTeamColors; - case ColoredTeams.Yellow: - return yellowTeamColors; - case ColoredTeams.Orange: - return orangeTeamColors; - case ColoredTeams.Green: - return greenTeamColors; - case ColoredTeams.Bot: - return botTeamColors; - case ColoredTeams.Humans: - return blueTeamColors; - case ColoredTeams.Nations: - return redTeamColors; - default: - return [this.assignColor(team)]; - } - } - assignColor(id: string): Colord { if (this.assigned.has(id)) { return this.assigned.get(id)!; @@ -84,30 +51,6 @@ export class ColorAllocator { this.assigned.set(id, color); return color; } - - assignTeamColor(team: Team): Colord { - const teamColors = this.getTeamColorVariations(team); - const rgb = teamColors[0].toRgb(); - rgb.r = Math.round(rgb.r); - rgb.g = Math.round(rgb.g); - rgb.b = Math.round(rgb.b); - return colord(rgb); - } - - assignTeamPlayerColor(team: Team, playerId: string): Colord { - if (this.teamPlayerColors.has(playerId)) { - return this.teamPlayerColors.get(playerId)!; - } - - const teamColors = this.getTeamColorVariations(team); - const hashValue = simpleHash(playerId); - const colorIndex = hashValue % teamColors.length; - const color = teamColors[colorIndex]; - - this.teamPlayerColors.set(playerId, color); - - return color; - } } // Select a distinct color index from the available colors that diff --git a/src/client/theme/ColorblindTheme.ts b/src/client/theme/ColorblindTheme.ts new file mode 100644 index 0000000000..44bef40e9f --- /dev/null +++ b/src/client/theme/ColorblindTheme.ts @@ -0,0 +1,58 @@ +import { Colord } from "colord"; +import { ColoredTeams, Team } from "../../core/game/Game"; +import { + botTeamColors, + cbBlueTeamColors, + cbGreenTeamColors, + cbOrangeTeamColors, + cbPurpleTeamColors, + cbRedTeamColors, + cbTealTeamColors, + cbYellowTeamColors, + colorblindColors, +} from "./Colors"; +import { PastelTheme } from "./PastelTheme"; + +/** + * Colorblind theme — keeps the light terrain but swaps player and team palettes + * for a high-contrast, lightness-varied, colorblind-safe set. Shares all the + * allocation logic from BaseTheme via PastelTheme. + */ +export class ColorblindTheme extends PastelTheme { + protected humanPalette(): Colord[] { + return colorblindColors; + } + protected botPalette(): Colord[] { + return colorblindColors; + } + protected nationPalette(): Colord[] { + return colorblindColors; + } + + protected teamColorVariations(team: Team): Colord[] { + switch (team) { + case ColoredTeams.Blue: + return cbBlueTeamColors; + case ColoredTeams.Red: + return cbRedTeamColors; + case ColoredTeams.Teal: + return cbTealTeamColors; + case ColoredTeams.Purple: + return cbPurpleTeamColors; + case ColoredTeams.Yellow: + return cbYellowTeamColors; + case ColoredTeams.Orange: + return cbOrangeTeamColors; + case ColoredTeams.Green: + return cbGreenTeamColors; + case ColoredTeams.Bot: + return botTeamColors; + case ColoredTeams.Humans: + return cbBlueTeamColors; + case ColoredTeams.Nations: + return cbRedTeamColors; + default: + return [this.humanColorAllocator.assignColor(team)]; + } + } +} diff --git a/src/client/theme/Colors.ts b/src/client/theme/Colors.ts index 81b45e6228..38fe797e40 100644 --- a/src/client/theme/Colors.ts +++ b/src/client/theme/Colors.ts @@ -23,6 +23,41 @@ export const orangeTeamColors: Colord[] = generateTeamColors(orange); export const greenTeamColors: Colord[] = generateTeamColors(green); export const botTeamColors: Colord[] = [botColor]; +// High-contrast, lightness-varied palette for colorblind mode. Hue is spread by +// the golden angle and lightness walks across a wide range so colors differ in +// brightness (the cue all colorblindness types retain), not just hue. The +// allocator's greedy max-ΔE pick then keeps neighbors as distinct as possible. +export const colorblindColors: Colord[] = Array.from({ length: 32 }, (_, i) => { + const h = (i * 137.508) % 360; + const l = 35 + ((i * 7) % 50); // 35..84, spread across entries + const c = 78; + return colord({ l, c, h }); +}); + +// Colorblind-safe team base colors (Okabe-Ito), expanded into per-player +// variations the same way the pastel teams are. +export const cbBlueTeamColors: Colord[] = generateTeamColors( + colord("rgb(0,114,178)"), +); +export const cbRedTeamColors: Colord[] = generateTeamColors( + colord("rgb(213,94,0)"), // vermillion +); +export const cbTealTeamColors: Colord[] = generateTeamColors( + colord("rgb(0,158,115)"), // bluish green +); +export const cbPurpleTeamColors: Colord[] = generateTeamColors( + colord("rgb(204,121,167)"), // reddish purple +); +export const cbYellowTeamColors: Colord[] = generateTeamColors( + colord("rgb(240,228,66)"), +); +export const cbOrangeTeamColors: Colord[] = generateTeamColors( + colord("rgb(230,159,0)"), +); +export const cbGreenTeamColors: Colord[] = generateTeamColors( + colord("rgb(86,180,233)"), // sky blue (green is hard for CVD) +); + function generateTeamColors(baseColor: Colord): Colord[] { const lch = baseColor.toLch(); const colorCount = 64; diff --git a/src/client/theme/PastelTheme.ts b/src/client/theme/PastelTheme.ts index df975b75b5..9abff5b0bc 100644 --- a/src/client/theme/PastelTheme.ts +++ b/src/client/theme/PastelTheme.ts @@ -1,132 +1,65 @@ -import { Colord, colord, LabaColor } from "colord"; -import { PseudoRandom } from "../../core/PseudoRandom"; -import { PlayerType, Team, TerrainType } from "../../core/game/Game"; +import { Colord, colord } from "colord"; +import { ColoredTeams, Team, TerrainType } from "../../core/game/Game"; import { GameMap, TileRef } from "../../core/game/GameMap"; -import { PlayerView } from "../../core/game/GameView"; -import { ColorAllocator } from "./ColorAllocator"; -import { botColors, fallbackColors, humanColors, nationColors } from "./Colors"; -import { Theme } from "./Theme"; - -export class PastelTheme implements Theme { - private rand = new PseudoRandom(123); - private humanColorAllocator = new ColorAllocator(humanColors, fallbackColors); - private botColorAllocator = new ColorAllocator(botColors, botColors); - private teamColorAllocator = new ColorAllocator(humanColors, fallbackColors); - private nationColorAllocator = new ColorAllocator(nationColors, nationColors); - - private background = colord("rgb(60,60,60)"); - private shore = colord("rgb(204,203,158)"); - private falloutColors = [ - colord("rgb(120,255,71)"), // Original color - colord("rgb(130,255,85)"), // Slightly lighter - colord("rgb(110,245,65)"), // Slightly darker - colord("rgb(125,255,75)"), // Warmer tint - colord("rgb(115,250,68)"), // Cooler tint - ]; - private water = colord("rgb(70,132,180)"); - private shorelineWater = colord("rgb(100,143,255)"); - - /** Default spawn highlight colors for other players in FFA, yellow */ - private _spawnHighlightColor = colord("rgb(255,213,79)"); - /** Added non-default spawn highlight colors for self, full white */ - private _spawnHighlightSelfColor = colord("rgb(255,255,255)"); - /** Added non-default spawn highlight colors for teammates, green */ - private _spawnHighlightTeamColor = colord("rgb(0,255,0)"); - /** Added non-default spawn highlight colors for enemies, red */ - private _spawnHighlightEnemyColor = colord("rgb(255,0,0)"); - - teamColor(team: Team): Colord { - return this.teamColorAllocator.assignTeamColor(team); - } - - territoryColor(player: PlayerView): Colord { - const team = player.team(); - if (team !== null) { - return this.teamColorAllocator.assignTeamPlayerColor(team, player.id()); +import { BaseTheme } from "./BaseTheme"; +import { + blueTeamColors, + botColors, + botTeamColors, + fallbackColors, + greenTeamColors, + humanColors, + nationColors, + orangeTeamColors, + purpleTeamColors, + redTeamColors, + tealTeamColors, + yellowTeamColors, +} from "./Colors"; + +export class PastelTheme extends BaseTheme { + protected shore = colord("rgb(204,203,158)"); + protected water = colord("rgb(70,132,180)"); + protected shorelineWater = colord("rgb(100,143,255)"); + + protected humanPalette(): Colord[] { + return humanColors; + } + protected botPalette(): Colord[] { + return botColors; + } + protected nationPalette(): Colord[] { + return nationColors; + } + protected fallbackPalette(): Colord[] { + return fallbackColors; + } + + protected teamColorVariations(team: Team): Colord[] { + switch (team) { + case ColoredTeams.Blue: + return blueTeamColors; + case ColoredTeams.Red: + return redTeamColors; + case ColoredTeams.Teal: + return tealTeamColors; + case ColoredTeams.Purple: + return purpleTeamColors; + case ColoredTeams.Yellow: + return yellowTeamColors; + case ColoredTeams.Orange: + return orangeTeamColors; + case ColoredTeams.Green: + return greenTeamColors; + case ColoredTeams.Bot: + return botTeamColors; + case ColoredTeams.Humans: + return blueTeamColors; + case ColoredTeams.Nations: + return redTeamColors; + default: + return [this.humanColorAllocator.assignColor(team)]; } - if (player.type() === PlayerType.Human) { - return this.humanColorAllocator.assignColor(player.id()); - } - if (player.type() === PlayerType.Bot) { - return this.botColorAllocator.assignColor(player.id()); - } - return this.nationColorAllocator.assignColor(player.id()); - } - - structureColors(territoryColor: Colord): { light: Colord; dark: Colord } { - // Convert territory color to LAB color space. Territory color is rendered in game with alpha = 150/255, use that here. - const lightLAB = territoryColor.alpha(150 / 255).toLab(); - // Get "border color" from territory color & convert to LAB color space - const darkLAB = this.borderColor(territoryColor).toLab(); - // Calculate the contrast of the two provided colors - let contrast = this.contrast(lightLAB, darkLAB); - - // Don't want excessive contrast, so incrementally increase contrast within a loop. - // Define target values, looping limits, and loop counter - const loopLimit = 10; // Switch from darkening border to lightening fill if loopLimit is reached - const maxIterations = 50; // maximum number of loops allowed, throw error above this limit - const contrastTarget = 0.5; - let loopCount = 0; - - // Adjust luminance by 5 in each iteration. This is a balance between speed and not overdoing contrast changes. - const luminanceChange = 5; - - while (contrast < contrastTarget) { - if (loopCount > maxIterations) { - // Prevent runaway loops - console.warn(`Infinite loop detected during structure color calculation. - Light color: ${colord(lightLAB).toRgbString()}, - Dark color: ${colord(darkLAB).toRgbString()}, - Contrast: ${contrast}`); - break; - - // Increase the light color if the "loop limit" has been reach - // (probably due to the dark color already being as dark as it can be) - } else if (loopCount > loopLimit) { - lightLAB.l = this.clamp(lightLAB.l + luminanceChange); - - // Decrease the dark color first to keep the light color as close - // to the territory color as possible - } else { - darkLAB.l = this.clamp(darkLAB.l - luminanceChange); - } - - // re-calculate contrast and increment loop counter - contrast = this.contrast(lightLAB, darkLAB); - loopCount++; - } - return { light: colord(lightLAB), dark: colord(darkLAB) }; - } - - private contrast(first: LabaColor, second: LabaColor): number { - return colord(first).delta(colord(second)); - } - - private clamp(num: number, low: number = 0, high: number = 100): number { - return Math.min(Math.max(low, num), high); - } - - // Don't call directly, use PlayerView - borderColor(territoryColor: Colord): Colord { - return territoryColor.darken(0.125); - } - - defendedBorderColors(territoryColor: Colord): { - light: Colord; - dark: Colord; - } { - return { - light: territoryColor.darken(0.2), - dark: territoryColor.darken(0.4), - }; - } - - focusedBorderColor(): Colord { - return colord("rgb(230,230,230)"); - } - - textColor(player: PlayerView): string { - return player.type() === PlayerType.Human ? "#000000" : "#4D4D4D"; } // | Terrain Type | Magnitude | Base Color Logic | Visual Description | @@ -174,32 +107,4 @@ export class PastelTheme implements Theme { }); } } - - backgroundColor(): Colord { - return this.background; - } - - falloutColor(): Colord { - return this.rand.randElement(this.falloutColors); - } - - font(): string { - return "Overpass, sans-serif"; - } - - spawnHighlightColor(): Colord { - return this._spawnHighlightColor; - } - /** Return spawn highlight color for self */ - spawnHighlightSelfColor(): Colord { - return this._spawnHighlightSelfColor; - } - /** Return spawn highlight color for teammates */ - spawnHighlightTeamColor(): Colord { - return this._spawnHighlightTeamColor; - } - /** Return spawn highlight color for enemies */ - spawnHighlightEnemyColor(): Colord { - return this._spawnHighlightEnemyColor; - } } diff --git a/src/client/theme/ThemeProvider.ts b/src/client/theme/ThemeProvider.ts index 307ddde6ab..55aa5f593c 100644 --- a/src/client/theme/ThemeProvider.ts +++ b/src/client/theme/ThemeProvider.ts @@ -1,4 +1,5 @@ import { UserSettings } from "../../core/game/UserSettings"; +import { ColorblindTheme } from "./ColorblindTheme"; import { PastelTheme } from "./PastelTheme"; import { PastelThemeDark } from "./PastelThemeDark"; import { Theme } from "./Theme"; @@ -12,9 +13,13 @@ class ThemeProvider { private readonly userSettings = new UserSettings(); private light = new PastelTheme(); private dark = new PastelThemeDark(); + private colorblind = new ColorblindTheme(); - /** The active theme, selected from the user's dark-mode preference. */ + /** The active theme, from colorblind mode, then the dark-mode preference. */ current(): Theme { + if (this.userSettings.graphicsOverrides().accessibility?.colorblind) { + return this.colorblind; + } return this.userSettings.darkMode() ? this.dark : this.light; } @@ -26,6 +31,7 @@ class ThemeProvider { reset(): void { this.light = new PastelTheme(); this.dark = new PastelThemeDark(); + this.colorblind = new ColorblindTheme(); } } diff --git a/tests/Colors.test.ts b/tests/Colors.test.ts index 1177575f58..c626f3d374 100644 --- a/tests/Colors.test.ts +++ b/tests/Colors.test.ts @@ -13,6 +13,7 @@ import { teal, yellow, } from "../src/client/theme/Colors"; +import { PastelTheme } from "../src/client/theme/PastelTheme"; import { ColoredTeams } from "../src/core/game/Game"; const mockColors: Colord[] = [ @@ -80,71 +81,35 @@ describe("ColorAllocator", () => { expect(c1.isEqual(c1Again)).toBe(true); expect(c2.isEqual(c2Again)).toBe(true); }); +}); - test("assignTeamColor returns the base color from the team", () => { - expect(allocator.assignTeamColor(ColoredTeams.Blue)).toEqual(blue); - expect(allocator.assignTeamColor(ColoredTeams.Red)).toEqual(red); - expect(allocator.assignTeamColor(ColoredTeams.Teal)).toEqual(teal); - expect(allocator.assignTeamColor(ColoredTeams.Purple)).toEqual(purple); - expect(allocator.assignTeamColor(ColoredTeams.Yellow)).toEqual(yellow); - expect(allocator.assignTeamColor(ColoredTeams.Orange)).toEqual(orange); - expect(allocator.assignTeamColor(ColoredTeams.Green)).toEqual(green); - expect(allocator.assignTeamColor(ColoredTeams.Bot)).toEqual(botColor); - expect(allocator.assignTeamColor(ColoredTeams.Humans)).toEqual(blue); - expect(allocator.assignTeamColor(ColoredTeams.Nations)).toEqual(red); +describe("PastelTheme team colors", () => { + test("teamColor returns the base color from the team", () => { + const theme = new PastelTheme(); + expect(theme.teamColor(ColoredTeams.Blue)).toEqual(blue); + expect(theme.teamColor(ColoredTeams.Red)).toEqual(red); + expect(theme.teamColor(ColoredTeams.Teal)).toEqual(teal); + expect(theme.teamColor(ColoredTeams.Purple)).toEqual(purple); + expect(theme.teamColor(ColoredTeams.Yellow)).toEqual(yellow); + expect(theme.teamColor(ColoredTeams.Orange)).toEqual(orange); + expect(theme.teamColor(ColoredTeams.Green)).toEqual(green); + expect(theme.teamColor(ColoredTeams.Bot)).toEqual(botColor); + expect(theme.teamColor(ColoredTeams.Humans)).toEqual(blue); + expect(theme.teamColor(ColoredTeams.Nations)).toEqual(red); }); - test("assignTeamPlayerColor always returns the same color for the same playerID", () => { - const playerId = "player123"; - - const blueColor1 = allocator.assignTeamPlayerColor( - ColoredTeams.Blue, - playerId, - ); - const blueColor2 = allocator.assignTeamPlayerColor( - ColoredTeams.Blue, - playerId, - ); - - expect(blueColor1.isEqual(blueColor2)).toBe(true); - - const redColor1 = allocator.assignTeamPlayerColor( - ColoredTeams.Red, - playerId, - ); - const redColor2 = allocator.assignTeamPlayerColor( - ColoredTeams.Red, - playerId, - ); - - expect(redColor1.isEqual(redColor2)).toBe(true); + test("teamColorForPlayer is stable for the same playerID", () => { + const theme = new PastelTheme(); + const a = theme.teamColorForPlayer(ColoredTeams.Blue, "player123"); + const b = theme.teamColorForPlayer(ColoredTeams.Blue, "player123"); + expect(a.isEqual(b)).toBe(true); }); - test("assignTeamPlayerColor returns a different color when the playerID is different", () => { - const playerIdOne = "player1"; - const playerIdTwo = "player2"; - - const blueColorPlayerOne = allocator.assignTeamPlayerColor( - ColoredTeams.Blue, - playerIdOne, - ); - const blueColorPlayerTwo = allocator.assignTeamPlayerColor( - ColoredTeams.Blue, - playerIdTwo, - ); - - expect(blueColorPlayerOne.isEqual(blueColorPlayerTwo)).toBe(false); - - const redColorPlayerOne = allocator.assignTeamPlayerColor( - ColoredTeams.Red, - playerIdOne, - ); - const redColorPlayerTwo = allocator.assignTeamPlayerColor( - ColoredTeams.Red, - playerIdTwo, - ); - - expect(redColorPlayerOne.isEqual(redColorPlayerTwo)).toBe(false); + test("teamColorForPlayer differs for different playerIDs", () => { + const theme = new PastelTheme(); + const a = theme.teamColorForPlayer(ColoredTeams.Blue, "player1"); + const b = theme.teamColorForPlayer(ColoredTeams.Blue, "player2"); + expect(a.isEqual(b)).toBe(false); }); }); From f2451c7a6077924ad61a7d3ed87e467891cc8da3 Mon Sep 17 00:00:00 2001 From: Noah Schmalenberger Date: Wed, 3 Jun 2026 19:19:43 +0000 Subject: [PATCH 4/9] Tune colorblind theme: distinct borders, CVD terrain, stronger tints Give the colorblind theme its own visual tuning on top of the swapped palettes: - Borders derive from each territory's fill but are darkened *relative* to the fill's own lightness (l * 0.6) rather than by a fixed amount, so every boundary reads as a consistently darker line without dark fills collapsing to near-black. - Terrain elevation bands are separated by lightness (dark plains -> mid highland -> near-white mountain) instead of the green->brown->gray hue ramp, which is hard to distinguish under red-green CVD. - Friend/foe border tint ratios raised to 0.85 so the blue/orange relationship cue dominates the darkened border instead of relying on subtle hue. --- src/client/render/gl/RenderOverrides.ts | 7 ++-- src/client/theme/ColorblindTheme.ts | 48 +++++++++++++++++++++++-- 2 files changed, 51 insertions(+), 4 deletions(-) diff --git a/src/client/render/gl/RenderOverrides.ts b/src/client/render/gl/RenderOverrides.ts index 14df6bcff1..84810c5d23 100644 --- a/src/client/render/gl/RenderOverrides.ts +++ b/src/client/render/gl/RenderOverrides.ts @@ -85,8 +85,11 @@ export function applyGraphicsOverrides( settings.mapOverlay.embargoTintR = 0.835; settings.mapOverlay.embargoTintG = 0.369; settings.mapOverlay.embargoTintB = 0; - settings.mapOverlay.friendlyTintRatio = 0.6; - settings.mapOverlay.embargoTintRatio = 0.6; + // Strong ratio so the friend/foe tint dominates the darkened territory + // border — neutral keeps its (darkened) fill hue, ally reads blue, enemy + // reads orange. + settings.mapOverlay.friendlyTintRatio = 0.85; + settings.mapOverlay.embargoTintRatio = 0.85; } } diff --git a/src/client/theme/ColorblindTheme.ts b/src/client/theme/ColorblindTheme.ts index 44bef40e9f..8c6690164e 100644 --- a/src/client/theme/ColorblindTheme.ts +++ b/src/client/theme/ColorblindTheme.ts @@ -1,5 +1,6 @@ -import { Colord } from "colord"; -import { ColoredTeams, Team } from "../../core/game/Game"; +import { Colord, colord } from "colord"; +import { ColoredTeams, Team, TerrainType } from "../../core/game/Game"; +import { GameMap, TileRef } from "../../core/game/GameMap"; import { botTeamColors, cbBlueTeamColors, @@ -55,4 +56,47 @@ export class ColorblindTheme extends PastelTheme { return [this.humanColorAllocator.assignColor(team)]; } } + + // Fill-derived border, darkened *relative* to each fill's own lightness + // rather than by a fixed amount. An absolute darken (e.g. .darken(0.3)) + // pushes already-dark fills to near-black while barely touching light ones, + // so borders read inconsistently across nations. Scaling lightness keeps + // every border the same proportion darker than its territory — distinct, but + // still hued and never collapsing to black. Friend/foe tints are mixed on top + // in the border shader. + borderColor(territoryColor: Colord): Colord { + const hsl = territoryColor.toHsl(); + return colord({ ...hsl, l: hsl.l * 0.6 }); + } + + // CVD-tuned terrain: separate elevation bands by *lightness* (the cue all + // colorblindness types keep) rather than the green→brown→gray hue ramp, which + // blurs plains↔hills under red-green CVD. Dark plains → mid hills → bright + // mountains. Water/shore are inherited (blue is already CVD-safe). + terrainColor(gm: GameMap, tile: TileRef): Colord { + const mag = gm.magnitude(tile); + if (gm.isShore(tile)) { + return this.shore; + } + switch (gm.terrainType(tile)) { + case TerrainType.Ocean: + case TerrainType.Lake: { + const w = this.water.rgba; + if (gm.isShoreline(tile) && gm.isWater(tile)) { + return this.shorelineWater; + } + return colord({ + r: Math.max(w.r - 10 + (11 - Math.min(mag, 10)), 0), + g: Math.max(w.g - 10 + (11 - Math.min(mag, 10)), 0), + b: Math.max(w.b - 10 + (11 - Math.min(mag, 10)), 0), + }); + } + case TerrainType.Plains: // dark green, low lightness + return colord({ r: 90, g: 140 - mag, b: 70 }); + case TerrainType.Highland: // mid ochre, clearly lighter than plains + return colord({ r: 165 + 2 * mag, g: 145 + 2 * mag, b: 105 + mag }); + case TerrainType.Mountain: // near-white, brightest band + return colord({ r: 225 + mag / 2, g: 225 + mag / 2, b: 228 + mag / 2 }); + } + } } From 2d2c8df7c4903406fd269a8185d7f24e47017f75 Mon Sep 17 00:00:00 2001 From: Noah Schmalenberger Date: Wed, 3 Jun 2026 19:41:49 +0000 Subject: [PATCH 5/9] Address review: docstrings, exhaustive terrain switch, colorblind test - Add JSDoc docstrings across the theme classes/methods, ColorAllocator helpers, RenderOverrides functions, and the colorblind settings methods to satisfy the docstring-coverage threshold. - Add an exhaustiveness guard to the terrainColor switch in PastelTheme and ColorblindTheme so a new TerrainType becomes a compile error rather than a silent undefined return. - Fix an orphaned comment in BaseTheme.structureColors. - Add a test verifying ColorblindTheme applies a palette distinct from PastelTheme. --- src/client/UserSettingModal.ts | 2 ++ src/client/render/gl/RenderOverrides.ts | 6 ++++ src/client/theme/BaseTheme.ts | 43 +++++++++++++++++++++---- src/client/theme/ColorAllocator.ts | 17 ++++++++-- src/client/theme/ColorblindTheme.ts | 36 ++++++++++++++------- src/client/theme/PastelTheme.ts | 33 +++++++++++++------ tests/Colors.test.ts | 24 ++++++++++++++ 7 files changed, 131 insertions(+), 30 deletions(-) diff --git a/src/client/UserSettingModal.ts b/src/client/UserSettingModal.ts index 4318de7ad6..13f863bf12 100644 --- a/src/client/UserSettingModal.ts +++ b/src/client/UserSettingModal.ts @@ -206,12 +206,14 @@ export class UserSettingModal extends BaseModal { console.log("🌙 Dark Mode:", this.userSettings.darkMode() ? "ON" : "OFF"); } + /** Whether colorblind mode is currently enabled in the graphics overrides. */ private colorblindMode(): boolean { return ( this.userSettings.graphicsOverrides().accessibility?.colorblind ?? false ); } + /** Flip the colorblind-mode graphics override and persist it. */ private toggleColorblindMode() { const overrides = this.userSettings.graphicsOverrides(); this.userSettings.setGraphicsOverrides({ diff --git a/src/client/render/gl/RenderOverrides.ts b/src/client/render/gl/RenderOverrides.ts index 84810c5d23..601a489eaf 100644 --- a/src/client/render/gl/RenderOverrides.ts +++ b/src/client/render/gl/RenderOverrides.ts @@ -3,6 +3,11 @@ import type { RenderSettings } from "./RenderSettings"; const DARK_AMBIENT = 0.35; +/** + * Apply the user's graphics overrides onto a RenderSettings in place: name + * scaling, classic/dark structure and name styling, and the colorblind-safe + * affiliation/tint palette. + */ export function applyGraphicsOverrides( settings: RenderSettings, overrides: GraphicsOverrides, @@ -93,6 +98,7 @@ export function applyGraphicsOverrides( } } +/** Apply dark-mode lighting (ambient + enabled) onto settings when active. */ export function applyDarkModeOverride( settings: RenderSettings, isDark: boolean, diff --git a/src/client/theme/BaseTheme.ts b/src/client/theme/BaseTheme.ts index 768534d1c8..80cac305a6 100644 --- a/src/client/theme/BaseTheme.ts +++ b/src/client/theme/BaseTheme.ts @@ -51,15 +51,21 @@ export abstract class BaseTheme implements Theme { } // --- Color data: concrete themes provide these --- + /** Color pool for human players. */ protected abstract humanPalette(): Colord[]; + /** Color pool for bot players. */ protected abstract botPalette(): Colord[]; + /** Color pool for nation (FFA AI) players. */ protected abstract nationPalette(): Colord[]; + /** Extra colors used once the human pool is exhausted. */ protected abstract fallbackPalette(): Colord[]; /** Per-team color variations; index 0 is the team's base color. */ protected abstract teamColorVariations(team: Team): Colord[]; + /** Color for a terrain tile, based on its type and elevation magnitude. */ abstract terrainColor(gm: GameMap, tile: TileRef): Colord; // --- Allocation dispatch (overridable) --- + /** Base color for a team (the first entry of its variations). */ teamColor(team: Team): Colord { const rgb = this.teamColorVariations(team)[0].toRgb(); return colord({ @@ -69,6 +75,11 @@ export abstract class BaseTheme implements Theme { }); } + /** + * Color for a player's territory: a per-player variation when the player is + * on a team, otherwise a distinct color allocated from the matching pool + * (human / bot / nation). + */ territoryColor(player: PlayerView): Colord { const team = player.team(); if (team !== null) { @@ -96,6 +107,11 @@ export abstract class BaseTheme implements Theme { } // --- Shared color math --- + /** + * Derive the light/dark color pair used to render a structure icon over a + * territory, nudging luminance until the two reach a minimum contrast so the + * icon stays legible on any fill. + */ structureColors(territoryColor: Colord): { light: Colord; dark: Colord } { // Convert territory color to LAB color space. Territory color is rendered in game with alpha = 150/255, use that here. const lightLAB = territoryColor.alpha(150 / 255).toLab(); @@ -122,15 +138,13 @@ export abstract class BaseTheme implements Theme { Dark color: ${colord(darkLAB).toRgbString()}, Contrast: ${contrast}`); break; - - // Increase the light color if the "loop limit" has been reach - // (probably due to the dark color already being as dark as it can be) } else if (loopCount > loopLimit) { + // Increase the light color once the loop limit is reached (probably + // because the dark color is already as dark as it can get). lightLAB.l = this.clamp(lightLAB.l + luminanceChange); - - // Decrease the dark color first to keep the light color as close - // to the territory color as possible } else { + // Decrease the dark color first to keep the light color as close + // to the territory color as possible. darkLAB.l = this.clamp(darkLAB.l - luminanceChange); } @@ -141,19 +155,25 @@ export abstract class BaseTheme implements Theme { return { light: colord(lightLAB), dark: colord(darkLAB) }; } + /** Perceptual (CIE76 delta-E) distance between two LAB colors. */ private contrast(first: LabaColor, second: LabaColor): number { return colord(first).delta(colord(second)); } + /** Clamp a number into the inclusive [low, high] range (default 0–100). */ private clamp(num: number, low: number = 0, high: number = 100): number { return Math.min(Math.max(low, num), high); } - // Don't call directly, use PlayerView + /** + * Border color for a territory. Don't call directly — use PlayerView. + * Themes override this to change how borders relate to the fill. + */ borderColor(territoryColor: Colord): Colord { return territoryColor.darken(0.125); } + /** Light/dark border pair used to render a defended (fortified) border. */ defendedBorderColors(territoryColor: Colord): { light: Colord; dark: Colord; @@ -164,35 +184,44 @@ export abstract class BaseTheme implements Theme { }; } + /** Border color used to highlight the currently focused player. */ focusedBorderColor(): Colord { return colord("rgb(230,230,230)"); } + /** Player name text color (darker for humans, gray for AI). */ textColor(player: PlayerView): string { return player.type() === PlayerType.Human ? "#000000" : "#4D4D4D"; } + /** Map background color. */ backgroundColor(): Colord { return this.background; } + /** A random color from the fallout palette (for the nuke fallout effect). */ falloutColor(): Colord { return this.rand.randElement(this.falloutColors); } + /** Font stack used for in-map text. */ font(): string { return "Overpass, sans-serif"; } + /** Highlight color for a spawnable tile during the spawn phase. */ spawnHighlightColor(): Colord { return this._spawnHighlightColor; } + /** Spawn highlight color for the local player's own tiles. */ spawnHighlightSelfColor(): Colord { return this._spawnHighlightSelfColor; } + /** Spawn highlight color for teammates' tiles. */ spawnHighlightTeamColor(): Colord { return this._spawnHighlightTeamColor; } + /** Spawn highlight color for enemies' tiles. */ spawnHighlightEnemyColor(): Colord { return this._spawnHighlightEnemyColor; } diff --git a/src/client/theme/ColorAllocator.ts b/src/client/theme/ColorAllocator.ts index 2a76a6de94..74f638e7f3 100644 --- a/src/client/theme/ColorAllocator.ts +++ b/src/client/theme/ColorAllocator.ts @@ -23,6 +23,13 @@ export class ColorAllocator { this.fallbackColors = [...colors, ...fallback]; } + /** + * 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. + */ assignColor(id: string): Colord { if (this.assigned.has(id)) { return this.assigned.get(id)!; @@ -53,8 +60,11 @@ export class ColorAllocator { } } -// Select a distinct color index from the available colors that -// is most different from the assigned colors +/** + * Index of the available color that is most perceptually different from the + * already-assigned colors (the one whose nearest assigned neighbor is farthest + * away, by delta-E 2000). Throws if no colors have been assigned yet. + */ export function selectDistinctColorIndex( availableColors: Colord[], assignedColors: Colord[], @@ -79,16 +89,19 @@ export function selectDistinctColorIndex( return maxIndex; } +/** Smallest delta-E 2000 distance from `lab1` to any of the assigned colors. */ function minDeltaE(lab1: Color, assignedLabColors: Color[]) { return assignedLabColors.reduce((min, assigned) => { return Math.min(min, deltaE2000(lab1, assigned)); }, Infinity); } +/** Perceptual distance between two colors using the CIEDE2000 formula. */ function deltaE2000(c1: Color, c2: Color): number { return c1.deltaE(c2, "2000"); } +/** Convert a colord color to a colorjs.io LAB color for delta-E math. */ function toColor(colord: Colord): Color { const lab = colord.toLab(); return new Color("lab", [lab.l, lab.a, lab.b]); diff --git a/src/client/theme/ColorblindTheme.ts b/src/client/theme/ColorblindTheme.ts index 8c6690164e..50d6835fbf 100644 --- a/src/client/theme/ColorblindTheme.ts +++ b/src/client/theme/ColorblindTheme.ts @@ -20,6 +20,7 @@ import { PastelTheme } from "./PastelTheme"; * allocation logic from BaseTheme via PastelTheme. */ export class ColorblindTheme extends PastelTheme { + /** All player pools share the single CVD-safe, lightness-varied palette. */ protected humanPalette(): Colord[] { return colorblindColors; } @@ -30,6 +31,7 @@ export class ColorblindTheme extends PastelTheme { return colorblindColors; } + /** Colorblind-safe per-team variations (blue/orange-anchored Okabe-Ito). */ protected teamColorVariations(team: Team): Colord[] { switch (team) { case ColoredTeams.Blue: @@ -57,28 +59,33 @@ export class ColorblindTheme extends PastelTheme { } } - // Fill-derived border, darkened *relative* to each fill's own lightness - // rather than by a fixed amount. An absolute darken (e.g. .darken(0.3)) - // pushes already-dark fills to near-black while barely touching light ones, - // so borders read inconsistently across nations. Scaling lightness keeps - // every border the same proportion darker than its territory — distinct, but - // still hued and never collapsing to black. Friend/foe tints are mixed on top - // in the border shader. + /** + * Fill-derived border, darkened *relative* to each fill's own lightness + * rather than by a fixed amount. An absolute darken (e.g. .darken(0.3)) + * pushes already-dark fills to near-black while barely touching light ones, + * so borders read inconsistently across nations. Scaling lightness keeps + * every border the same proportion darker than its territory — distinct, but + * still hued and never collapsing to black. Friend/foe tints are mixed on top + * in the border shader. + */ borderColor(territoryColor: Colord): Colord { const hsl = territoryColor.toHsl(); return colord({ ...hsl, l: hsl.l * 0.6 }); } - // CVD-tuned terrain: separate elevation bands by *lightness* (the cue all - // colorblindness types keep) rather than the green→brown→gray hue ramp, which - // blurs plains↔hills under red-green CVD. Dark plains → mid hills → bright - // mountains. Water/shore are inherited (blue is already CVD-safe). + /** + * CVD-tuned terrain: separate elevation bands by *lightness* (the cue all + * colorblindness types keep) rather than the green→brown→gray hue ramp, which + * blurs plains↔hills under red-green CVD. Dark plains → mid hills → bright + * mountains. Water/shore are inherited (blue is already CVD-safe). + */ terrainColor(gm: GameMap, tile: TileRef): Colord { const mag = gm.magnitude(tile); if (gm.isShore(tile)) { return this.shore; } - switch (gm.terrainType(tile)) { + const type = gm.terrainType(tile); + switch (type) { case TerrainType.Ocean: case TerrainType.Lake: { const w = this.water.rgba; @@ -97,6 +104,11 @@ export class ColorblindTheme extends PastelTheme { return colord({ r: 165 + 2 * mag, g: 145 + 2 * mag, b: 105 + mag }); case TerrainType.Mountain: // near-white, brightest band return colord({ r: 225 + mag / 2, g: 225 + mag / 2, b: 228 + mag / 2 }); + default: { + // Exhaustiveness guard: a new TerrainType is a compile error here. + const _exhaustive: never = type; + return _exhaustive; + } } } } diff --git a/src/client/theme/PastelTheme.ts b/src/client/theme/PastelTheme.ts index 9abff5b0bc..2bd094c1e7 100644 --- a/src/client/theme/PastelTheme.ts +++ b/src/client/theme/PastelTheme.ts @@ -17,6 +17,11 @@ import { yellowTeamColors, } from "./Colors"; +/** + * Default light theme — soft pastel player palettes and a naturalistic + * (green → tan → white) terrain ramp. Other themes extend it to reuse the + * shared terrain/water colors while swapping palettes. + */ export class PastelTheme extends BaseTheme { protected shore = colord("rgb(204,203,158)"); protected water = colord("rgb(70,132,180)"); @@ -62,20 +67,25 @@ export class PastelTheme extends BaseTheme { } } - // | Terrain Type | Magnitude | Base Color Logic | Visual Description | - // | :---------------- | :-------- | :---------------------------------------------- | :------------------------------------------------------------------- | - // | **Shore (Land)** | N/A | Fixed: `rgb(204, 203, 158)` | Sandy beige. Overrides other land types if adjacent to water. | - // | **Plains** | 0 - 9 | `rgb(190, 220, 138)` - `rgb(190, 202, 138)` | Light green. Gets slightly darker/less green as magnitude increases. | - // | **Highland** | 10 - 19 | `rgb(220, 203, 158)` - `rgb(238, 221, 176)` | Tan/Beige. Gets lighter as magnitude increases. | - // | **Mountain** | 20 - 30 | `rgb(240, 240, 240)` - `rgb(245, 245, 245)` | Grayscale (White/Grey). Represents snow caps or rocky peaks. | - // | **Water (Shore)** | 0 | Fixed: `rgb(100, 143, 255)` | Light blue near land. | - // | **Water (Deep)** | 1 - 10+ | `rgb(70, 132, 180)` - `rgb(61, 123, 171)` | Darker blue, adjusted slightly by distance to land. | + /** + * Naturalistic terrain ramp by type and elevation magnitude: + * + * | Terrain Type | Magnitude | Base Color Logic | Visual Description | + * | :---------------- | :-------- | :---------------------------------------------- | :------------------------------------------------------------------- | + * | **Shore (Land)** | N/A | Fixed: `rgb(204, 203, 158)` | Sandy beige. Overrides other land types if adjacent to water. | + * | **Plains** | 0 - 9 | `rgb(190, 220, 138)` - `rgb(190, 202, 138)` | Light green. Gets slightly darker/less green as magnitude increases. | + * | **Highland** | 10 - 19 | `rgb(220, 203, 158)` - `rgb(238, 221, 176)` | Tan/Beige. Gets lighter as magnitude increases. | + * | **Mountain** | 20 - 30 | `rgb(240, 240, 240)` - `rgb(245, 245, 245)` | Grayscale (White/Grey). Represents snow caps or rocky peaks. | + * | **Water (Shore)** | 0 | Fixed: `rgb(100, 143, 255)` | Light blue near land. | + * | **Water (Deep)** | 1 - 10+ | `rgb(70, 132, 180)` - `rgb(61, 123, 171)` | Darker blue, adjusted slightly by distance to land. | + */ terrainColor(gm: GameMap, tile: TileRef): Colord { const mag = gm.magnitude(tile); if (gm.isShore(tile)) { return this.shore; } - switch (gm.terrainType(tile)) { + const type = gm.terrainType(tile); + switch (type) { case TerrainType.Ocean: { const w = this.water.rgba; if (gm.isShoreline(tile) && gm.isWater(tile)) { @@ -105,6 +115,11 @@ export class PastelTheme extends BaseTheme { g: 230 + mag / 2, b: 230 + mag / 2, }); + default: { + // Exhaustiveness guard: a new TerrainType is a compile error here. + const _exhaustive: never = type; + return _exhaustive; + } } } } diff --git a/tests/Colors.test.ts b/tests/Colors.test.ts index c626f3d374..f61982ab98 100644 --- a/tests/Colors.test.ts +++ b/tests/Colors.test.ts @@ -3,6 +3,7 @@ import { ColorAllocator, selectDistinctColorIndex, } from "../src/client/theme/ColorAllocator"; +import { ColorblindTheme } from "../src/client/theme/ColorblindTheme"; import { blue, botColor, @@ -113,6 +114,29 @@ describe("PastelTheme team colors", () => { }); }); +describe("ColorblindTheme", () => { + test("applies a palette distinct from PastelTheme", () => { + const pastel = new PastelTheme(); + const colorblind = new ColorblindTheme(); + + // At least one team's base color should differ — the colorblind theme + // swaps the team palettes for CVD-safe (Okabe-Ito) colors. + const teams = [ + ColoredTeams.Blue, + ColoredTeams.Red, + ColoredTeams.Teal, + ColoredTeams.Purple, + ColoredTeams.Yellow, + ColoredTeams.Orange, + ColoredTeams.Green, + ]; + const anyDifferent = teams.some( + (team) => !pastel.teamColor(team).isEqual(colorblind.teamColor(team)), + ); + expect(anyDifferent).toBe(true); + }); +}); + describe("selectDistinctColor", () => { test("returns the most distant color", () => { const assignedColors = [colord({ r: 255, g: 0, b: 0 })]; // bright red From 9855a272b983009cc9e6e6b3b8187c892d339337 Mon Sep 17 00:00:00 2001 From: Noah Schmalenberger Date: Wed, 3 Jun 2026 19:53:58 +0000 Subject: [PATCH 6/9] Drop impossible null return from selectDistinctColorIndex The function only throws or returns a number index, so the `| null` return type and the `?? 0` guard at the call site were dead. Narrow the return type to `number` and remove the now-unnecessary fallback and the null assertion in the test. --- src/client/theme/ColorAllocator.ts | 8 +++++--- tests/Colors.test.ts | 3 +-- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/client/theme/ColorAllocator.ts b/src/client/theme/ColorAllocator.ts index 74f638e7f3..c5e1df96ee 100644 --- a/src/client/theme/ColorAllocator.ts +++ b/src/client/theme/ColorAllocator.ts @@ -50,8 +50,10 @@ export class ColorAllocator { selectedIndex = rand.nextInt(0, this.availableColors.length); } else { const assignedColors = Array.from(this.assigned.values()); - selectedIndex = - selectDistinctColorIndex(this.availableColors, assignedColors) ?? 0; + selectedIndex = selectDistinctColorIndex( + this.availableColors, + assignedColors, + ); } const color = this.availableColors.splice(selectedIndex, 1)[0]; @@ -68,7 +70,7 @@ export class ColorAllocator { export function selectDistinctColorIndex( availableColors: Colord[], assignedColors: Colord[], -): number | null { +): number { if (assignedColors.length === 0) { throw new Error("No assigned colors"); } diff --git a/tests/Colors.test.ts b/tests/Colors.test.ts index f61982ab98..627c855705 100644 --- a/tests/Colors.test.ts +++ b/tests/Colors.test.ts @@ -147,8 +147,7 @@ describe("selectDistinctColor", () => { ]; const result = selectDistinctColorIndex(availableColors, assignedColors); - expect(result).not.toBeNull(); - const rgb = availableColors[result!].toRgb(); + const rgb = availableColors[result].toRgb(); expect([ { r: 0, g: 255, b: 0, a: 1 }, { r: 0, g: 0, b: 255, a: 1 }, From f163443e14b8f8b5ff048077fd8ce5cfb7404a97 Mon Sep 17 00:00:00 2001 From: Noah Schmalenberger Date: Thu, 4 Jun 2026 23:21:33 +0000 Subject: [PATCH 7/9] Add colorblind toggle to the in-game graphics settings Colorblind mode lives under graphicsOverrides.accessibility, so add it to the in-game GraphicsSettingsModal under a new "Accessibility" section, reusing the existing colorblind_label/desc strings. Toggling it patches the accessibility overrides like the other graphics toggles, which the ClientGameRunner graphics listener applies live. --- resources/lang/en.json | 1 + .../hud/layers/GraphicsSettingsModal.ts | 50 +++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/resources/lang/en.json b/resources/lang/en.json index ec3eb489d3..1851e86bb8 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -946,6 +946,7 @@ "colored": "Colored", "black": "Black", "section_structure_icons": "Structure Icons", + "section_accessibility": "Accessibility", "classic_icons_label": "Classic icons", "classic_icons_desc": "Lighter outline with near-black interior", "section_map": "Map", diff --git a/src/client/hud/layers/GraphicsSettingsModal.ts b/src/client/hud/layers/GraphicsSettingsModal.ts index 5ffd316c17..480701648e 100644 --- a/src/client/hud/layers/GraphicsSettingsModal.ts +++ b/src/client/hud/layers/GraphicsSettingsModal.ts @@ -288,6 +288,18 @@ export class GraphicsSettingsModal extends LitElement implements Controller { this.requestUpdate(); } + /** Merge a patch into the accessibility graphics overrides and persist it. */ + private patchAccessibility( + patch: Partial, + ) { + const current = this.userSettings.graphicsOverrides(); + this.userSettings.setGraphicsOverrides({ + ...current, + accessibility: { ...current.accessibility, ...patch }, + }); + this.requestUpdate(); + } + private currentSpecialEffects(): boolean { return ( this.userSettings.graphicsOverrides().passEnabled?.fx ?? @@ -299,6 +311,18 @@ export class GraphicsSettingsModal extends LitElement implements Controller { this.patchPassEnabled({ fx: !this.currentSpecialEffects() }); } + /** Whether colorblind mode is currently enabled. */ + private currentColorblind(): boolean { + return ( + this.userSettings.graphicsOverrides().accessibility?.colorblind ?? false + ); + } + + /** Toggle colorblind-friendly colors. */ + private onToggleColorblind() { + this.patchAccessibility({ colorblind: !this.currentColorblind() }); + } + private onNameScaleChange(event: Event) { const value = parseFloat((event.target as HTMLInputElement).value); this.patchName({ nameScaleFactor: value }); @@ -339,6 +363,7 @@ export class GraphicsSettingsModal extends LitElement implements Controller { const territoryAlpha = this.currentTerritoryAlpha(); const railDrawDistance = RAIL_ZOOM_MAX - this.currentRailMinZoom(); const railThickness = this.currentRailThickness(); + const colorblind = this.currentColorblind(); return html`
+
+ ${translateText("graphics_setting.section_accessibility")} +
+ + +