Files
OpenFrontIO/src/client/render/gl/RenderSettings.ts
T
Evan 1db02acdc2 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>
2026-06-11 12:50:50 -07:00

391 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
borderCompute: boolean;
borderStamp: boolean;
trail: boolean;
territoryPatterns: boolean;
structure: boolean;
unit: boolean;
name: boolean;
falloutBloom: boolean;
railroad: boolean;
fx: boolean;
bar: boolean;
nameDebug: boolean;
};
falloutBloom: {
broilSpeedCold: number;
broilSpeedHot: number;
noiseFreq1: number;
noiseFreq2: number;
contrastLoCold: number;
contrastLoHot: number;
contrastHiCold: number;
contrastHiHot: number;
metaFreq: number;
intensityCold: number;
intensityHot: number;
metaInfluenceCold: number;
metaInfluenceHot: number;
opacityFadeEnd: number;
bloomR: number;
bloomG: number;
bloomB: number;
bloomCoverage: number;
heatDecayPerTick: number;
particleColorDarkR: number;
particleColorDarkG: number;
particleColorDarkB: number;
particleColorBrightR: number;
particleColorBrightG: number;
particleColorBrightB: number;
particleThresholdUnowned: number;
particleThresholdOwned: number;
particleFlickerSpeed: number;
particleStrength: number;
particleFreshScale: number;
};
lighting: {
ambient: number;
enabled: boolean;
falloffPower: number;
falloutLightR: number;
falloutLightG: number;
falloutLightB: number;
falloutLightIntensity: number;
falloutLightThreshold: number;
emberLightR: number;
emberLightG: number;
emberLightB: number;
emberLightIntensity: number;
blurZoomDivisor: number;
lightRadiusMultiplier: number;
};
mapOverlay: {
trailAlpha: number;
defenseCheckerDarken: number;
territoryDefenseDarken: number;
/** Saturation of the territory fill. 1 = full color, 0 = grayscale. */
territorySaturation: number;
/** Absolute opacity of the territory fill. 1 = fully opaque (terrain hidden), ~0.588 = default. */
territoryAlpha: number;
staleNukeBase: number;
staleNukeVariation: number;
staleNukeAlpha: number;
staleNukeR: number;
staleNukeG: number;
staleNukeB: number;
highlightBrighten: number;
highlightFillBrighten: number;
highlightThicken: number;
defensePostRange: number;
embargoTintRatio: number;
friendlyTintRatio: number;
embargoTintR: number;
embargoTintG: number;
embargoTintB: number;
friendlyTintR: number;
friendlyTintG: number;
friendlyTintB: number;
};
/** Alt-view affiliation colors (01 RGB). */
affiliation: {
selfR: number;
selfG: number;
selfB: number;
allyR: number;
allyG: number;
allyB: number;
neutralR: number;
neutralG: number;
neutralB: number;
enemyR: number;
enemyG: number;
enemyB: number;
};
railroad: {
railMinZoom: number;
railFadeRange: number;
railDetailZoom: number;
railAlpha: number;
/** Track width multiplier (1 = default width). */
railThickness: number;
};
structure: {
iconSize: number;
dotsZoomThreshold: number;
/** Icon size multiplier when zoomed out past dotsZoomThreshold. */
dotScale: number;
iconScaleFactorZoomedOut: number;
/**
* Zoom level at which structures begin growing with the canvas.
* Below this zoom, structures stay at a fixed screen size (capped).
* Above this zoom, they grow proportionally to zoom — i.e. world-anchored,
* so they cover a fixed area of the map.
*/
iconGrowZoom: number;
shapes: Record<string, { scale: number; iconFill: number }>;
highlightOutlineWidth: number;
highlightDimAlpha: number;
/** HSV value multiplier applied to the icon fill (interior). 1.0 = no darkening. */
fillDarken: number;
/** HSV value multiplier applied to the icon border (outer ring). 1.0 = no darkening. */
borderDarken: number;
/** Multiplier on final icon alpha. 1.0 = opaque. */
iconAlpha: number;
/** RGB color of the inner icon glyph */
iconR: number;
iconG: number;
iconB: number;
};
structureLevel: {
scale: number;
outlineWidth: number;
};
bar: {
healthBarW: number;
healthBarH: number;
healthBarOffsetY: number;
progressBarW: number;
progressBarH: number;
progressBarOffsetY: number;
borderWidth: number;
threshold1: number;
threshold2: number;
threshold3: number;
colorRedR: number;
colorRedG: number;
colorRedB: number;
colorOrangeR: number;
colorOrangeG: number;
colorOrangeB: number;
colorYellowR: number;
colorYellowG: number;
colorYellowB: number;
colorGreenR: number;
colorGreenG: number;
colorGreenB: number;
};
unit: {
unitSize: number;
flickerSpeed: number;
angryR: number;
angryG: number;
angryB: number;
// Steady soft glow rendered underneath the hydrogen bomb
hBombGlowScale: number; // quad enlargement factor (1 = no glow room)
hBombGlowR: number;
hBombGlowG: number;
hBombGlowB: number;
hBombGlowStrength: number; // peak opacity of the glow
hBombGlowInner: number; // radial falloff start (0..1, quad-space)
};
name: {
lerpSpeed: number;
cullThreshold: number;
nameScaleFactor: number;
nameScaleCap: number;
troopSizeMultiplier: number;
outlineWidth: number;
outlineR: number;
outlineG: number;
outlineB: number;
outlineUsePlayerColor: boolean;
fillUsePlayerColor: boolean;
/** Name fill grayscale shade by player type (0 = black). Human is always 0. */
nameShadeNation: number;
nameShadeBot: number;
emojiRowOffset: number;
statusRowOffset: number;
/** Alpha multiplier applied to a name while the cursor is over it. */
hoverFadeAlpha: number;
};
fx: {
shockwaveRingWidth: number;
nukeShockwaveDurationMs: number;
nukeShockwaveRadiusFactor: number;
samShockwaveDurationMs: number;
samShockwaveRadius: number;
debrisLifetimeMs: number;
debrisFadeIn: number; // 01 fraction of lifetime
debrisFadeOut: number; // 01 fraction of lifetime (start of fade)
conquestLifetimeMs: number;
conquestFadeIn: number;
conquestFadeOut: number;
};
nukeTrajectory: {
lineWidth: number; // px — main line stroke width
outlineWidth: number; // px — extra width for outline behind line
dashTargetable: number; // px — dash length in targetable zone
gapTargetable: number; // px — gap length in targetable zone
dashUntargetable: number; // px — dash length in untargetable zone
gapUntargetable: number; // px — gap length in untargetable zone
lineR: number; // normal line color
lineG: number;
lineB: number;
interceptR: number; // line color after SAM intercept
interceptG: number;
interceptB: number;
outlineR: number; // outline color (normal)
outlineG: number;
outlineB: number;
interceptOutlineR: number; // outline color (after intercept)
interceptOutlineG: number;
interceptOutlineB: number;
markerCircleRadius: number; // px — zone boundary circle size
markerXRadius: number; // px — SAM intercept X size
};
nukeTelegraph: {
strokeWidth: number; // world units — circle ring width
dashLen: number; // world units — outer ring dash length
gapLen: number; // world units — outer ring gap length
rotationSpeed: number; // outer ring rotation speed
baseAlpha: number; // base opacity (01)
pulseAmplitude: number; // alpha pulse ±
pulseSpeed: number; // pulse frequency (radians/sec)
fillAlphaOffset: number; // inner fill is baseAlpha minus this
colorR: number; // circle color
colorG: number;
colorB: number;
};
moveIndicator: {
startRadius: number; // screen px — initial distance from center
chevronSize: number; // screen px — wing span
lineWidth: number; // screen px — stroke width
duration: number; // ms — total animation lifetime
converge: number; // 01 — fraction of radius consumed during animation
};
samRadius: {
strokeWidth: number; // ring half-width in world units
dashLen: number; // dash length in world units
gapLen: number; // gap length in world units
rotationSpeed: number; // world units per second
alpha: number; // base opacity (01)
outlineWidth: number; // outline border width in world units
outlineSoftness: number; // smoothstep range (0 = hard, higher = softer)
};
bonusPopup: {
scale: number;
lifetimeMs: number;
riseSpeed: number;
yOffset: number;
outlineWidth: number;
colorR: number;
colorG: number;
colorB: number;
minScreenScale: number; // minimum world-scale when zoomed out (prevents vanishing)
cullZoom: number; // popups hidden below this zoom level
};
ghostCost: {
screenScale: number; // screen-relative em scale; divided by zoom each frame for fixed on-screen size
screenYOffset: number; // screen-relative downward offset from icon center; divided by zoom each frame for fixed on-screen gap
};
spawnOverlay: {
highlightRadius: number; // tile highlight radius (squared internally)
highlightAlpha: number; // tile highlight opacity (01)
selfMinRad: number; // self ring inner radius
selfMaxRad: number; // self ring outer radius
mateMinRad: number; // teammate ring inner radius
mateMaxRad: number; // teammate ring outer radius
animSpeed: number; // breathing animation speed
gradientInnerEdge: number; // static gradient inner ramp end (01)
gradientSolidEnd: number; // static gradient solid band end (01)
};
altView: {
gridFontSize: number;
recolorStructures: boolean;
};
tileDrip: {
/**
* Round-robin bucket count for staggering territory tile uploads across
* render frames. One bucket drains per frame at 60Hz. 12 ≈ 200ms max
* latency, which absorbs a 100ms tick delay without a visible freeze.
* Changing at runtime requires reload.
*/
bucketCount: number;
};
lightConfigs: Record<string, { radius: number; intensity: number }>;
}
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 Omit<RenderSettings, "theme">),
theme: createThemeSettings(),
};
}
/** Dump current settings to a downloadable JSON file. */
export function dumpSettings(settings: RenderSettings): void {
const json = JSON.stringify(settings, null, 2);
const blob = new Blob([json], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "render-settings.json";
a.click();
URL.revokeObjectURL(url);
}