diff --git a/src/client/components/LobbyPlayerView.ts b/src/client/components/LobbyPlayerView.ts index d4f4510e1..ad7f232f4 100644 --- a/src/client/components/LobbyPlayerView.ts +++ b/src/client/components/LobbyPlayerView.ts @@ -16,8 +16,7 @@ import { assignTeamsLobbyPreview } from "../../core/game/TeamAssignment"; import { UserSettings } from "../../core/game/UserSettings"; import { ClientInfo, TeamCountConfig } from "../../core/Schemas"; import { createRandomName, formatPlayerDisplayName } from "../../core/Util"; -import { Theme } from "../theme/Theme"; -import { themeProvider } from "../theme/ThemeProvider"; +import { Theme, themeProvider } from "../theme/ThemeProvider"; import { getTranslatedPlayerTeamLabel, translateText } from "../Utils"; export interface TeamPreviewData { diff --git a/src/client/hud/SpriteLoader.ts b/src/client/hud/SpriteLoader.ts index 8361bd0ac..4facdfab0 100644 --- a/src/client/hud/SpriteLoader.ts +++ b/src/client/hud/SpriteLoader.ts @@ -2,7 +2,7 @@ import { Colord } from "colord"; import { assetUrl } from "../../core/AssetUrls"; import { TrainType, UnitType } from "../../core/game/Game"; import { UnitView } from "../../core/game/GameView"; -import { Theme } from "../theme/Theme"; +import { Theme } from "../theme/ThemeProvider"; const atomBombSprite = assetUrl("sprites/atombomb.png"); const hydrogenBombSprite = assetUrl("sprites/hydrogenbomb.png"); const mirvSprite = assetUrl("sprites/mirv2.png"); diff --git a/src/client/render/CLAUDE.md b/src/client/render/CLAUDE.md index 68520110b..8991f264c 100644 --- a/src/client/render/CLAUDE.md +++ b/src/client/render/CLAUDE.md @@ -49,6 +49,7 @@ each frame (and animate from local time, e.g. the spawn-overlay breath). | `gl/Camera.ts` | World↔screen math; mutated externally each frame via `setCameraState` | | `gl/RenderSettings.ts` | Typed view of `render-settings.json` (tuning knobs) | | `gl/render-settings.json` | All per-pass tuning constants (alpha, radii, colors, etc.) | +| `gl/*-theme.json` | Theme data (player/team palettes, color-derivation knobs) — the active one is combined into `settings.theme` at runtime | | `gl/passes/` | One file per pass — see "Pass conventions" below | | `gl/utils/` | Cross-pass helpers: `GlUtils` (program/shader compile), `TileCodec` (`OWNER_MASK` etc.), `NukeTrajectory` (Bezier math), `Affiliation`, `HeatManager`, `GpuResources` | | `gl/shaders/` | `.glsl` source files (`?raw` imported by passes) | @@ -144,6 +145,14 @@ constants. Passes read their slice (`settings.spawnOverlay`, `settings.bar`, etc.) at construct time and use it in `draw`. The debug GUI in `gl/debug/` gives a live-editable view of the same object during development. +Theme data (player/team palettes, color-derivation knobs) lives in sibling +theme JSONs (`gl/default-theme.json`, `gl/colorblind-theme.json`); +`createRenderSettings()` combines the active one with `render-settings.json` +into the `settings.theme` slice (the colorblind graphics override swaps the +slice in `applyGraphicsOverrides`). The theme module in `src/client/theme/` +builds its allocators and color derivations from the same theme JSONs — see +`ThemeProvider.ts`. + ## Adding a new pass 1. Define any new types in `types/` if the pass needs new input shapes. diff --git a/src/client/render/gl/RenderOverrides.ts b/src/client/render/gl/RenderOverrides.ts index f5175ccf2..5d718cb13 100644 --- a/src/client/render/gl/RenderOverrides.ts +++ b/src/client/render/gl/RenderOverrides.ts @@ -1,5 +1,5 @@ import type { GraphicsOverrides } from "./GraphicsOverrides"; -import type { RenderSettings } from "./RenderSettings"; +import { createThemeSettings, type RenderSettings } from "./RenderSettings"; const DARK_AMBIENT = 0.35; @@ -73,6 +73,9 @@ export function applyGraphicsOverrides( settings.name.outlineB = channel; } if (overrides.accessibility?.colorblind === true) { + // Swap the active theme slice for the colorblind palette (replaced + // wholesale — palette arrays differ in length between themes). + settings.theme = createThemeSettings("colorblind"); // 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. diff --git a/src/client/render/gl/RenderSettings.ts b/src/client/render/gl/RenderSettings.ts index 6112dff56..716029e51 100644 --- a/src/client/render/gl/RenderSettings.ts +++ b/src/client/render/gl/RenderSettings.ts @@ -1,6 +1,46 @@ +import colorblindTheme from "./colorblind-theme.json"; +import defaultTheme from "./default-theme.json"; import defaults from "./render-settings.json"; +/** + * Theme data — player/team palettes and color-derivation knobs. Loaded from a + * theme JSON file (default-theme.json or colorblind-theme.json) and combined + * with render-settings.json at runtime so all graphics configuration flows + * through one pipeline. Colors are hex strings; palettes are consumed by the + * theme module (src/client/theme/), which generates team variations and + * allocates player colors at runtime. + */ +export interface ThemeSettings { + /** + * Base color per colored team (keys match ColoredTeams). Per-player + * variations are generated at runtime; Bot stays a single flat color. + */ + teamColors: Record; + humanColors: string[]; + nationColors: string[]; + botColors: string[]; + /** Used when the primary palettes are exhausted. */ + fallbackColors: string[]; + /** Border = territory color darkened by this absolute amount. */ + borderDarken: number; + /** + * Border HSL lightness multiplier, applied before borderDarken. 1 = no-op. + * Scaling keeps every border the same proportion darker than its fill + * (used by the colorblind theme so dark fills don't collapse to black). + */ + borderLightnessScale: number; + defendedBorderDarkenLight: number; + defendedBorderDarkenDark: number; + /** Minimum LAB delta between structure fill and border colors. */ + structureContrastTarget: number; + /** Border color of the local player's territory. */ + focusedBorderColor: string; + /** Tint applied to unit sprites during spawn highlight. */ + spawnHighlightColor: string; +} + export interface RenderSettings { + theme: ThemeSettings; passEnabled: { terrain: boolean; territory: boolean; @@ -311,9 +351,30 @@ export interface RenderSettings { lightConfigs: Record; } -/** Create a fresh settings object with defaults from render-settings.json. */ +export type ThemeName = "default" | "colorblind"; + +// Typed so tsc validates each theme JSON against the ThemeSettings shape. +const THEMES: Record = { + default: defaultTheme, + colorblind: colorblindTheme, +}; + +/** Create fresh theme settings with defaults from the named theme JSON. */ +export function createThemeSettings( + name: ThemeName = "default", +): ThemeSettings { + return JSON.parse(JSON.stringify(THEMES[name])) as ThemeSettings; +} + +/** + * Create a fresh settings object: render-settings.json combined with the + * active theme JSON. + */ export function createRenderSettings(): RenderSettings { - return JSON.parse(JSON.stringify(defaults)) as RenderSettings; + return { + ...(JSON.parse(JSON.stringify(defaults)) as Omit), + theme: createThemeSettings(), + }; } /** Dump current settings to a downloadable JSON file. */ diff --git a/src/client/render/gl/Renderer.ts b/src/client/render/gl/Renderer.ts index ea46f971f..8cdfb0e83 100644 --- a/src/client/render/gl/Renderer.ts +++ b/src/client/render/gl/Renderer.ts @@ -673,6 +673,8 @@ export class GPURenderer { ); // SAM radius pass stores its own copy this.samRadiusPass.setPaletteData(this.paletteData); + // Name pass caches per-player colors and bakes them into slot rows + this.namePass.refreshPlayerColors(this.paletteData); } /** Register late-arriving players (updates palette + NamePass lookup maps). */ diff --git a/src/client/render/gl/SettingsUtils.ts b/src/client/render/gl/SettingsUtils.ts index fa48e0086..6e2574832 100644 --- a/src/client/render/gl/SettingsUtils.ts +++ b/src/client/render/gl/SettingsUtils.ts @@ -4,10 +4,18 @@ type Obj = Record; -/** Recursively assign source values onto target, preserving target's structure. */ +/** + * Recursively assign source values onto target, preserving target's structure. + * Arrays are replaced wholesale (theme palettes differ in length between + * themes, so per-index merging would leave stale entries behind). + */ export function deepAssign(target: Obj, source: Obj): void { for (const key of Object.keys(source)) { - if ( + if (Array.isArray(source[key])) { + if (key in target) { + target[key] = structuredClone(source[key]); + } + } else if ( typeof source[key] === "object" && source[key] !== null && typeof target[key] === "object" && diff --git a/src/client/render/gl/colorblind-theme.json b/src/client/render/gl/colorblind-theme.json new file mode 100644 index 000000000..0d60fbbfd --- /dev/null +++ b/src/client/render/gl/colorblind-theme.json @@ -0,0 +1,430 @@ +{ + "teamColors": { + "Red": "#d55e00", + "Blue": "#0072b2", + "Teal": "#009e73", + "Purple": "#cc79a7", + "Yellow": "#f0e442", + "Orange": "#e69f00", + "Green": "#56b4e9", + "Bot": "#d1cdc7", + "Humans": "#0072b2", + "Nations": "#d55e00" + }, + "humanColors": [ + "#b60056", + "#007700", + "#0076fb", + "#db5f11", + "#00b8ae", + "#ff75f9", + "#b6c706", + "#00e8ff", + "#c7003b", + "#008b3a", + "#7e72ff", + "#cf8400", + "#00c8ee", + "#ff75dd", + "#90e448", + "#0069e0", + "#cd331d", + "#009e79", + "#cb6af6", + "#b9a600", + "#00d4ff", + "#ff83bd", + "#007100", + "#0069ef", + "#c76000", + "#00afb8", + "#ff64e0", + "#9ac407", + "#00dcff", + "#ba0025", + "#008445", + "#8f62ef" + ], + "nationColors": [ + "#b60056", + "#007700", + "#0076fb", + "#db5f11", + "#00b8ae", + "#ff75f9", + "#b6c706", + "#00e8ff", + "#c7003b", + "#008b3a", + "#7e72ff", + "#cf8400", + "#00c8ee", + "#ff75dd", + "#90e448", + "#0069e0", + "#cd331d", + "#009e79", + "#cb6af6", + "#b9a600", + "#00d4ff", + "#ff83bd", + "#007100", + "#0069ef", + "#c76000", + "#00afb8", + "#ff64e0", + "#9ac407", + "#00dcff", + "#ba0025", + "#008445", + "#8f62ef" + ], + "botColors": [ + "#b60056", + "#007700", + "#0076fb", + "#db5f11", + "#00b8ae", + "#ff75f9", + "#b6c706", + "#00e8ff", + "#c7003b", + "#008b3a", + "#7e72ff", + "#cf8400", + "#00c8ee", + "#ff75dd", + "#90e448", + "#0069e0", + "#cd331d", + "#009e79", + "#cb6af6", + "#b9a600", + "#00d4ff", + "#ff83bd", + "#007100", + "#0069ef", + "#c76000", + "#00afb8", + "#ff64e0", + "#9ac407", + "#00dcff", + "#ba0025", + "#008445", + "#8f62ef" + ], + "fallbackColors": [ + "#230000", + "#2d0000", + "#370000", + "#410000", + "#4b0000", + "#550000", + "#5f0000", + "#690000", + "#730000", + "#7d0000", + "#870000", + "#910000", + "#9b0000", + "#a50000", + "#af0000", + "#b90000", + "#c30005", + "#cd000a", + "#d7000f", + "#e10014", + "#eb0019", + "#f5001e", + "#ff0023", + "#ff0a2d", + "#ff1437", + "#ff1e41", + "#ff284b", + "#ff3255", + "#ff3c5f", + "#ff4669", + "#ff5073", + "#ff5a7d", + "#ff6487", + "#ff6e91", + "#ff789b", + "#ff82a5", + "#ff8caf", + "#ff96b9", + "#ffa0c3", + "#ffaacd", + "#ffb4d7", + "#ffbee1", + "#ffc8eb", + "#002d00", + "#003700", + "#004100", + "#004b00", + "#005500", + "#005f00", + "#006900", + "#007300", + "#007d00", + "#008700", + "#009100", + "#009b00", + "#00a500", + "#00af00", + "#00b900", + "#00c305", + "#00cd0a", + "#00d70f", + "#00e114", + "#00eb19", + "#00f51e", + "#00ff23", + "#0aff2d", + "#14ff37", + "#1eff41", + "#28ff4b", + "#32ff55", + "#3cff5f", + "#46ff69", + "#50ff73", + "#5aff7d", + "#64ff87", + "#6eff91", + "#78ff9b", + "#82ffa5", + "#8cffaf", + "#96ffb9", + "#a0ffc3", + "#aaffcd", + "#b4ffd7", + "#beffe1", + "#c8ffeb", + "#000023", + "#00002d", + "#000037", + "#000041", + "#00004b", + "#000055", + "#00005f", + "#000069", + "#000073", + "#00007d", + "#000087", + "#000091", + "#00009b", + "#0000a5", + "#0000af", + "#0000b9", + "#0500c3", + "#0a00cd", + "#0f00d7", + "#1400e1", + "#1900eb", + "#1e00f5", + "#2300ff", + "#2d0aff", + "#3714ff", + "#411eff", + "#4b28ff", + "#5532ff", + "#5f3cff", + "#6946ff", + "#7350ff", + "#7d5aff", + "#8764ff", + "#916eff", + "#9b78ff", + "#a582ff", + "#af8cff", + "#b996ff", + "#c3a0ff", + "#cdaaff", + "#d7b4ff", + "#e1beff", + "#ebc8ff", + "#230023", + "#2d002d", + "#370037", + "#410041", + "#4b004b", + "#550055", + "#5f005f", + "#690069", + "#730073", + "#7d007d", + "#870087", + "#910091", + "#9b009b", + "#a500a5", + "#af00af", + "#b900b9", + "#c305c3", + "#cd0acd", + "#d70fd7", + "#e114e1", + "#eb19eb", + "#f51ef5", + "#ff23ff", + "#ff2dff", + "#ff37ff", + "#ff41ff", + "#ff4bff", + "#ff55ff", + "#ff5fff", + "#ff69ff", + "#ff73ff", + "#ff7dff", + "#ff87ff", + "#ff91ff", + "#ff9bff", + "#ffa5ff", + "#ffafff", + "#ffb9ff", + "#ffc3ff", + "#ffcdff", + "#ffd7ff", + "#002323", + "#002d2d", + "#003737", + "#004141", + "#004b4b", + "#005555", + "#005f5f", + "#006969", + "#007373", + "#007d7d", + "#008787", + "#009191", + "#009b9b", + "#00a5a5", + "#00afaf", + "#00b9b9", + "#05c3c3", + "#0acdcd", + "#0fd7d7", + "#14e1e1", + "#19ebeb", + "#1ef5f5", + "#23ffff", + "#2dffff", + "#37ffff", + "#41ffff", + "#4bffff", + "#55ffff", + "#5fffff", + "#69ffff", + "#73ffff", + "#7dffff", + "#87ffff", + "#91ffff", + "#9bffff", + "#a5ffff", + "#afffff", + "#b9ffff", + "#c3ffff", + "#cdffff", + "#d7ffff", + "#232300", + "#2d2d00", + "#373700", + "#414100", + "#4b4b00", + "#555500", + "#5f5f00", + "#696900", + "#737300", + "#7d7d00", + "#878700", + "#919100", + "#9b9b00", + "#a5a500", + "#afaf00", + "#b9b900", + "#c3c305", + "#cdcd0a", + "#d7d70f", + "#e1e114", + "#ebeb19", + "#f5f51e", + "#ffff23", + "#ffff2d", + "#ffff37", + "#ffff41", + "#ffff4b", + "#ffff55", + "#ffff5f", + "#ffff69", + "#ffff73", + "#ffff7d", + "#ffff87", + "#ffff91", + "#ffff9b", + "#ffffa5", + "#ffffaf", + "#ffffb9", + "#ffffc3", + "#ffffcd", + "#ffffd7", + "#d7ffc8", + "#e1ffaf", + "#f0faa0", + "#f5f5af", + "#96c8ff", + "#a0d7ff", + "#aae1ff", + "#b4ebfa", + "#bef5f0", + "#d2fff5", + "#dcffff", + "#e6faff", + "#f0f0ff", + "#fae6ff", + "#aabeff", + "#b4b4ff", + "#c8aaff", + "#be8cc3", + "#c391c8", + "#c896cd", + "#cd9bd2", + "#d2a0d7", + "#d7a5dc", + "#dcaae1", + "#e1afe6", + "#e6b4eb", + "#ebb9f0", + "#f0bef5", + "#f5c3fa", + "#fac8ff", + "#ffcdff", + "#ffd2ff", + "#ffd2fa", + "#ffcdf5", + "#ffd7f5", + "#dca0ff", + "#eb96ff", + "#f5a0f0", + "#ffaae1", + "#ffb9d7", + "#ffc3eb", + "#ffc8dc", + "#ffd2e6", + "#ffdceb", + "#ffdcfa", + "#ffe1ff", + "#ffe6f5", + "#ffebeb", + "#ffd7c3", + "#ffe1b4", + "#ffe6be", + "#ffebc8", + "#fff5d2", + "#fff0dc" + ], + "borderDarken": 0, + "borderLightnessScale": 0.6, + "defendedBorderDarkenLight": 0.2, + "defendedBorderDarkenDark": 0.4, + "structureContrastTarget": 0.5, + "focusedBorderColor": "#e6e6e6", + "spawnHighlightColor": "#ffd54f" +} diff --git a/src/client/render/gl/default-theme.json b/src/client/render/gl/default-theme.json new file mode 100644 index 000000000..fd0b97ec9 --- /dev/null +++ b/src/client/render/gl/default-theme.json @@ -0,0 +1,495 @@ +{ + "teamColors": { + "Red": "#eb3333", + "Blue": "#2962ff", + "Teal": "#2bd4bd", + "Purple": "#9234ea", + "Yellow": "#e7b008", + "Orange": "#f97415", + "Green": "#41be52", + "Bot": "#d1cdc7", + "Humans": "#2962ff", + "Nations": "#eb3333" + }, + "humanColors": [ + "#a3e635", + "#84cc16", + "#10b981", + "#34d399", + "#2dd4bf", + "#4ade80", + "#6ee7b7", + "#86efac", + "#97ffbb", + "#baffc9", + "#e6fad2", + "#22c55e", + "#43be54", + "#52b788", + "#30b2b4", + "#e6fffa", + "#dcf0fa", + "#e9d5ff", + "#ccccff", + "#dcdcff", + "#cae1ff", + "#93c5fd", + "#7dd3fc", + "#63cafd", + "#38bdf8", + "#60a5fa", + "#3b82f6", + "#4f46e5", + "#7c3aed", + "#9333ea", + "#b388ff", + "#a78bfa", + "#d946ef", + "#a855f7", + "#be5cfb", + "#c084fc", + "#f0abfc", + "#f472b6", + "#ec4899", + "#dc2626", + "#ef4444", + "#eb4b4b", + "#f56565", + "#f87171", + "#fb7185", + "#fda4af", + "#fca5a5", + "#ffcce5", + "#fad7e1", + "#fbebf5", + "#f0f0c8", + "#fafad2", + "#fff0c8", + "#ffdfba", + "#fcd34d", + "#fbbf24", + "#eab308", + "#ca8a04", + "#f59e0b", + "#fb923c", + "#f97316", + "#ea580c", + "#854d0e" + ], + "nationColors": [ + "#d2d264", + "#b4d278", + "#aabe64", + "#50c878", + "#82c882", + "#8cb48c", + "#a0bea0", + "#a0b48c", + "#64a050", + "#648c6e", + "#64b4a0", + "#82b4aa", + "#aabeb4", + "#648296", + "#78a0c8", + "#8c96b4", + "#64d2d2", + "#8cb4dc", + "#82aabe", + "#64b4e6", + "#5082be", + "#7878be", + "#966ebe", + "#a078a0", + "#aa8cbe", + "#b482b4", + "#be8c96", + "#b464e6", + "#b4a0b4", + "#aa96aa", + "#968296", + "#e6b4b4", + "#d2a0c8", + "#e682b4", + "#d264a0", + "#be6482", + "#dc7878", + "#c8826e", + "#e68c8c", + "#e66464", + "#e69664", + "#d28c50", + "#e6b450", + "#c8a06e", + "#be9682", + "#beb4a0", + "#b4aa8c", + "#c8c88c", + "#beaa64" + ], + "botColors": [ + "#96a08c", + "#a0a096", + "#aaaa8c", + "#aaaa78", + "#96a078", + "#96aa82", + "#96aa96", + "#82aa82", + "#8ca08c", + "#789664", + "#788c78", + "#64aa82", + "#78a096", + "#82a096", + "#78aaaa", + "#78a0be", + "#8296aa", + "#8296a0", + "#8c96a0", + "#8ca0aa", + "#96a0a0", + "#6478a0", + "#78828c", + "#8282a0", + "#8c828c", + "#8c78a0", + "#968296", + "#968ca0", + "#a082a0", + "#aa96aa", + "#a078be", + "#a07882", + "#aa788c", + "#aa8278", + "#aa8282", + "#b48c8c", + "#be82a0", + "#be7878", + "#be8c78", + "#bea064", + "#aa8c64", + "#a08c82", + "#aa9682", + "#a09678", + "#a0968c", + "#a08c96", + "#a096a0", + "#968c96", + "#b4a0a0" + ], + "fallbackColors": [ + "#230000", + "#2d0000", + "#370000", + "#410000", + "#4b0000", + "#550000", + "#5f0000", + "#690000", + "#730000", + "#7d0000", + "#870000", + "#910000", + "#9b0000", + "#a50000", + "#af0000", + "#b90000", + "#c30005", + "#cd000a", + "#d7000f", + "#e10014", + "#eb0019", + "#f5001e", + "#ff0023", + "#ff0a2d", + "#ff1437", + "#ff1e41", + "#ff284b", + "#ff3255", + "#ff3c5f", + "#ff4669", + "#ff5073", + "#ff5a7d", + "#ff6487", + "#ff6e91", + "#ff789b", + "#ff82a5", + "#ff8caf", + "#ff96b9", + "#ffa0c3", + "#ffaacd", + "#ffb4d7", + "#ffbee1", + "#ffc8eb", + "#002d00", + "#003700", + "#004100", + "#004b00", + "#005500", + "#005f00", + "#006900", + "#007300", + "#007d00", + "#008700", + "#009100", + "#009b00", + "#00a500", + "#00af00", + "#00b900", + "#00c305", + "#00cd0a", + "#00d70f", + "#00e114", + "#00eb19", + "#00f51e", + "#00ff23", + "#0aff2d", + "#14ff37", + "#1eff41", + "#28ff4b", + "#32ff55", + "#3cff5f", + "#46ff69", + "#50ff73", + "#5aff7d", + "#64ff87", + "#6eff91", + "#78ff9b", + "#82ffa5", + "#8cffaf", + "#96ffb9", + "#a0ffc3", + "#aaffcd", + "#b4ffd7", + "#beffe1", + "#c8ffeb", + "#000023", + "#00002d", + "#000037", + "#000041", + "#00004b", + "#000055", + "#00005f", + "#000069", + "#000073", + "#00007d", + "#000087", + "#000091", + "#00009b", + "#0000a5", + "#0000af", + "#0000b9", + "#0500c3", + "#0a00cd", + "#0f00d7", + "#1400e1", + "#1900eb", + "#1e00f5", + "#2300ff", + "#2d0aff", + "#3714ff", + "#411eff", + "#4b28ff", + "#5532ff", + "#5f3cff", + "#6946ff", + "#7350ff", + "#7d5aff", + "#8764ff", + "#916eff", + "#9b78ff", + "#a582ff", + "#af8cff", + "#b996ff", + "#c3a0ff", + "#cdaaff", + "#d7b4ff", + "#e1beff", + "#ebc8ff", + "#230023", + "#2d002d", + "#370037", + "#410041", + "#4b004b", + "#550055", + "#5f005f", + "#690069", + "#730073", + "#7d007d", + "#870087", + "#910091", + "#9b009b", + "#a500a5", + "#af00af", + "#b900b9", + "#c305c3", + "#cd0acd", + "#d70fd7", + "#e114e1", + "#eb19eb", + "#f51ef5", + "#ff23ff", + "#ff2dff", + "#ff37ff", + "#ff41ff", + "#ff4bff", + "#ff55ff", + "#ff5fff", + "#ff69ff", + "#ff73ff", + "#ff7dff", + "#ff87ff", + "#ff91ff", + "#ff9bff", + "#ffa5ff", + "#ffafff", + "#ffb9ff", + "#ffc3ff", + "#ffcdff", + "#ffd7ff", + "#002323", + "#002d2d", + "#003737", + "#004141", + "#004b4b", + "#005555", + "#005f5f", + "#006969", + "#007373", + "#007d7d", + "#008787", + "#009191", + "#009b9b", + "#00a5a5", + "#00afaf", + "#00b9b9", + "#05c3c3", + "#0acdcd", + "#0fd7d7", + "#14e1e1", + "#19ebeb", + "#1ef5f5", + "#23ffff", + "#2dffff", + "#37ffff", + "#41ffff", + "#4bffff", + "#55ffff", + "#5fffff", + "#69ffff", + "#73ffff", + "#7dffff", + "#87ffff", + "#91ffff", + "#9bffff", + "#a5ffff", + "#afffff", + "#b9ffff", + "#c3ffff", + "#cdffff", + "#d7ffff", + "#232300", + "#2d2d00", + "#373700", + "#414100", + "#4b4b00", + "#555500", + "#5f5f00", + "#696900", + "#737300", + "#7d7d00", + "#878700", + "#919100", + "#9b9b00", + "#a5a500", + "#afaf00", + "#b9b900", + "#c3c305", + "#cdcd0a", + "#d7d70f", + "#e1e114", + "#ebeb19", + "#f5f51e", + "#ffff23", + "#ffff2d", + "#ffff37", + "#ffff41", + "#ffff4b", + "#ffff55", + "#ffff5f", + "#ffff69", + "#ffff73", + "#ffff7d", + "#ffff87", + "#ffff91", + "#ffff9b", + "#ffffa5", + "#ffffaf", + "#ffffb9", + "#ffffc3", + "#ffffcd", + "#ffffd7", + "#d7ffc8", + "#e1ffaf", + "#f0faa0", + "#f5f5af", + "#96c8ff", + "#a0d7ff", + "#aae1ff", + "#b4ebfa", + "#bef5f0", + "#d2fff5", + "#dcffff", + "#e6faff", + "#f0f0ff", + "#fae6ff", + "#aabeff", + "#b4b4ff", + "#c8aaff", + "#be8cc3", + "#c391c8", + "#c896cd", + "#cd9bd2", + "#d2a0d7", + "#d7a5dc", + "#dcaae1", + "#e1afe6", + "#e6b4eb", + "#ebb9f0", + "#f0bef5", + "#f5c3fa", + "#fac8ff", + "#ffcdff", + "#ffd2ff", + "#ffd2fa", + "#ffcdf5", + "#ffd7f5", + "#dca0ff", + "#eb96ff", + "#f5a0f0", + "#ffaae1", + "#ffb9d7", + "#ffc3eb", + "#ffc8dc", + "#ffd2e6", + "#ffdceb", + "#ffdcfa", + "#ffe1ff", + "#ffe6f5", + "#ffebeb", + "#ffd7c3", + "#ffe1b4", + "#ffe6be", + "#ffebc8", + "#fff5d2", + "#fff0dc" + ], + "borderDarken": 0.125, + "borderLightnessScale": 1, + "defendedBorderDarkenLight": 0.2, + "defendedBorderDarkenDark": 0.4, + "structureContrastTarget": 0.5, + "focusedBorderColor": "#e6e6e6", + "spawnHighlightColor": "#ffd54f" +} diff --git a/src/client/render/gl/passes/name-pass/index.ts b/src/client/render/gl/passes/name-pass/index.ts index e8be608ed..41ac742b3 100644 --- a/src/client/render/gl/passes/name-pass/index.ts +++ b/src/client/render/gl/passes/name-pass/index.ts @@ -222,6 +222,25 @@ export class NamePass { } } + /** + * Re-read every known player's territory color from the palette and rewrite + * the live slot rows. Called after a mid-game palette refresh (e.g. toggling + * colorblind mode) so name fills/outlines pick up the re-themed colors. + */ + refreshPlayerColors(paletteData: Float32Array): void { + for (const [id, p] of this.playerByID) { + const off = p.smallID * 4; + this.playerColors.set(id, [ + paletteData[off], + paletteData[off + 1], + paletteData[off + 2], + ]); + } + for (const slot of this.slots.values()) { + this.writePlayerDataRow(slot); + } + } + /** * Request the texture layer for a slot's flag (called once at slot creation). * If the image is already loaded the layer index is set immediately; otherwise diff --git a/src/client/render/gl/utils/ColorUtils.ts b/src/client/render/gl/utils/ColorUtils.ts index da575efc9..e91f87bec 100644 --- a/src/client/render/gl/utils/ColorUtils.ts +++ b/src/client/render/gl/utils/ColorUtils.ts @@ -2,7 +2,7 @@ * GPU-ready color utilities. * * Terrain RGBA: Uint8Array(w × h × 4) — one RGBA pixel per tile, computed - * from PastelTheme rules applied to the raw terrain byte layout. + * from the terrain color rules applied to the raw terrain byte layout. * * Player palette is NOT built here — consumers provide a pre-built * Float32Array(PALETTE_SIZE × 2 × 4) to the GPURenderer constructor. @@ -19,7 +19,7 @@ export function getPaletteSize(): number { /** * Compute a static RGBA8 texture from raw terrain bytes. - * Replicates PastelTheme.terrainColor() on the CPU. + * The single source of truth for terrain colors. * * Terrain byte layout per tile: * bit 7: isLand diff --git a/src/client/theme/BaseTheme.ts b/src/client/theme/BaseTheme.ts deleted file mode 100644 index 80cac305a..000000000 --- a/src/client/theme/BaseTheme.ts +++ /dev/null @@ -1,228 +0,0 @@ -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 --- - /** 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/ColorblindTheme.ts b/src/client/theme/ColorblindTheme.ts deleted file mode 100644 index ad3eb690b..000000000 --- a/src/client/theme/ColorblindTheme.ts +++ /dev/null @@ -1,113 +0,0 @@ -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: { - 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 deleted file mode 100644 index 38fe797e4..000000000 --- a/src/client/theme/Colors.ts +++ /dev/null @@ -1,564 +0,0 @@ -import { colord, Colord, extend } from "colord"; -import labPlugin from "colord/plugins/lab"; -import lchPlugin from "colord/plugins/lch"; - -extend([lchPlugin]); -extend([labPlugin]); - -export const red = colord("rgb(235,51,51)"); -export const blue = colord("rgb(41,98,255)"); -export const teal = colord("rgb(43,212,189)"); -export const purple = colord("rgb(146,52,234)"); -export const yellow = colord("rgb(231,176,8)"); -export const orange = colord("rgb(249,116,21)"); -export const green = colord("rgb(65,190,82)"); -export const botColor = colord("rgb(209,205,199)"); - -export const redTeamColors: Colord[] = generateTeamColors(red); -export const blueTeamColors: Colord[] = generateTeamColors(blue); -export const tealTeamColors: Colord[] = generateTeamColors(teal); -export const purpleTeamColors: Colord[] = generateTeamColors(purple); -export const yellowTeamColors: Colord[] = generateTeamColors(yellow); -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; - const goldenAngle = 137.508; - - return Array.from({ length: colorCount }, (_, index) => { - if (index === 0) return baseColor; - - // Spread hues evenly across ±6° band using golden angle within that range - const hueShift = ((index * goldenAngle) % 12) - 6; - const h = (lch.h + hueShift + 360) % 360; - - // Chroma oscillates ±10% around the base to add variety without washing out - const chromaFactor = 1.0 + 0.1 * Math.sin(index * 0.7); - const c = Math.max(10, Math.min(130, lch.c * chromaFactor)); - - // Lightness alternates above/below the base using golden angle spacing - // Tighter range (±18) keeps teammates recognizable as the same team - const lightOffset = 18 * Math.sin(index * goldenAngle * (Math.PI / 180)); - const l = Math.max(25, Math.min(80, lch.l + lightOffset)); - - return colord({ l, c, h }); - }); -} - -export const nationColors: Colord[] = [ - colord("rgb(210,210,100)"), // Lime Yellow - colord("rgb(180,210,120)"), // Light Green - colord("rgb(170,190,100)"), // Yellow Green - colord("rgb(80,200,120)"), // Emerald Green - colord("rgb(130,200,130)"), // Light Sea Green - colord("rgb(140,180,140)"), // Dark Sea Green - colord("rgb(160,190,160)"), // Pale Green - colord("rgb(160,180,140)"), // Dark Olive Green - colord("rgb(100,160,80)"), // Olive Green - colord("rgb(100,140,110)"), // Sea Green - colord("rgb(100,180,160)"), // Aquamarine - colord("rgb(130,180,170)"), // Medium Aquamarine - colord("rgb(170,190,180)"), // Pale Blue Green - colord("rgb(100,130,150)"), // Steel Blue - colord("rgb(120,160,200)"), // Cornflower Blue - colord("rgb(140,150,180)"), // Light Slate Gray - colord("rgb(100,210,210)"), // Turquoise - colord("rgb(140,180,220)"), // Light Blue - colord("rgb(130,170,190)"), // Cadet Blue - colord("rgb(100,180,230)"), // Sky Blue - colord("rgb(80,130,190)"), // Navy Blue - colord("rgb(120,120,190)"), // Periwinkle - colord("rgb(150,110,190)"), // Lavender - colord("rgb(160,120,160)"), // Purple Gray - colord("rgb(170,140,190)"), // Medium Purple - colord("rgb(180,130,180)"), // Plum - colord("rgb(190,140,150)"), // Puce - colord("rgb(180,100,230)"), // Purple - colord("rgb(180,160,180)"), // Mauve - colord("rgb(170,150,170)"), // Dusty Rose - colord("rgb(150,130,150)"), // Thistle - colord("rgb(230,180,180)"), // Light Pink - colord("rgb(210,160,200)"), // Orchid - colord("rgb(230,130,180)"), // Pink - colord("rgb(210,100,160)"), // Hot Pink - colord("rgb(190,100,130)"), // Maroon - colord("rgb(220,120,120)"), // Coral - colord("rgb(200,130,110)"), // Dark Salmon - colord("rgb(230,140,140)"), // Salmon - colord("rgb(230,100,100)"), // Bright Red - colord("rgb(230,150,100)"), // Peach - colord("rgb(210,140,80)"), // Light Orange - colord("rgb(230,180,80)"), // Golden Yellow - colord("rgb(200,160,110)"), // Tan - colord("rgb(190,150,130)"), // Rosy Brown - colord("rgb(190,180,160)"), // Tan Gray - colord("rgb(180,170,140)"), // Dark Khaki - colord("rgb(200,200,140)"), // Khaki - colord("rgb(190,170,100)"), // Sand -]; - -// Bright pastel theme with 64 colors -export const humanColors: Colord[] = [ - colord("rgb(163,230,53)"), // Yellow Green - colord("rgb(132,204,22)"), // Lime - colord("rgb(16,185,129)"), // Sea Green - colord("rgb(52,211,153)"), // Spearmint - colord("rgb(45,212,191)"), // Turquoise - colord("rgb(74,222,128)"), // Mint - colord("rgb(110,231,183)"), // Seafoam - colord("rgb(134,239,172)"), // Light Green - colord("rgb(151,255,187)"), // Fresh Mint - colord("rgb(186,255,201)"), // Pale Emerald - colord("rgb(230,250,210)"), // Pastel Lime - colord("rgb(34,197,94)"), // Emerald - colord("rgb(67,190,84)"), // Fresh Green - colord("rgb(82,183,136)"), // Jade - colord("rgb(48,178,180)"), // Teal - colord("rgb(230,255,250)"), // Mint Whisper - colord("rgb(220,240,250)"), // Ice Blue - colord("rgb(233,213,255)"), // Light Lilac - colord("rgb(204,204,255)"), // Soft Lavender Blue - colord("rgb(220,220,255)"), // Meringue Blue - colord("rgb(202,225,255)"), // Baby Blue - colord("rgb(147,197,253)"), // Powder Blue - colord("rgb(125,211,252)"), // Crystal Blue - colord("rgb(99,202,253)"), // Azure - colord("rgb(56,189,248)"), // Light Blue - colord("rgb(96,165,250)"), // Sky Blue - colord("rgb(59,130,246)"), // Royal Blue - colord("rgb(79,70,229)"), // Indigo - colord("rgb(124,58,237)"), // Royal Purple - colord("rgb(147,51,234)"), // Bright Purple - colord("rgb(179,136,255)"), // Light Purple - colord("rgb(167,139,250)"), // Periwinkle - colord("rgb(217,70,239)"), // Fuchsia - colord("rgb(168,85,247)"), // Vibrant Purple - colord("rgb(190,92,251)"), // Amethyst - colord("rgb(192,132,252)"), // Lavender - colord("rgb(240,171,252)"), // Orchid - colord("rgb(244,114,182)"), // Rose - colord("rgb(236,72,153)"), // Deep Pink - colord("rgb(220,38,38)"), // Ruby - colord("rgb(239,68,68)"), // Crimson - colord("rgb(235,75,75)"), // Bright Red - colord("rgb(245,101,101)"), // Coral - colord("rgb(248,113,113)"), // Warm Red - colord("rgb(251,113,133)"), // Watermelon - colord("rgb(253,164,175)"), // Salmon Pink - colord("rgb(252,165,165)"), // Peach - colord("rgb(255,204,229)"), // Blush Pink - colord("rgb(250,215,225)"), // Cotton Candy - colord("rgb(251,235,245)"), // Rose Powder - colord("rgb(240,240,200)"), // Light Khaki - colord("rgb(250,250,210)"), // Pastel Lemon - colord("rgb(255,240,200)"), // Vanilla - colord("rgb(255,223,186)"), // Apricot Cream - colord("rgb(252,211,77)"), // Golden - colord("rgb(251,191,36)"), // Marigold - colord("rgb(234,179,8)"), // Sunflower - colord("rgb(202,138,4)"), // Rich Gold - colord("rgb(245,158,11)"), // Amber - colord("rgb(251,146,60)"), // Light Orange - colord("rgb(249,115,22)"), // Tangerine - colord("rgb(234,88,12)"), // Burnt Orange - colord("rgb(133,77,14)"), // Chocolate -]; - -export const botColors: Colord[] = [ - colord("rgb(150,160,140)"), // Muted Dark Olive Green - colord("rgb(160,160,150)"), // Muted Tan Gray - colord("rgb(170,170,140)"), // Muted Khaki - colord("rgb(170,170,120)"), // Muted Lime Yellow - colord("rgb(150,160,120)"), // Muted Yellow Green - colord("rgb(150,170,130)"), // Muted Light Green - colord("rgb(150,170,150)"), // Muted Pale Green - colord("rgb(130,170,130)"), // Muted Light Sea Green - colord("rgb(140,160,140)"), // Muted Dark Sea Green - colord("rgb(120,150,100)"), // Muted Olive Green - colord("rgb(120,140,120)"), // Muted Sea Green - colord("rgb(100,170,130)"), // Muted Emerald Green - colord("rgb(120,160,150)"), // Muted Aquamarine - colord("rgb(130,160,150)"), // Muted Medium Aquamarine - colord("rgb(120,170,170)"), // Muted Turquoise - colord("rgb(120,160,190)"), // Muted Sky Blue - colord("rgb(130,150,170)"), // Muted Cornflower Blue - colord("rgb(130,150,160)"), // Muted Cadet Blue - colord("rgb(140,150,160)"), // Muted Light Slate Gray - colord("rgb(140,160,170)"), // Muted Light Blue - colord("rgb(150,160,160)"), // Muted Pale Blue Green - colord("rgb(100,120,160)"), // Muted Navy Blue - colord("rgb(120,130,140)"), // Muted Steel Blue - colord("rgb(130,130,160)"), // Muted Periwinkle - colord("rgb(140,130,140)"), // Muted Thistle - colord("rgb(140,120,160)"), // Muted Lavender - colord("rgb(150,130,150)"), // Muted Purple Gray - colord("rgb(150,140,160)"), // Muted Medium Purple - colord("rgb(160,130,160)"), // Muted Plum - colord("rgb(170,150,170)"), // Muted Orchid - colord("rgb(160,120,190)"), // Muted Purple - colord("rgb(160,120,130)"), // Muted Maroon - colord("rgb(170,120,140)"), // Muted Hot Pink - colord("rgb(170,130,120)"), // Muted Dark Salmon - colord("rgb(170,130,130)"), // Muted Coral - colord("rgb(180,140,140)"), // Muted Salmon - colord("rgb(190,130,160)"), // Muted Pink - colord("rgb(190,120,120)"), // Muted Red - colord("rgb(190,140,120)"), // Muted Peach - colord("rgb(190,160,100)"), // Muted Golden Yellow - colord("rgb(170,140,100)"), // Muted Light Orange - colord("rgb(160,140,130)"), // Muted Rosy Brown - colord("rgb(170,150,130)"), // Muted Tan - colord("rgb(160,150,120)"), // Muted Sand - colord("rgb(160,150,140)"), // Muted Dark Khaki - colord("rgb(160,140,150)"), // Muted Puce - colord("rgb(160,150,160)"), // Muted Mauve - colord("rgb(150,140,150)"), // Muted Dusty Rose - colord("rgb(180,160,160)"), // Muted Light Pink -]; - -// Fallback colors for when the color palette is exhausted. -export const fallbackColors: Colord[] = [ - colord("rgb(35,0,0)"), - colord("rgb(45,0,0)"), - colord("rgb(55,0,0)"), - colord("rgb(65,0,0)"), - colord("rgb(75,0,0)"), - colord("rgb(85,0,0)"), - colord("rgb(95,0,0)"), - colord("rgb(105,0,0)"), - colord("rgb(115,0,0)"), - colord("rgb(125,0,0)"), - colord("rgb(135,0,0)"), - colord("rgb(145,0,0)"), - colord("rgb(155,0,0)"), - colord("rgb(165,0,0)"), - colord("rgb(175,0,0)"), - colord("rgb(185,0,0)"), - colord("rgb(195,0,5)"), - colord("rgb(205,0,10)"), - colord("rgb(215,0,15)"), - colord("rgb(225,0,20)"), - colord("rgb(235,0,25)"), - colord("rgb(245,0,30)"), - colord("rgb(255,0,35)"), - colord("rgb(255,10,45)"), - colord("rgb(255,20,55)"), - colord("rgb(255,30,65)"), - colord("rgb(255,40,75)"), - colord("rgb(255,50,85)"), - colord("rgb(255,60,95)"), - colord("rgb(255,70,105)"), - colord("rgb(255,80,115)"), - colord("rgb(255,90,125)"), - colord("rgb(255,100,135)"), - colord("rgb(255,110,145)"), - colord("rgb(255,120,155)"), - colord("rgb(255,130,165)"), - colord("rgb(255,140,175)"), - colord("rgb(255,150,185)"), - colord("rgb(255,160,195)"), - colord("rgb(255,170,205)"), - colord("rgb(255,180,215)"), - colord("rgb(255,190,225)"), - colord("rgb(255,200,235)"), - colord("rgb(0,45,0)"), - colord("rgb(0,55,0)"), - colord("rgb(0,65,0)"), - colord("rgb(0,75,0)"), - colord("rgb(0,85,0)"), - colord("rgb(0,95,0)"), - colord("rgb(0,105,0)"), - colord("rgb(0,115,0)"), - colord("rgb(0,125,0)"), - colord("rgb(0,135,0)"), - colord("rgb(0,145,0)"), - colord("rgb(0,155,0)"), - colord("rgb(0,165,0)"), - colord("rgb(0,175,0)"), - colord("rgb(0,185,0)"), - colord("rgb(0,195,5)"), - colord("rgb(0,205,10)"), - colord("rgb(0,215,15)"), - colord("rgb(0,225,20)"), - colord("rgb(0,235,25)"), - colord("rgb(0,245,30)"), - colord("rgb(0,255,35)"), - colord("rgb(10,255,45)"), - colord("rgb(20,255,55)"), - colord("rgb(30,255,65)"), - colord("rgb(40,255,75)"), - colord("rgb(50,255,85)"), - colord("rgb(60,255,95)"), - colord("rgb(70,255,105)"), - colord("rgb(80,255,115)"), - colord("rgb(90,255,125)"), - colord("rgb(100,255,135)"), - colord("rgb(110,255,145)"), - colord("rgb(120,255,155)"), - colord("rgb(130,255,165)"), - colord("rgb(140,255,175)"), - colord("rgb(150,255,185)"), - colord("rgb(160,255,195)"), - colord("rgb(170,255,205)"), - colord("rgb(180,255,215)"), - colord("rgb(190,255,225)"), - colord("rgb(200,255,235)"), - colord("rgb(0,0,35)"), - colord("rgb(0,0,45)"), - colord("rgb(0,0,55)"), - colord("rgb(0,0,65)"), - colord("rgb(0,0,75)"), - colord("rgb(0,0,85)"), - colord("rgb(0,0,95)"), - colord("rgb(0,0,105)"), - colord("rgb(0,0,115)"), - colord("rgb(0,0,125)"), - colord("rgb(0,0,135)"), - colord("rgb(0,0,145)"), - colord("rgb(0,0,155)"), - colord("rgb(0,0,165)"), - colord("rgb(0,0,175)"), - colord("rgb(0,0,185)"), - colord("rgb(5,0,195)"), - colord("rgb(10,0,205)"), - colord("rgb(15,0,215)"), - colord("rgb(20,0,225)"), - colord("rgb(25,0,235)"), - colord("rgb(30,0,245)"), - colord("rgb(35,0,255)"), - colord("rgb(45,10,255)"), - colord("rgb(55,20,255)"), - colord("rgb(65,30,255)"), - colord("rgb(75,40,255)"), - colord("rgb(85,50,255)"), - colord("rgb(95,60,255)"), - colord("rgb(105,70,255)"), - colord("rgb(115,80,255)"), - colord("rgb(125,90,255)"), - colord("rgb(135,100,255)"), - colord("rgb(145,110,255)"), - colord("rgb(155,120,255)"), - colord("rgb(165,130,255)"), - colord("rgb(175,140,255)"), - colord("rgb(185,150,255)"), - colord("rgb(195,160,255)"), - colord("rgb(205,170,255)"), - colord("rgb(215,180,255)"), - colord("rgb(225,190,255)"), - colord("rgb(235,200,255)"), - colord("rgb(35,0,35)"), - colord("rgb(45,0,45)"), - colord("rgb(55,0,55)"), - colord("rgb(65,0,65)"), - colord("rgb(75,0,75)"), - colord("rgb(85,0,85)"), - colord("rgb(95,0,95)"), - colord("rgb(105,0,105)"), - colord("rgb(115,0,115)"), - colord("rgb(125,0,125)"), - colord("rgb(135,0,135)"), - colord("rgb(145,0,145)"), - colord("rgb(155,0,155)"), - colord("rgb(165,0,165)"), - colord("rgb(175,0,175)"), - colord("rgb(185,0,185)"), - colord("rgb(195,5,195)"), - colord("rgb(205,10,205)"), - colord("rgb(215,15,215)"), - colord("rgb(225,20,225)"), - colord("rgb(235,25,235)"), - colord("rgb(245,30,245)"), - colord("rgb(255,35,255)"), - colord("rgb(255,45,255)"), - colord("rgb(255,55,255)"), - colord("rgb(255,65,255)"), - colord("rgb(255,75,255)"), - colord("rgb(255,85,255)"), - colord("rgb(255,95,255)"), - colord("rgb(255,105,255)"), - colord("rgb(255,115,255)"), - colord("rgb(255,125,255)"), - colord("rgb(255,135,255)"), - colord("rgb(255,145,255)"), - colord("rgb(255,155,255)"), - colord("rgb(255,165,255)"), - colord("rgb(255,175,255)"), - colord("rgb(255,185,255)"), - colord("rgb(255,195,255)"), - colord("rgb(255,205,255)"), - colord("rgb(255,215,255)"), - colord("rgb(0,35,35)"), - colord("rgb(0,45,45)"), - colord("rgb(0,55,55)"), - colord("rgb(0,65,65)"), - colord("rgb(0,75,75)"), - colord("rgb(0,85,85)"), - colord("rgb(0,95,95)"), - colord("rgb(0,105,105)"), - colord("rgb(0,115,115)"), - colord("rgb(0,125,125)"), - colord("rgb(0,135,135)"), - colord("rgb(0,145,145)"), - colord("rgb(0,155,155)"), - colord("rgb(0,165,165)"), - colord("rgb(0,175,175)"), - colord("rgb(0,185,185)"), - colord("rgb(5,195,195)"), - colord("rgb(10,205,205)"), - colord("rgb(15,215,215)"), - colord("rgb(20,225,225)"), - colord("rgb(25,235,235)"), - colord("rgb(30,245,245)"), - colord("rgb(35,255,255)"), - colord("rgb(45,255,255)"), - colord("rgb(55,255,255)"), - colord("rgb(65,255,255)"), - colord("rgb(75,255,255)"), - colord("rgb(85,255,255)"), - colord("rgb(95,255,255)"), - colord("rgb(105,255,255)"), - colord("rgb(115,255,255)"), - colord("rgb(125,255,255)"), - colord("rgb(135,255,255)"), - colord("rgb(145,255,255)"), - colord("rgb(155,255,255)"), - colord("rgb(165,255,255)"), - colord("rgb(175,255,255)"), - colord("rgb(185,255,255)"), - colord("rgb(195,255,255)"), - colord("rgb(205,255,255)"), - colord("rgb(215,255,255)"), - colord("rgb(35,35,0)"), - colord("rgb(45,45,0)"), - colord("rgb(55,55,0)"), - colord("rgb(65,65,0)"), - colord("rgb(75,75,0)"), - colord("rgb(85,85,0)"), - colord("rgb(95,95,0)"), - colord("rgb(105,105,0)"), - colord("rgb(115,115,0)"), - colord("rgb(125,125,0)"), - colord("rgb(135,135,0)"), - colord("rgb(145,145,0)"), - colord("rgb(155,155,0)"), - colord("rgb(165,165,0)"), - colord("rgb(175,175,0)"), - colord("rgb(185,185,0)"), - colord("rgb(195,195,5)"), - colord("rgb(205,205,10)"), - colord("rgb(215,215,15)"), - colord("rgb(225,225,20)"), - colord("rgb(235,235,25)"), - colord("rgb(245,245,30)"), - colord("rgb(255,255,35)"), - colord("rgb(255,255,45)"), - colord("rgb(255,255,55)"), - colord("rgb(255,255,65)"), - colord("rgb(255,255,75)"), - colord("rgb(255,255,85)"), - colord("rgb(255,255,95)"), - colord("rgb(255,255,105)"), - colord("rgb(255,255,115)"), - colord("rgb(255,255,125)"), - colord("rgb(255,255,135)"), - colord("rgb(255,255,145)"), - colord("rgb(255,255,155)"), - colord("rgb(255,255,165)"), - colord("rgb(255,255,175)"), - colord("rgb(255,255,185)"), - colord("rgb(255,255,195)"), - colord("rgb(255,255,205)"), - colord("rgb(255,255,215)"), - colord("rgb(215,255,200)"), // Fresh Mint - colord("rgb(225,255,175)"), // Soft Lime - colord("rgb(240,250,160)"), // Citrus Wash - colord("rgb(245,245,175)"), // Lemon Mist - colord("rgb(150,200,255)"), // Cornflower Mist - colord("rgb(160,215,255)"), // Powder Blue - colord("rgb(170,225,255)"), // Baby Sky - colord("rgb(180,235,250)"), // Aqua Pastel - colord("rgb(190,245,240)"), // Ice Mint - colord("rgb(210,255,245)"), // Sea Mist - colord("rgb(220,255,255)"), // Pale Aqua - colord("rgb(230,250,255)"), // Sky Haze - colord("rgb(240,240,255)"), // Frosted Lilac - colord("rgb(250,230,255)"), // Misty Mauve - colord("rgb(170,190,255)"), // Periwinkle Ice - colord("rgb(180,180,255)"), // Pale Indigo - colord("rgb(200,170,255)"), // Lilac Bloom - colord("rgb(190,140,195)"), // Fuchsia Tint - colord("rgb(195,145,200)"), // Dusky Rose - colord("rgb(200,150,205)"), // Plum Frost - colord("rgb(205,155,210)"), // Berry Foam - colord("rgb(210,160,215)"), // Grape Cloud - colord("rgb(215,165,220)"), // Light Bloom - colord("rgb(220,170,225)"), // Cherry Blossom - colord("rgb(225,175,230)"), // Faded Rose - colord("rgb(230,180,235)"), // Dreamy Mauve - colord("rgb(235,185,240)"), // Powder Violet - colord("rgb(240,190,245)"), // Pastel Violet - colord("rgb(245,195,250)"), // Soft Magenta - colord("rgb(250,200,255)"), // Lilac Cream - colord("rgb(255,205,255)"), // Violet Bloom - colord("rgb(255,210,255)"), // Orchid Mist - colord("rgb(255,210,250)"), // Lavender Mist - colord("rgb(255,205,245)"), // Pastel Orchid - colord("rgb(255,215,245)"), // Rose Whisper - colord("rgb(220,160,255)"), // Violet Mist - colord("rgb(235,150,255)"), // Orchid Glow - colord("rgb(245,160,240)"), // Rose Lilac - colord("rgb(255,170,225)"), // Bubblegum Pink - colord("rgb(255,185,215)"), // Blush Mist - colord("rgb(255,195,235)"), // Faded Fuchsia - colord("rgb(255,200,220)"), // Cotton Rose - colord("rgb(255,210,230)"), // Pastel Blush - colord("rgb(255,220,235)"), // Pink Mist - colord("rgb(255,220,250)"), // Powder Petal - colord("rgb(255,225,255)"), // Petal Mist - colord("rgb(255,230,245)"), // Light Rose - colord("rgb(255,235,235)"), // Blushed Petal - colord("rgb(255,215,195)"), // Apricot Glow - colord("rgb(255,225,180)"), // Butter Peach - colord("rgb(255,230,190)"), - colord("rgb(255,235,200)"), // Cream Peach - colord("rgb(255,245,210)"), // Soft Banana - colord("rgb(255,240,220)"), // Pastel Sand -]; diff --git a/src/client/theme/PastelTheme.ts b/src/client/theme/PastelTheme.ts deleted file mode 100644 index 2bd094c1e..000000000 --- a/src/client/theme/PastelTheme.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { Colord, colord } from "colord"; -import { ColoredTeams, Team, TerrainType } from "../../core/game/Game"; -import { GameMap, TileRef } from "../../core/game/GameMap"; -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)]; - } - } - - /** - * 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; - } - const type = gm.terrainType(tile); - switch (type) { - case TerrainType.Ocean: { - 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: - return colord({ - r: 190, - g: 220 - 2 * mag, - b: 138, - }); - case TerrainType.Highland: - return colord({ - r: 200 + 2 * mag, - g: 183 + 2 * mag, - b: 138 + 2 * mag, - }); - case TerrainType.Mountain: - return colord({ - r: 230 + mag / 2, - 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/src/client/theme/PastelThemeDark.ts b/src/client/theme/PastelThemeDark.ts deleted file mode 100644 index d4c5bed3c..000000000 --- a/src/client/theme/PastelThemeDark.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { Colord, colord } from "colord"; -import { TerrainType } from "../../core/game/Game"; -import { GameMap, TileRef } from "../../core/game/GameMap"; -import { PastelTheme } from "./PastelTheme"; - -export class PastelThemeDark extends PastelTheme { - private darkShore = colord("rgb(134,133,88)"); - - private darkWater = colord("rgb(14,11,30)"); - private darkShorelineWater = colord("rgb(50,50,50)"); - - // | Terrain Type | Magnitude | Base Color Logic | Visual Description | - // | :---------------- | :-------- | :---------------------------------------------- | :-------------------- | - // | **Shore (Land)** | N/A | Fixed: `rgb(134, 133, 88)` | Dark olive. | - // | **Plains** | 0 - 9 | `rgb(140, 170, 88)` - `rgb(140, 152, 88)` | Muted green. | - // | **Highland** | 10 - 19 | `rgb(170, 153, 108)` - `rgb(188, 171, 126)` | Dark earth tone. | - // | **Mountain** | 20 - 30 | `rgb(190, 190, 190)` - `rgb(195, 195, 195)` | Dark gray. | - // | **Water (Shore)** | 0 | Fixed: `rgb(50, 50, 50)` | Dark gray/black. | - // | **Water (Deep)** | 1 - 10+ | `rgb(22, 19, 38)` - `rgb(14, 11, 30)` | Very dark blue/black. | - - terrainColor(gm: GameMap, tile: TileRef): Colord { - const mag = gm.magnitude(tile); - if (gm.isShore(tile)) { - return this.darkShore; - } - switch (gm.terrainType(tile)) { - case TerrainType.Ocean: { - const w = this.darkWater.rgba; - if (gm.isShoreline(tile) && gm.isWater(tile)) { - return this.darkShorelineWater; - } - if (gm.magnitude(tile) < 10) { - return colord({ - r: Math.max(w.r + 9 - mag, 0), - g: Math.max(w.g + 9 - mag, 0), - b: Math.max(w.b + 9 - mag, 0), - }); - } - return this.darkWater; - } - case TerrainType.Plains: - return colord({ - r: 140, - g: 170 - 2 * mag, - b: 88, - }); - case TerrainType.Highland: - return colord({ - r: 150 + 2 * mag, - g: 133 + 2 * mag, - b: 88 + 2 * mag, - }); - case TerrainType.Mountain: - return colord({ - r: 180 + mag / 2, - g: 180 + mag / 2, - b: 180 + mag / 2, - }); - } - } -} diff --git a/src/client/theme/Theme.ts b/src/client/theme/Theme.ts deleted file mode 100644 index 6ae1fb3d8..000000000 --- a/src/client/theme/Theme.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { Colord } from "colord"; -import { Team } from "../../core/game/Game"; -import { GameMap, TileRef } from "../../core/game/GameMap"; -import { PlayerView } from "../../core/game/GameView"; - -export interface Theme { - teamColor(team: Team): Colord; - // Don't call directly, use PlayerView - territoryColor(playerInfo: PlayerView): Colord; - // Don't call directly, use PlayerView - structureColors(territoryColor: Colord): { light: Colord; dark: Colord }; - // Don't call directly, use PlayerView - borderColor(territoryColor: Colord): Colord; - // Don't call directly, use PlayerView - defendedBorderColors(territoryColor: Colord): { light: Colord; dark: Colord }; - focusedBorderColor(): Colord; - terrainColor(gm: GameMap, tile: TileRef): Colord; - backgroundColor(): Colord; - falloutColor(): Colord; - font(): string; - textColor(playerInfo: PlayerView): string; - spawnHighlightColor(): Colord; - spawnHighlightSelfColor(): Colord; - spawnHighlightTeamColor(): Colord; - spawnHighlightEnemyColor(): Colord; -} diff --git a/src/client/theme/ThemeProvider.ts b/src/client/theme/ThemeProvider.ts index 55aa5f593..52b2abb09 100644 --- a/src/client/theme/ThemeProvider.ts +++ b/src/client/theme/ThemeProvider.ts @@ -1,8 +1,256 @@ +import { Colord, colord, LabaColor } from "colord"; +import { PlayerType, Team } from "../../core/game/Game"; +import { PlayerView } from "../../core/game/GameView"; import { UserSettings } from "../../core/game/UserSettings"; -import { ColorblindTheme } from "./ColorblindTheme"; -import { PastelTheme } from "./PastelTheme"; -import { PastelThemeDark } from "./PastelThemeDark"; -import { Theme } from "./Theme"; +import { simpleHash } from "../../core/Util"; +import { + createThemeSettings, + ThemeSettings, +} from "../render/gl/RenderSettings"; +import { ColorAllocator } from "./ColorAllocator"; + +/** + * The color surface consumed by PlayerView and HUD components. Built from + * `ThemeSettings` (a theme JSON like default-theme.json, combined with + * render-settings.json into the graphics-configuration pipeline). + */ +export interface Theme { + teamColor(team: Team): Colord; + // Don't call directly, use PlayerView + territoryColor(playerInfo: PlayerView): Colord; + // Don't call directly, use PlayerView + structureColors(territoryColor: Colord): { light: Colord; dark: Colord }; + // Don't call directly, use PlayerView + borderColor(territoryColor: Colord): Colord; + // Don't call directly, use PlayerView + defendedBorderColors(territoryColor: Colord): { light: Colord; dark: Colord }; + focusedBorderColor(): Colord; + spawnHighlightColor(): Colord; +} + +/** + * Generate per-player color variations around a team's base color, spreading + * hue/chroma/lightness so teammates stay recognizable as one team. + */ +function generateTeamColors(baseColor: Colord): Colord[] { + const lch = baseColor.toLch(); + const colorCount = 64; + const goldenAngle = 137.508; + + return Array.from({ length: colorCount }, (_, index) => { + if (index === 0) return baseColor; + + // Spread hues evenly across ±6° band using golden angle within that range + const hueShift = ((index * goldenAngle) % 12) - 6; + const h = (lch.h + hueShift + 360) % 360; + + // Chroma oscillates ±10% around the base to add variety without washing out + const chromaFactor = 1.0 + 0.1 * Math.sin(index * 0.7); + const c = Math.max(10, Math.min(130, lch.c * chromaFactor)); + + // Lightness alternates above/below the base using golden angle spacing + // Tighter range (±18) keeps teammates recognizable as the same team + const lightOffset = 18 * Math.sin(index * goldenAngle * (Math.PI / 180)); + const l = Math.max(25, Math.min(80, lch.l + lightOffset)); + + return colord({ l, c, h }); + }); +} + +/** + * Build the per-team variation palettes from theme settings. The Bot team + * stays a single flat color; every other team gets generated variations. + */ +export function buildTeamPalettes( + settings: ThemeSettings, +): Map { + const palettes = new Map(); + for (const [team, hex] of Object.entries(settings.teamColors)) { + const base = colord(hex); + palettes.set(team, team === "Bot" ? [base] : generateTeamColors(base)); + } + return palettes; +} + +/** + * A theme built entirely from `ThemeSettings` data. Owns the per-pool color + * allocators and the territory/team color dispatch, plus the color math every + * theme shares — a new theme is just a new theme JSON. + */ +export class SettingsTheme implements Theme { + private humanColorAllocator: ColorAllocator; + private botColorAllocator: ColorAllocator; + private nationColorAllocator: ColorAllocator; + private teamPalettes: Map; + private teamPlayerColors = new Map(); + + private _focusedBorderColor: Colord; + private _spawnHighlightColor: Colord; + + constructor(private settings: ThemeSettings) { + const humanColors = settings.humanColors.map(colord); + const botColors = settings.botColors.map(colord); + const nationColors = settings.nationColors.map(colord); + const fallbackColors = settings.fallbackColors.map(colord); + + this.humanColorAllocator = new ColorAllocator(humanColors, fallbackColors); + this.botColorAllocator = new ColorAllocator(botColors, botColors); + this.nationColorAllocator = new ColorAllocator(nationColors, nationColors); + this.teamPalettes = buildTeamPalettes(settings); + + this._focusedBorderColor = colord(settings.focusedBorderColor); + this._spawnHighlightColor = colord(settings.spawnHighlightColor); + } + + /** Per-team color variations; index 0 is the team's base color. */ + private teamColorVariations(team: Team): Colord[] { + return ( + this.teamPalettes.get(team) ?? [ + this.humanColorAllocator.assignColor(team), + ] + ); + } + + /** 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), + }); + } + + /** 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; + } + + /** + * 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()); + } + + /** + * 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 = this.settings.structureContrastTarget; + 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. + * `borderLightnessScale` darkens *relative* to the fill's own lightness + * (so dark fills don't collapse to black); `borderDarken` is an absolute + * darken on top. Each theme JSON uses one or the other. + */ + borderColor(territoryColor: Colord): Colord { + let out = territoryColor; + const scale = this.settings.borderLightnessScale; + if (scale !== 1) { + const hsl = out.toHsl(); + out = colord({ ...hsl, l: hsl.l * scale }); + } + const darken = this.settings.borderDarken; + if (darken !== 0) { + out = out.darken(darken); + } + return out; + } + + /** Light/dark border pair used to render a defended (fortified) border. */ + defendedBorderColors(territoryColor: Colord): { + light: Colord; + dark: Colord; + } { + return { + light: territoryColor.darken(this.settings.defendedBorderDarkenLight), + dark: territoryColor.darken(this.settings.defendedBorderDarkenDark), + }; + } + + /** Border color used to highlight the currently focused player. */ + focusedBorderColor(): Colord { + return this._focusedBorderColor; + } + + /** Highlight color for a spawnable tile during the spawn phase. */ + spawnHighlightColor(): Colord { + return this._spawnHighlightColor; + } +} /** * Client-side source of truth for the active theme. Themes were moved out of @@ -11,16 +259,15 @@ import { Theme } from "./Theme"; */ class ThemeProvider { private readonly userSettings = new UserSettings(); - private light = new PastelTheme(); - private dark = new PastelThemeDark(); - private colorblind = new ColorblindTheme(); + private defaultTheme = new SettingsTheme(createThemeSettings("default")); + private colorblind = new SettingsTheme(createThemeSettings("colorblind")); - /** The active theme, from colorblind mode, then the dark-mode preference. */ + /** The active theme, selected from the colorblind-mode preference. */ current(): Theme { if (this.userSettings.graphicsOverrides().accessibility?.colorblind) { return this.colorblind; } - return this.userSettings.darkMode() ? this.dark : this.light; + return this.defaultTheme; } /** @@ -29,9 +276,8 @@ class ThemeProvider { * colour-pool depletion across games in a single session. */ reset(): void { - this.light = new PastelTheme(); - this.dark = new PastelThemeDark(); - this.colorblind = new ColorblindTheme(); + this.defaultTheme = new SettingsTheme(createThemeSettings("default")); + this.colorblind = new SettingsTheme(createThemeSettings("colorblind")); } } diff --git a/tests/Colors.test.ts b/tests/Colors.test.ts index 627c85570..9e3ed8c12 100644 --- a/tests/Colors.test.ts +++ b/tests/Colors.test.ts @@ -1,20 +1,11 @@ import { colord, Colord } from "colord"; +import defaultTheme from "../src/client/render/gl/default-theme.json"; +import { createThemeSettings } from "../src/client/render/gl/RenderSettings"; import { ColorAllocator, selectDistinctColorIndex, } from "../src/client/theme/ColorAllocator"; -import { ColorblindTheme } from "../src/client/theme/ColorblindTheme"; -import { - blue, - botColor, - green, - orange, - purple, - red, - teal, - yellow, -} from "../src/client/theme/Colors"; -import { PastelTheme } from "../src/client/theme/PastelTheme"; +import { SettingsTheme } from "../src/client/theme/ThemeProvider"; import { ColoredTeams } from "../src/core/game/Game"; const mockColors: Colord[] = [ @@ -84,40 +75,43 @@ describe("ColorAllocator", () => { }); }); -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); +describe("default theme team colors", () => { + const teamBase = (team: keyof typeof defaultTheme.teamColors): Colord => + colord(defaultTheme.teamColors[team]); + + test("teamColor returns the base color from the theme JSON", () => { + const theme = new SettingsTheme(createThemeSettings("default")); + expect(theme.teamColor(ColoredTeams.Blue)).toEqual(teamBase("Blue")); + expect(theme.teamColor(ColoredTeams.Red)).toEqual(teamBase("Red")); + expect(theme.teamColor(ColoredTeams.Teal)).toEqual(teamBase("Teal")); + expect(theme.teamColor(ColoredTeams.Purple)).toEqual(teamBase("Purple")); + expect(theme.teamColor(ColoredTeams.Yellow)).toEqual(teamBase("Yellow")); + expect(theme.teamColor(ColoredTeams.Orange)).toEqual(teamBase("Orange")); + expect(theme.teamColor(ColoredTeams.Green)).toEqual(teamBase("Green")); + expect(theme.teamColor(ColoredTeams.Bot)).toEqual(teamBase("Bot")); + expect(theme.teamColor(ColoredTeams.Humans)).toEqual(teamBase("Humans")); + expect(theme.teamColor(ColoredTeams.Nations)).toEqual(teamBase("Nations")); }); test("teamColorForPlayer is stable for the same playerID", () => { - const theme = new PastelTheme(); + const theme = new SettingsTheme(createThemeSettings("default")); const a = theme.teamColorForPlayer(ColoredTeams.Blue, "player123"); const b = theme.teamColorForPlayer(ColoredTeams.Blue, "player123"); expect(a.isEqual(b)).toBe(true); }); test("teamColorForPlayer differs for different playerIDs", () => { - const theme = new PastelTheme(); + const theme = new SettingsTheme(createThemeSettings("default")); const a = theme.teamColorForPlayer(ColoredTeams.Blue, "player1"); const b = theme.teamColorForPlayer(ColoredTeams.Blue, "player2"); expect(a.isEqual(b)).toBe(false); }); }); -describe("ColorblindTheme", () => { - test("applies a palette distinct from PastelTheme", () => { - const pastel = new PastelTheme(); - const colorblind = new ColorblindTheme(); +describe("colorblind theme", () => { + test("applies a palette distinct from the default theme", () => { + const defaultTheme = new SettingsTheme(createThemeSettings("default")); + const colorblind = new SettingsTheme(createThemeSettings("colorblind")); // At least one team's base color should differ — the colorblind theme // swaps the team palettes for CVD-safe (Okabe-Ito) colors. @@ -131,10 +125,18 @@ describe("ColorblindTheme", () => { ColoredTeams.Green, ]; const anyDifferent = teams.some( - (team) => !pastel.teamColor(team).isEqual(colorblind.teamColor(team)), + (team) => + !defaultTheme.teamColor(team).isEqual(colorblind.teamColor(team)), ); expect(anyDifferent).toBe(true); }); + + test("scales border lightness relative to the fill", () => { + const colorblind = new SettingsTheme(createThemeSettings("colorblind")); + const fill = colord("#0072b2"); + const border = colorblind.borderColor(fill); + expect(border.toHsl().l).toBeCloseTo(fill.toHsl().l * 0.6, 0); + }); }); describe("selectDistinctColor", () => { diff --git a/tests/util/viewStubs.ts b/tests/util/viewStubs.ts index 6ad5503e5..e8a059d31 100644 --- a/tests/util/viewStubs.ts +++ b/tests/util/viewStubs.ts @@ -7,7 +7,7 @@ */ import { colord } from "colord"; -import { Theme } from "../../src/client/theme/Theme"; +import { Theme } from "../../src/client/theme/ThemeProvider"; import { GameView } from "../../src/client/view/GameView"; import { PlayerView } from "../../src/client/view/PlayerView"; import { Config } from "../../src/core/configuration/Config"; @@ -40,15 +40,7 @@ export function stubTheme(): Theme { borderColor: () => grey, defendedBorderColors: () => defended, focusedBorderColor: () => grey, - terrainColor: () => white, - backgroundColor: () => white, - falloutColor: () => white, - font: () => "Arial", - textColor: () => "#000000", spawnHighlightColor: () => white, - spawnHighlightSelfColor: () => white, - spawnHighlightTeamColor: () => white, - spawnHighlightEnemyColor: () => white, }; }