mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 13:20:43 +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>
285 lines
11 KiB
TypeScript
285 lines
11 KiB
TypeScript
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 { 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
|
||
* `src/core` (the simulation never reads colors); this singleton replaces the
|
||
* old `Config.theme()` accessor.
|
||
*/
|
||
class ThemeProvider {
|
||
private readonly userSettings = new UserSettings();
|
||
private defaultTheme = new SettingsTheme(createThemeSettings("default"));
|
||
private colorblind = new SettingsTheme(createThemeSettings("colorblind"));
|
||
|
||
/** The active theme, selected from the colorblind-mode preference. */
|
||
current(): Theme {
|
||
if (this.userSettings.graphicsOverrides().accessibility?.colorblind) {
|
||
return this.colorblind;
|
||
}
|
||
return this.defaultTheme;
|
||
}
|
||
|
||
/**
|
||
* Recreate the themes so their colour allocators start empty. Call once per
|
||
* game — matches the previous per-`Config` theme lifecycle and prevents
|
||
* colour-pool depletion across games in a single session.
|
||
*/
|
||
reset(): void {
|
||
this.defaultTheme = new SettingsTheme(createThemeSettings("default"));
|
||
this.colorblind = new SettingsTheme(createThemeSettings("colorblind"));
|
||
}
|
||
}
|
||
|
||
export const themeProvider = new ThemeProvider();
|