diff --git a/resources/lang/en.json b/resources/lang/en.json index ed980ff7fd..9afee34aac 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -798,6 +798,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", @@ -930,6 +932,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", "reset_label": "Reset to defaults", diff --git a/src/client/UserSettingModal.ts b/src/client/UserSettingModal.ts index b7b821e926..b48938eb1f 100644 --- a/src/client/UserSettingModal.ts +++ b/src/client/UserSettingModal.ts @@ -206,6 +206,25 @@ 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({ + ...overrides, + accessibility: { + ...overrides.accessibility, + colorblind: !this.colorblindMode(), + }, + }); + } + private toggleEmojis() { this.userSettings.toggleEmojis(); @@ -751,6 +770,15 @@ export class UserSettingModal extends BaseModal { @change=${this.toggleDarkMode} > + + + , + ) { + const current = this.userSettings.graphicsOverrides(); + this.userSettings.setGraphicsOverrides({ + ...current, + accessibility: { ...current.accessibility, ...patch }, + }); + this.requestUpdate(); + } + + /** 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 }); @@ -179,6 +203,7 @@ export class GraphicsSettingsModal extends LitElement implements Controller { const nameCull = this.currentNameCull(); const namesColored = !this.currentDarkNames(); const classicIcons = this.currentClassicIcons(); + const colorblind = this.currentColorblind(); return html` + + ${translateText("graphics_setting.section_accessibility")} + + + + + + ${translateText("user_setting.colorblind_label")} + + + ${translateText("user_setting.colorblind_desc")} + + + + ${colorblind + ? translateText("user_setting.on") + : translateText("user_setting.off")} + + + (); + + // 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 --- + /** 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({ + r: Math.round(rgb.r), + g: Math.round(rgb.g), + b: Math.round(rgb.b), + }); + } + + /** + * 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) { + 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 --- + /** + * 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(); + // 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; + } 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); + } 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); + } + + // re-calculate contrast and increment loop counter + contrast = this.contrast(lightLAB, darkLAB); + loopCount++; + } + 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); + } + + /** + * 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; + } { + return { + light: territoryColor.darken(0.2), + dark: territoryColor.darken(0.4), + }; + } + + /** 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 317b00559a..c5e1df96ee 100644 --- a/src/client/theme/ColorAllocator.ts +++ b/src/client/theme/ColorAllocator.ts @@ -1,61 +1,35 @@ -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)]; - } - } - + /** + * 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)!; @@ -76,46 +50,27 @@ 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]; 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 -// 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[], -): number | null { +): number { if (assignedColors.length === 0) { throw new Error("No assigned colors"); } @@ -136,16 +91,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 new file mode 100644 index 0000000000..50d6835fbf --- /dev/null +++ b/src/client/theme/ColorblindTheme.ts @@ -0,0 +1,114 @@ +import { Colord, colord } from "colord"; +import { ColoredTeams, Team, TerrainType } from "../../core/game/Game"; +import { GameMap, TileRef } from "../../core/game/GameMap"; +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 { + /** All player pools share the single CVD-safe, lightness-varied palette. */ + protected humanPalette(): Colord[] { + return colorblindColors; + } + protected botPalette(): Colord[] { + return colorblindColors; + } + protected nationPalette(): Colord[] { + return colorblindColors; + } + + /** Colorblind-safe per-team variations (blue/orange-anchored Okabe-Ito). */ + 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)]; + } + } + + /** + * 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; + } + const type = gm.terrainType(tile); + switch (type) { + 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 }); + default: { + // Exhaustiveness guard: a new TerrainType is a compile error here. + const _exhaustive: never = type; + return _exhaustive; + } + } + } +} 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 b11e5d01e9..093f6efe61 100644 --- a/src/client/theme/PastelTheme.ts +++ b/src/client/theme/PastelTheme.ts @@ -1,148 +1,91 @@ -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"; + +/** + * 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)"); + 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 | - // | :---------------- | :-------- | :---------------------------------------------- | :------------------------------------------------------------------- | - // | **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: case TerrainType.Lake: { const w = this.water.rgba; @@ -173,34 +116,11 @@ export class PastelTheme implements Theme { 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; + } } } - - 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..627c855705 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, @@ -13,6 +14,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 +82,58 @@ 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, - ); + 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); + }); - expect(redColor1.isEqual(redColor2)).toBe(true); + 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); }); +}); - test("assignTeamPlayerColor returns a different color when the playerID is different", () => { - const playerIdOne = "player1"; - const playerIdTwo = "player2"; +describe("ColorblindTheme", () => { + test("applies a palette distinct from PastelTheme", () => { + const pastel = new PastelTheme(); + const colorblind = new ColorblindTheme(); - const blueColorPlayerOne = allocator.assignTeamPlayerColor( - ColoredTeams.Blue, - playerIdOne, - ); - const blueColorPlayerTwo = allocator.assignTeamPlayerColor( + // 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, - playerIdTwo, - ); - - expect(blueColorPlayerOne.isEqual(blueColorPlayerTwo)).toBe(false); - - const redColorPlayerOne = allocator.assignTeamPlayerColor( - ColoredTeams.Red, - playerIdOne, - ); - const redColorPlayerTwo = allocator.assignTeamPlayerColor( ColoredTeams.Red, - playerIdTwo, + ColoredTeams.Teal, + ColoredTeams.Purple, + ColoredTeams.Yellow, + ColoredTeams.Orange, + ColoredTeams.Green, + ]; + const anyDifferent = teams.some( + (team) => !pastel.teamColor(team).isEqual(colorblind.teamColor(team)), ); - - expect(redColorPlayerOne.isEqual(redColorPlayerTwo)).toBe(false); + expect(anyDifferent).toBe(true); }); }); @@ -158,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 },