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:
Evan
2026-06-11 12:50:50 -07:00
committed by GitHub
parent 3c0ff7a6f2
commit 1db02acdc2
20 changed files with 1329 additions and 1180 deletions
-228
View File
@@ -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 0100). */
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;
}
}
-113
View File
@@ -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;
}
}
}
}
-564
View File
@@ -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
];
-125
View File
@@ -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;
}
}
}
}
-61
View File
@@ -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,
});
}
}
}
-26
View File
@@ -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;
}
+258 -12
View File
@@ -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 0100). */
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"));
}
}