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
+34 -32
View File
@@ -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", () => {
+1 -9
View File
@@ -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,
};
}