mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 10:21:27 +00:00
1db02acdc2
**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>
159 lines
5.6 KiB
TypeScript
159 lines
5.6 KiB
TypeScript
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 { SettingsTheme } from "../src/client/theme/ThemeProvider";
|
|
import { ColoredTeams } from "../src/core/game/Game";
|
|
|
|
const mockColors: Colord[] = [
|
|
colord({ r: 255, g: 0, b: 0 }),
|
|
colord({ r: 0, g: 255, b: 0 }),
|
|
colord({ r: 0, g: 0, b: 255 }),
|
|
];
|
|
|
|
const fallbackMockColors: Colord[] = [
|
|
colord({ r: 0, g: 0, b: 0 }),
|
|
colord({ r: 255, g: 255, b: 255 }),
|
|
];
|
|
|
|
const fallbackColors = [...fallbackMockColors, ...mockColors];
|
|
|
|
describe("ColorAllocator", () => {
|
|
let allocator: ColorAllocator;
|
|
|
|
beforeEach(() => {
|
|
allocator = new ColorAllocator(mockColors, fallbackMockColors);
|
|
});
|
|
|
|
test("returns a unique color for each new ID", () => {
|
|
const c1 = allocator.assignColor("a");
|
|
const c2 = allocator.assignColor("b");
|
|
const c3 = allocator.assignColor("c");
|
|
|
|
expect(c1.isEqual(c2)).toBe(false);
|
|
expect(c1.isEqual(c3)).toBe(false);
|
|
expect(c2.isEqual(c3)).toBe(false);
|
|
});
|
|
|
|
test("returns the same color for the same ID", () => {
|
|
const c1 = allocator.assignColor("a");
|
|
const c2 = allocator.assignColor("a");
|
|
|
|
expect(c1.isEqual(c2)).toBe(true);
|
|
});
|
|
|
|
test("falls back when colors are exhausted", () => {
|
|
allocator.assignColor("1");
|
|
allocator.assignColor("2");
|
|
allocator.assignColor("3");
|
|
const fallback = allocator.assignColor("4");
|
|
const fallback2 = allocator.assignColor("5");
|
|
|
|
const match = fallbackColors.some((color) => color.isEqual(fallback));
|
|
expect(match).toBe(true);
|
|
|
|
const match2 = fallback.isEqual(fallback2);
|
|
expect(match2).toBe(false);
|
|
});
|
|
|
|
test("assignBotColor returns deterministic color from botColors", () => {
|
|
const allocator = new ColorAllocator(mockColors, mockColors);
|
|
|
|
const id1 = "bot123";
|
|
const id2 = "bot456";
|
|
|
|
const c1 = allocator.assignColor(id1);
|
|
const c2 = allocator.assignColor(id2);
|
|
const c1Again = allocator.assignColor(id1);
|
|
const c2Again = allocator.assignColor(id2);
|
|
|
|
expect(c1.isEqual(c1Again)).toBe(true);
|
|
expect(c2.isEqual(c2Again)).toBe(true);
|
|
});
|
|
});
|
|
|
|
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 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 SettingsTheme(createThemeSettings("default"));
|
|
const a = theme.teamColorForPlayer(ColoredTeams.Blue, "player1");
|
|
const b = theme.teamColorForPlayer(ColoredTeams.Blue, "player2");
|
|
expect(a.isEqual(b)).toBe(false);
|
|
});
|
|
});
|
|
|
|
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.
|
|
const teams = [
|
|
ColoredTeams.Blue,
|
|
ColoredTeams.Red,
|
|
ColoredTeams.Teal,
|
|
ColoredTeams.Purple,
|
|
ColoredTeams.Yellow,
|
|
ColoredTeams.Orange,
|
|
ColoredTeams.Green,
|
|
];
|
|
const anyDifferent = teams.some(
|
|
(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", () => {
|
|
test("returns the most distant color", () => {
|
|
const assignedColors = [colord({ r: 255, g: 0, b: 0 })]; // bright red
|
|
const availableColors = [
|
|
colord({ r: 254, g: 1, b: 1 }), // too close
|
|
colord({ r: 0, g: 255, b: 0 }), // distinct green
|
|
colord({ r: 0, g: 0, b: 255 }), // distinct blue
|
|
];
|
|
|
|
const result = selectDistinctColorIndex(availableColors, assignedColors);
|
|
const rgb = availableColors[result].toRgb();
|
|
expect([
|
|
{ r: 0, g: 255, b: 0, a: 1 },
|
|
{ r: 0, g: 0, b: 255, a: 1 },
|
|
]).toContainEqual(rgb);
|
|
});
|
|
});
|