mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 08:20:50 +00:00
Move theme data into the render-settings JSON pipeline (#4223)
**Add approved & assigned issue number here:** N/A — maintainer refactor. ## Description: Replaces the theme class hierarchy (`BaseTheme`/`PastelTheme`/`ColorblindTheme`) with theme JSON files — `default-theme.json` and `colorblind-theme.json` — combined with `render-settings.json` at runtime into a single graphics-configuration pipeline (`settings.theme`). One `SettingsTheme` class keeps the algorithms (color allocation, team-variation generation, LAB-contrast structure colors) and reads all data from `ThemeSettings`; adding a theme is now just adding a JSON file. Colorblind mode (#4150) is fully preserved: - Same palettes — the 32-color CVD-safe pool and Okabe-Ito team colors are baked into `colorblind-theme.json` - The relative border rule (`l × 0.6`) is expressed as a `borderLightnessScale` knob alongside the default theme's absolute `borderDarken` - The mid-game re-theme wiring (`refreshPlayerColors`/`refreshPalette`) and the affiliation/friend-foe tint overrides are unchanged; `applyGraphicsOverrides` now also swaps the `settings.theme` slice - `deepAssign` replaces arrays wholesale so differing palette lengths survive theme switches Verified against the previous implementation with an equivalence test (since removed): default-theme colors are byte-identical including allocation order; colorblind team/derived colors are byte-identical, and FFA assignment may permute within the same palette (hex baking rounds upstream's fractional-RGB colord objects, which can flip the allocator's greedy delta-E ordering — rendered colors round identically either way). Also removes dead theme surface (`terrainColor`, `backgroundColor`, `falloutColor`, `font`, `textColor`, spawn-highlight variants, `PastelThemeDark`) — GL terrain colors and dark mode were already handled in the renderer. Note this means the colorblind terrain bands from #4150 were dead code (nothing calls `terrainColor`; GL terrain comes from `ColorUtils.encodeTerrainTile`); wiring CVD-safe terrain into the terrain texture would be a follow-up. ## Please complete the following: - [x] I have added screenshots for all UI updates — N/A, no UI changes (verified color-identical) - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file — N/A, no user-visible text - [x] I have added relevant tests to the test directory — `tests/Colors.test.ts` updated for the new pipeline (team colors from theme JSON, colorblind palette/border tests) ## Please put your Discord username so you can be contacted if a bug or regression is found: evanpelle 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<string, string>;
|
||||
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<string, { radius: number; intensity: number }>;
|
||||
}
|
||||
|
||||
/** 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<ThemeName, ThemeSettings> = {
|
||||
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<RenderSettings, "theme">),
|
||||
theme: createThemeSettings(),
|
||||
};
|
||||
}
|
||||
|
||||
/** Dump current settings to a downloadable JSON file. */
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -4,10 +4,18 @@
|
||||
|
||||
type Obj = Record<string, any>;
|
||||
|
||||
/** 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" &&
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<string, Colord>();
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
];
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<Team, Colord[]> {
|
||||
const palettes = new Map<Team, Colord[]>();
|
||||
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<Team, Colord[]>;
|
||||
private teamPlayerColors = new Map<string, Colord>();
|
||||
|
||||
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"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+34
-32
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user