feat(client): graphics presets — simplify settings behind an Advanced section (#4663)

## Summary

The graphics settings modal had grown too complex for average players
(~25 sliders/toggles/color pickers in one flat list). This reworks
graphics configuration around **presets**:

- **Shared `<graphics-preset-selector>` component**
(`src/client/components/`): a self-contained dropdown of built-in +
saved presets that applies a preset wholesale, shows a disabled "Custom"
entry while the current overrides match no preset, and offers delete for
saved presets. It reads/writes `UserSettings` directly and re-renders on
the settings-changed events, so both hosts stay in sync automatically.
- **In-game graphics modal**: preset dropdown up top, everything else
collapsed under an **Advanced settings** expander. Advanced also holds
the custom-preset tools: save-as-preset, copy settings JSON to
clipboard, and paste-JSON import (Zod-validated, inline error).
- **Main-menu settings modal**: a new "Graphics Preset" row using the
same component — replacing the colorblind toggle.
- **Built-in presets**: Default, Night, and Colorblind — defined as data
in `graphics-presets.json` (schema-parsed at load).

### Design notes

- **Theme palettes are an enum, not a boolean.**
`accessibility.colorblind` is replaced by `palette: "default" |
"colorblind"` — `PALETTE_NAMES` in `GraphicsOverrides.ts` is the single
source of truth (`ThemeName` derives from it; `ThemeProvider` builds one
theme per palette), so adding a future palette is one enum entry + one
theme JSON. Legacy stored booleans are translated to the enum at read
time.
- **The colorblind look is data.** The previously hardcoded Okabe-Ito
friend-foe colors are now generic override fields
(`affiliation.self/ally/enemyColor`,
`mapOverlay.friendly/embargoTintColor` + ratios) carried by the
Colorblind preset JSON; the palette selects only the theme. Selecting
Colorblind anywhere (menu or in-game) applies the full look. Side
benefit: any preset can customize friend/foe border colors.
- **Existing players are protected**: a one-time migration snapshots
pre-existing custom overrides into a "My settings" preset on first init,
so switching presets can never lose a hand-tuned config. Flag-only
colorblind users are upgraded to the full Colorblind preset so they keep
the blue/orange borders. Writing the presets key (even as `{}`) stamps
the migration done.
- Applying a preset reuses the existing `setGraphicsOverrides` →
change-event → live-regenerate path, so presets and imports take effect
immediately (including terrain-texture rebuilds for color changes).

## Test plan

- [x] Full suite: 176 files / 2100 tests pass; `tsc`, ESLint, Prettier
clean
- [x] `GraphicsPresets.test.ts` locks preset JSON to the renderer values
the old hardcoded colorblind block produced, schema-validates every
built-in preset, and covers legacy boolean → palette-enum translation
- [x] Driven headless in a real singleplayer game (WebGL): dropdown
present in both the main-menu settings and the in-game modal; selection
in one reflects in the other; presets apply + persist; "Custom" appears
after a manual slider tweak; save-as-preset selects the new entry;
delete via ✕ + confirm; copy JSON → clipboard; invalid-JSON inline
error; valid import applies live (ocean recolor visible in render)
- [x] Migration: seeded legacy overrides survive init untouched and
appear as an active "My settings" preset; legacy colorblind boolean
upgrades to the full preset; fresh profiles get no phantom preset;
migration is idempotent

🤖 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-07-21 16:11:43 -07:00
committed by GitHub
co-authored by Claude Fable 5
parent 6734f76488
commit 1360b27bbd
15 changed files with 1660 additions and 921 deletions
+24 -3
View File
@@ -596,6 +596,8 @@
"title": "Game is Starting..."
},
"graphics_setting": {
"advanced_desc": "Fine-tune individual graphics options",
"advanced_label": "Advanced settings",
"black": "Black",
"classic_icons_desc": "Lighter outline with near-black interior",
"classic_icons_label": "Classic icons",
@@ -606,6 +608,9 @@
"colored_names_label": "Name color",
"coordinate_grid_opacity_desc": "How opaque the coordinate grid is (lower lets more things show through)",
"coordinate_grid_opacity_label": "Coordinate grid opacity",
"copy_json_copied": "Copied!",
"copy_json_desc": "Copy the current settings as JSON to share with friends",
"copy_json_label": "Copy settings JSON",
"fallout_desc": "Show the green nuclear fallout glow on irradiated territory. Disable to improve performance",
"fallout_label": "Fallout effects",
"highland_color_desc": "Base color for higher-elevation terrain.",
@@ -624,6 +629,10 @@
"hover_glow_width_label": "Hover glow size",
"icon_size_desc": "How large structure icons are drawn on the map",
"icon_size_label": "Structure icon size",
"import_json_apply": "Apply",
"import_json_desc": "Paste settings JSON from a friend and apply it",
"import_json_invalid": "That doesn't look like valid settings JSON",
"import_json_label": "Import settings JSON",
"lighting_ambient_desc": "Darkens the map and adds a glow around structures (0 = off)",
"lighting_ambient_label": "Ambient light",
"lighting_unit_glow_desc": "How far the glow spreads around units and structures",
@@ -641,6 +650,16 @@
"ocean_color_label": "Ocean color",
"plains_color_desc": "Base color for lowland terrain.",
"plains_color_label": "Plains color",
"preset_colorblind": "Colorblind",
"preset_colorblind_desc": "Colorblind-friendly territory and border colors",
"preset_custom": "Custom",
"preset_default": "Default",
"preset_default_desc": "The standard OpenFront look",
"preset_delete_confirm": "Delete preset \"{name}\"?",
"preset_migrated_name": "My settings",
"preset_name_placeholder": "Preset name",
"preset_night": "Night",
"preset_night_desc": "Darkened map with glowing structures and units",
"rail_distance_desc": "How far zoomed out train tracks remain visible",
"rail_distance_label": "Train track draw distance",
"rail_thickness_desc": "How wide train tracks are drawn",
@@ -649,11 +668,13 @@
"reset_label": "Reset to defaults",
"sand_color_desc": "Base color for shores.",
"sand_color_label": "Shore color",
"section_accessibility": "Accessibility",
"save_preset_label": "Save",
"section_custom_presets": "Custom presets",
"section_effects": "Effects",
"section_lighting": "Lighting",
"section_map": "Map",
"section_name_labels": "Name Labels",
"section_presets": "Presets",
"section_structure_icons": "Structure Icons",
"section_terrain": "Terrain",
"structure_dots_desc": "Collapse structure icons into small dots when zoomed out",
@@ -1526,8 +1547,6 @@
"camera_movement": "Camera Movement",
"center_camera": "Center Camera",
"center_camera_desc": "Center camera on player",
"colorblind_desc": "Use colorblind-friendly territory and border colors",
"colorblind_label": "Colorblind Mode",
"coordinate_grid_desc": "Toggle the alphanumeric grid overlay",
"coordinate_grid_label": "Coordinate Grid",
"cursor_cost_label_desc": "Show a cost pill under the build cursor icon",
@@ -1549,6 +1568,8 @@
"game_speed_up_desc": "Cycle to next game speed (0.5, 1, 2, max). Single player only.",
"go_to_player_desc": "Toggle zooming in on the player in the beginning of a game.",
"go_to_player_label": "Go to player on start",
"graphics_preset_desc": "Choose the preset look for the map. Fine-tune or make your own in the in-game graphics settings.",
"graphics_preset_label": "Graphics Preset",
"graphics_refresh_modifier": "Graphics refresh modifier",
"graphics_refresh_modifier_desc": "Hold this key and R to refresh graphics",
"graphics_settings_desc": "Adjust how the map looks",
+105
View File
@@ -0,0 +1,105 @@
import { UserSettings } from "../core/game/UserSettings";
import { GraphicsOverridesSchema, type GraphicsOverrides } from "./render/gl";
import builtinPresets from "./render/gl/graphics-presets.json";
import { translateText } from "./Utils";
// Built-in presets, defined in graphics-presets.json — each entry's overrides
// are schema-parsed at load (JSON imports can't carry the palette enum's
// literal types). Overrides are applied wholesale. Night's ambient 0.36 is
// the graphics modal slider's level 8.
export const BUILTIN_PRESETS: ReadonlyArray<{
nameKey: string;
descKey: string;
overrides: GraphicsOverrides;
}> = builtinPresets.map((preset) => ({
nameKey: preset.nameKey,
descKey: preset.descKey,
overrides: GraphicsOverridesSchema.parse(preset.overrides),
}));
// Serialize with recursively sorted keys so preset equality doesn't depend on
// the order the settings were touched in.
export function stableStringify(value: unknown): string {
if (value === null || typeof value !== "object") return JSON.stringify(value);
if (Array.isArray(value)) {
return `[${value.map(stableStringify).join(",")}]`;
}
const entries = Object.entries(value as Record<string, unknown>)
.filter(([, v]) => v !== undefined)
.sort(([a], [b]) => (a < b ? -1 : 1))
.map(([k, v]) => `${JSON.stringify(k)}:${stableStringify(v)}`);
return `{${entries.join(",")}}`;
}
/**
* Parse player-pasted settings JSON. Returns null unless the text is valid
* JSON the schema recognizes in full. The schema strips unknown keys (needed
* to read legacy stored data), which would let a mistyped paste apply as an
* empty or partial config — so anything the parse dropped rejects the import
* instead.
*/
export function parseGraphicsOverridesJson(
text: string,
): GraphicsOverrides | null {
let raw: unknown;
try {
raw = JSON.parse(text);
} catch {
return null;
}
const parsed = GraphicsOverridesSchema.safeParse(raw);
if (!parsed.success) return null;
if (stableStringify(parsed.data) !== stableStringify(raw)) return null;
return parsed.data;
}
/**
* One-time migration for players who tuned their graphics before presets
* existed: snapshot their current custom settings as a saved preset so
* applying a preset can never lose them. Writing the presets key (even as
* {}) marks the migration as done.
*
* Runs at game start and again from the preset selector right before it
* applies anything — the main-menu selector is reachable before any game
* starts, and the snapshot must exist before the first wholesale overwrite.
*/
export function migrateLegacyGraphicsSettings(
userSettings: UserSettings,
): void {
if (userSettings.hasGraphicsPresets()) return;
// The old colorblind toggle stored only a boolean (surfaced as
// palette: "colorblind" by graphicsOverrides()); the Okabe-Ito friend-foe
// border colors it hardcoded are now override data carried by the
// Colorblind preset. Graft them onto any legacy colorblind config — not
// just palette-only ones — so those players keep the blue/orange borders
// alongside whatever else they customized.
let current = userSettings.graphicsOverrides();
const colorblind = BUILTIN_PRESETS.find(
(preset) => preset.nameKey === "graphics_setting.preset_colorblind",
);
if (current.palette === "colorblind" && colorblind !== undefined) {
current = {
...current,
affiliation: {
...colorblind.overrides.affiliation,
...current.affiliation,
},
mapOverlay: {
...colorblind.overrides.mapOverlay,
...current.mapOverlay,
},
};
userSettings.setGraphicsOverrides(current);
}
const isCustom =
Object.keys(current).length > 0 &&
!BUILTIN_PRESETS.some(
(preset) =>
stableStringify(preset.overrides) === stableStringify(current),
);
userSettings.setGraphicsPresets(
isCustom
? { [translateText("graphics_setting.preset_migrated_name")]: current }
: {},
);
}
+1
View File
@@ -222,6 +222,7 @@ export class LangSelector extends LitElement {
"settings-modal",
"username-input",
"game-mode-selector",
"graphics-preset-selector",
"user-setting",
"o-modal",
"o-button",
+15 -27
View File
@@ -9,6 +9,7 @@ import "./components/baseComponents/setting/SettingSelect";
import "./components/baseComponents/setting/SettingSlider";
import "./components/baseComponents/setting/SettingToggle";
import { BaseModal } from "./components/BaseModal";
import "./components/GraphicsPresetSelector";
import { modalHeader } from "./components/ui/ModalHeader";
import { Platform } from "./Platform";
@@ -214,25 +215,6 @@ export class UserSettingModal extends BaseModal {
}, 5000);
}
/** Whether colorblind mode is currently enabled in the graphics overrides. */
private colorblindMode(): boolean {
return (
this.userSettings.graphicsOverrides().accessibility?.colorblind ?? false
);
}
/** Flip the colorblind-mode graphics override and persist it. */
private toggleColorblindMode() {
const overrides = this.userSettings.graphicsOverrides();
this.userSettings.setGraphicsOverrides({
...overrides,
accessibility: {
...overrides.accessibility,
colorblind: !this.colorblindMode(),
},
});
}
private toggleEmojis() {
this.userSettings.toggleEmojis();
@@ -772,14 +754,20 @@ export class UserSettingModal extends BaseModal {
private renderBasicSettings() {
return html`
<!-- 🎨 Colorblind Mode -->
<setting-toggle
label="${translateText("user_setting.colorblind_label")}"
description="${translateText("user_setting.colorblind_desc")}"
id="colorblind-toggle"
.checked=${this.colorblindMode()}
@change=${this.toggleColorblindMode}
></setting-toggle>
<!-- 🎨 Graphics preset -->
<div
class="flex flex-col w-full p-4 bg-white/5 border border-white/10 rounded-xl hover:bg-white/10 transition-all gap-3"
>
<div class="flex flex-col min-w-0">
<div class="text-white font-bold text-base block mb-1">
${translateText("user_setting.graphics_preset_label")}
</div>
<div class="text-white/50 text-sm leading-snug">
${translateText("user_setting.graphics_preset_desc")}
</div>
</div>
<graphics-preset-selector></graphics-preset-selector>
</div>
<!-- 😊 Emojis -->
<setting-toggle
@@ -0,0 +1,171 @@
import { html, LitElement } from "lit";
import { customElement, state } from "lit/decorators.js";
import {
GRAPHICS_KEY,
GRAPHICS_PRESETS_KEY,
USER_SETTINGS_CHANGED_EVENT,
UserSettings,
} from "../../core/game/UserSettings";
import {
BUILTIN_PRESETS,
migrateLegacyGraphicsSettings,
stableStringify,
} from "../GraphicsPresets";
import { showInGameConfirm } from "../InGameModal";
import { type GraphicsOverrides } from "../render/gl";
import { translateText } from "../Utils";
const CUSTOM_VALUE = "custom";
/**
* Dropdown that applies a graphics preset (built-ins + the player's saved
* ones) wholesale. Shows a disabled "Custom" entry while the current
* overrides match no preset, and a delete button when a saved preset is
* selected. Self-contained: reads/writes UserSettings directly and re-renders
* on the settings-changed events, so hosts just drop the element in.
*/
@customElement("graphics-preset-selector")
export class GraphicsPresetSelector extends LitElement {
private readonly userSettings = new UserSettings();
// The option value the player last picked. Selection is otherwise derived
// by content matching, which cannot tell apart presets with identical
// overrides (e.g. a saved preset that duplicates a built-in) — without
// this, such a preset could never show as selected, so its delete button
// could never appear.
@state()
private lastSelected: string | null = null;
createRenderRoot() {
return this;
}
private readonly onSettingsChanged = () => this.requestUpdate();
connectedCallback() {
super.connectedCallback();
for (const key of [GRAPHICS_KEY, GRAPHICS_PRESETS_KEY]) {
globalThis.addEventListener(
`${USER_SETTINGS_CHANGED_EVENT}:${key}`,
this.onSettingsChanged,
);
}
}
disconnectedCallback() {
for (const key of [GRAPHICS_KEY, GRAPHICS_PRESETS_KEY]) {
globalThis.removeEventListener(
`${USER_SETTINGS_CHANGED_EVENT}:${key}`,
this.onSettingsChanged,
);
}
super.disconnectedCallback();
}
private isActive(overrides: GraphicsOverrides): boolean {
return (
stableStringify(this.userSettings.graphicsOverrides()) ===
stableStringify(overrides)
);
}
private overridesForValue(value: string): GraphicsOverrides | undefined {
const separator = value.indexOf(":");
if (separator === -1) return undefined;
const kind = value.slice(0, separator);
const key = value.slice(separator + 1);
return kind === "builtin"
? BUILTIN_PRESETS.find((p) => p.nameKey === key)?.overrides
: this.userSettings.graphicsPresets()[key];
}
private selectedValue(): string {
const last =
this.lastSelected !== null
? this.overridesForValue(this.lastSelected)
: undefined;
if (last !== undefined && this.isActive(last)) {
return this.lastSelected!;
}
const builtin = BUILTIN_PRESETS.find((p) => this.isActive(p.overrides));
if (builtin !== undefined) return `builtin:${builtin.nameKey}`;
const user = Object.entries(this.userSettings.graphicsPresets()).find(
([, overrides]) => this.isActive(overrides),
);
if (user !== undefined) return `user:${user[0]}`;
return CUSTOM_VALUE;
}
private onSelect(event: Event) {
const value = (event.target as HTMLSelectElement).value;
const overrides = this.overridesForValue(value);
if (overrides !== undefined) {
// Snapshot pre-preset custom settings before the first wholesale
// overwrite — the in-game migration hasn't run yet if the player has
// only used the main-menu selector.
migrateLegacyGraphicsSettings(this.userSettings);
this.lastSelected = value;
this.userSettings.setGraphicsOverrides(overrides);
}
}
private async onDelete() {
const value = this.selectedValue();
if (!value.startsWith("user:")) return;
const name = value.slice("user:".length);
const confirmed = await showInGameConfirm(
translateText("graphics_setting.preset_delete_confirm", { name }),
);
if (!confirmed) return;
const presets = { ...this.userSettings.graphicsPresets() };
delete presets[name];
this.userSettings.setGraphicsPresets(presets);
}
render() {
const selected = this.selectedValue();
const userPresets = Object.keys(this.userSettings.graphicsPresets());
return html`
<div class="flex gap-2 items-center">
<select
@change=${this.onSelect}
class="flex-1 min-w-0 px-2 py-1.5 bg-slate-900 border border-slate-500 rounded-sm text-sm text-white cursor-pointer"
>
${selected === CUSTOM_VALUE
? html`<option value=${CUSTOM_VALUE} .selected=${true} disabled>
${translateText("graphics_setting.preset_custom")}
</option>`
: null}
${BUILTIN_PRESETS.map(
(preset) =>
html`<option
value=${`builtin:${preset.nameKey}`}
.selected=${selected === `builtin:${preset.nameKey}`}
>
${translateText(preset.nameKey)}
</option>`,
)}
${userPresets.map(
(name) =>
html`<option
value=${`user:${name}`}
.selected=${selected === `user:${name}`}
>
${name}
</option>`,
)}
</select>
${selected.startsWith("user:")
? html`
<button
class="px-2 py-1 text-slate-400 hover:text-white"
@click=${this.onDelete}
>
</button>
`
: null}
</div>
`;
}
}
File diff suppressed because it is too large Load Diff
+33 -5
View File
@@ -1,7 +1,16 @@
import { z } from "zod";
/**
* Selectable theme palettes. Each name maps to a `<name>-theme.json` in
* gl/ (registered in RenderSettings' THEMES) — extend both when adding a
* new palette.
*/
export const PALETTE_NAMES = ["default", "colorblind"] as const;
export const GraphicsOverridesSchema = z
.object({
// Which theme palette to render with (player colors, terrain tints, …).
palette: z.enum(PALETTE_NAMES),
name: z
.object({
nameScaleFactor: z.number(),
@@ -34,6 +43,22 @@ export const GraphicsOverridesSchema = z
// "#rrggbb" hex string; overrides the lingering fallout ground tint
// left on territory after a nuke.
staleNukeColor: z.string(),
// "#rrggbb" hex strings; normal-view relationship border tints for
// friendly (allied) and embargoed/enemy territory.
friendlyTintColor: z.string(),
embargoTintColor: z.string(),
// How strongly those tints override the territory border color (0-1).
friendlyTintRatio: z.number(),
embargoTintRatio: z.number(),
})
.partial(),
affiliation: z
.object({
// "#rrggbb" hex strings; alt-view border colors for your own, allied,
// and enemy territory.
selfColor: z.string(),
allyColor: z.string(),
enemyColor: z.string(),
})
.partial(),
railroad: z
@@ -56,11 +81,6 @@ export const GraphicsOverridesSchema = z
fallout: z.boolean(),
})
.partial(),
accessibility: z
.object({
colorblind: z.boolean(),
})
.partial(),
terrain: z
.object({
// "#rrggbb" hex string; overrides the base ocean (deep water) color.
@@ -84,3 +104,11 @@ export const GraphicsOverridesSchema = z
.partial();
export type GraphicsOverrides = z.infer<typeof GraphicsOverridesSchema>;
/** User-saved graphics presets: preset name → the overrides it applies. */
export const GraphicsPresetsSchema = z.record(
z.string(),
GraphicsOverridesSchema,
);
export type GraphicsPresets = z.infer<typeof GraphicsPresetsSchema>;
+60 -29
View File
@@ -82,6 +82,49 @@ export function applyGraphicsOverrides(
settings.mapOverlay.staleNukeB = rgb[2] / 255;
}
}
if (overrides.mapOverlay?.friendlyTintColor !== undefined) {
applyHexColor(overrides.mapOverlay.friendlyTintColor, (r, g, b) => {
settings.mapOverlay.friendlyTintR = r;
settings.mapOverlay.friendlyTintG = g;
settings.mapOverlay.friendlyTintB = b;
});
}
if (overrides.mapOverlay?.embargoTintColor !== undefined) {
applyHexColor(overrides.mapOverlay.embargoTintColor, (r, g, b) => {
settings.mapOverlay.embargoTintR = r;
settings.mapOverlay.embargoTintG = g;
settings.mapOverlay.embargoTintB = b;
});
}
if (overrides.mapOverlay?.friendlyTintRatio !== undefined) {
settings.mapOverlay.friendlyTintRatio =
overrides.mapOverlay.friendlyTintRatio;
}
if (overrides.mapOverlay?.embargoTintRatio !== undefined) {
settings.mapOverlay.embargoTintRatio =
overrides.mapOverlay.embargoTintRatio;
}
if (overrides.affiliation?.selfColor !== undefined) {
applyHexColor(overrides.affiliation.selfColor, (r, g, b) => {
settings.affiliation.selfR = r;
settings.affiliation.selfG = g;
settings.affiliation.selfB = b;
});
}
if (overrides.affiliation?.allyColor !== undefined) {
applyHexColor(overrides.affiliation.allyColor, (r, g, b) => {
settings.affiliation.allyR = r;
settings.affiliation.allyG = g;
settings.affiliation.allyB = b;
});
}
if (overrides.affiliation?.enemyColor !== undefined) {
applyHexColor(overrides.affiliation.enemyColor, (r, g, b) => {
settings.affiliation.enemyR = r;
settings.affiliation.enemyG = g;
settings.affiliation.enemyB = b;
});
}
if (overrides.railroad?.railMinZoom !== undefined) {
settings.railroad.railMinZoom = overrides.railroad.railMinZoom;
}
@@ -138,34 +181,22 @@ export function applyGraphicsOverrides(
settings.name.outlineG = channel;
settings.name.outlineB = channel;
}
if (overrides.accessibility?.colorblind === true) {
// Swap the active theme slice for the colorblind palette (replaced
// wholesale — palette arrays differ in length between themes).
settings.theme = createThemeSettings("colorblind");
// Swap the red/green friend-foe encoding (the most common confusion axis)
// for a colorblind-safe blue/orange pairing (Okabe-Ito).
// Alt-view affiliation borders: self/ally in the blue family, enemy orange.
settings.affiliation.selfR = 0;
settings.affiliation.selfG = 0.447;
settings.affiliation.selfB = 0.698;
settings.affiliation.allyR = 0.337;
settings.affiliation.allyG = 0.706;
settings.affiliation.allyB = 0.914;
settings.affiliation.enemyR = 0.835;
settings.affiliation.enemyG = 0.369;
settings.affiliation.enemyB = 0;
// Normal-view relationship border tints: friendly blue, enemy orange,
// applied strongly so the cue doesn't rely on subtle hue.
settings.mapOverlay.friendlyTintR = 0;
settings.mapOverlay.friendlyTintG = 0.447;
settings.mapOverlay.friendlyTintB = 0.698;
settings.mapOverlay.embargoTintR = 0.835;
settings.mapOverlay.embargoTintG = 0.369;
settings.mapOverlay.embargoTintB = 0;
// Strong ratio so the friend/foe tint dominates the darkened territory
// border — neutral keeps its (darkened) fill hue, ally reads blue, enemy
// reads orange.
settings.mapOverlay.friendlyTintRatio = 0.85;
settings.mapOverlay.embargoTintRatio = 0.85;
if (overrides.palette !== undefined) {
// Swap the active theme slice for the named palette (replaced wholesale —
// palette arrays differ in length between themes). The rest of a look —
// e.g. the Colorblind preset's Okabe-Ito friend-foe border colors — is
// plain override data carried by graphics-presets.json.
settings.theme = createThemeSettings(overrides.palette);
}
}
// hexToRgb yields 0-255 channels; the renderer uniforms are 0-1 floats.
function applyHexColor(
hex: string,
assign: (r: number, g: number, b: number) => void,
): void {
const rgb = hexToRgb(hex);
if (rgb !== null) {
assign(rgb[0] / 255, rgb[1] / 255, rgb[2] / 255);
}
}
+2 -1
View File
@@ -1,5 +1,6 @@
import colorblindTheme from "./colorblind-theme.json";
import defaultTheme from "./default-theme.json";
import { PALETTE_NAMES } from "./GraphicsOverrides";
import defaults from "./render-settings.json";
/**
@@ -408,7 +409,7 @@ export interface RenderSettings {
lightConfigs: Record<string, { radius: number; intensity: number }>;
}
export type ThemeName = "default" | "colorblind";
export type ThemeName = (typeof PALETTE_NAMES)[number];
// Typed so tsc validates each theme JSON against the ThemeSettings shape.
const THEMES: Record<ThemeName, ThemeSettings> = {
@@ -0,0 +1,30 @@
[
{
"nameKey": "graphics_setting.preset_default",
"descKey": "graphics_setting.preset_default_desc",
"overrides": {}
},
{
"nameKey": "graphics_setting.preset_night",
"descKey": "graphics_setting.preset_night_desc",
"overrides": { "lighting": { "ambient": 0.36 } }
},
{
"nameKey": "graphics_setting.preset_colorblind",
"descKey": "graphics_setting.preset_colorblind_desc",
"overrides": {
"palette": "colorblind",
"affiliation": {
"selfColor": "#0072b2",
"allyColor": "#56b4e9",
"enemyColor": "#d55e00"
},
"mapOverlay": {
"friendlyTintColor": "#0072b2",
"embargoTintColor": "#d55e00",
"friendlyTintRatio": 0.85,
"embargoTintRatio": 0.85
}
}
}
]
+1 -1
View File
@@ -2,7 +2,7 @@ export type { AttackRingInput } from "../types";
// createDebugGui is intentionally not re-exported here — it pulls lil-gui and
// the debug GUI into the main bundle; dynamically import "./debug/index".
export { GraphicsOverridesSchema } from "./GraphicsOverrides";
export type { GraphicsOverrides } from "./GraphicsOverrides";
export type { GraphicsOverrides, GraphicsPresets } from "./GraphicsOverrides";
export { GLUnavailableError, showGLGate, trackGLInit } from "./initGL";
export { MapRenderer } from "./MapRenderer";
export { preloadAtlasData } from "./passes/name-pass/AtlasData";
+17 -9
View File
@@ -2,8 +2,10 @@ import { Colord, colord, LabaColor } from "colord";
import { PlayerType, Team } from "../../core/game/Game";
import { UserSettings } from "../../core/game/UserSettings";
import { simpleHash } from "../../core/Util";
import { PALETTE_NAMES } from "../render/gl/GraphicsOverrides";
import {
createThemeSettings,
ThemeName,
ThemeSettings,
} from "../render/gl/RenderSettings";
import { PlayerView } from "../view";
@@ -259,15 +261,13 @@ export class SettingsTheme implements Theme {
*/
class ThemeProvider {
private readonly userSettings = new UserSettings();
private defaultTheme = new SettingsTheme(createThemeSettings("default"));
private colorblind = new SettingsTheme(createThemeSettings("colorblind"));
private themes = createThemes();
/** The active theme, selected from the colorblind-mode preference. */
/** The active theme, selected from the palette graphics override. */
current(): Theme {
if (this.userSettings.graphicsOverrides().accessibility?.colorblind) {
return this.colorblind;
}
return this.defaultTheme;
return this.themes[
this.userSettings.graphicsOverrides().palette ?? "default"
];
}
/**
@@ -276,9 +276,17 @@ class ThemeProvider {
* colour-pool depletion across games in a single session.
*/
reset(): void {
this.defaultTheme = new SettingsTheme(createThemeSettings("default"));
this.colorblind = new SettingsTheme(createThemeSettings("colorblind"));
this.themes = createThemes();
}
}
function createThemes(): Record<ThemeName, SettingsTheme> {
return Object.fromEntries(
PALETTE_NAMES.map((name) => [
name,
new SettingsTheme(createThemeSettings(name)),
]),
) as Record<ThemeName, SettingsTheme>;
}
export const themeProvider = new ThemeProvider();
+43 -2
View File
@@ -1,6 +1,8 @@
import {
GraphicsOverrides,
GraphicsOverridesSchema,
GraphicsPresets,
GraphicsPresetsSchema,
} from "../../client/render/gl/GraphicsOverrides";
import { Cosmetics } from "../CosmeticSchemas";
import { PlayerPattern } from "../Schemas";
@@ -59,6 +61,7 @@ export const COLOR_KEY = "settings.territoryColor";
export const PERFORMANCE_OVERLAY_KEY = "settings.performanceOverlay";
export const KEYBINDS_KEY = "settings.keybinds";
export const GRAPHICS_KEY = "settings.graphics";
export const GRAPHICS_PRESETS_KEY = "settings.graphicsPresets";
export const EFFECTS_KEY = "settings.effects";
export class UserSettings {
@@ -399,8 +402,21 @@ export class UserSettings {
const raw = this.getString(GRAPHICS_KEY, "");
if (!raw) return {};
try {
const parsed = GraphicsOverridesSchema.safeParse(JSON.parse(raw));
if (parsed.success) return parsed.data;
const json: unknown = JSON.parse(raw);
const parsed = GraphicsOverridesSchema.safeParse(json);
if (parsed.success) {
const overrides = parsed.data;
// Legacy: colorblind was an accessibility.colorblind boolean before
// the palette enum existed; the schema strips the unknown section, so
// translate it here. Rewritten to the new shape on the next save.
const legacyColorblind = (
json as { accessibility?: { colorblind?: unknown } }
).accessibility?.colorblind;
if (overrides.palette === undefined && legacyColorblind === true) {
overrides.palette = "colorblind";
}
return overrides;
}
} catch {
// fall through
}
@@ -411,6 +427,31 @@ export class UserSettings {
this.setString(GRAPHICS_KEY, JSON.stringify(value));
}
// Named user-saved graphics presets. Returns {} if missing, unparseable, or
// fails schema validation.
graphicsPresets(): GraphicsPresets {
const raw = this.getString(GRAPHICS_PRESETS_KEY, "");
if (!raw) return {};
try {
const parsed = GraphicsPresetsSchema.safeParse(JSON.parse(raw));
if (parsed.success) return parsed.data;
} catch {
// fall through
}
return {};
}
setGraphicsPresets(value: GraphicsPresets): void {
this.setString(GRAPHICS_PRESETS_KEY, JSON.stringify(value));
}
// Whether the presets key has ever been written. Distinguishes a player who
// deleted all their presets (stored "{}") from one who has never seen the
// preset UI — used to run the legacy-overrides migration exactly once.
hasGraphicsPresets(): boolean {
return this.getString(GRAPHICS_PRESETS_KEY, "") !== "";
}
// In case localStorage was manually edited to be invalid, return an empty object
parsedUserKeybinds(): Record<string, any> {
const raw = this.getString(KEYBINDS_KEY, "{}");
+177
View File
@@ -0,0 +1,177 @@
import { beforeEach, describe, expect, it } from "vitest";
import {
BUILTIN_PRESETS,
migrateLegacyGraphicsSettings,
parseGraphicsOverridesJson,
} from "../src/client/GraphicsPresets";
import builtinPresets from "../src/client/render/gl/graphics-presets.json";
import { GraphicsOverridesSchema } from "../src/client/render/gl/GraphicsOverrides";
import { applyGraphicsOverrides } from "../src/client/render/gl/RenderOverrides";
import {
createRenderSettings,
createThemeSettings,
} from "../src/client/render/gl/RenderSettings";
import {
GRAPHICS_KEY,
GRAPHICS_PRESETS_KEY,
UserSettings,
} from "../src/core/game/UserSettings";
describe("built-in graphics presets", () => {
it("every preset's overrides validate against the schema", () => {
for (const preset of builtinPresets) {
const parsed = GraphicsOverridesSchema.safeParse(preset.overrides);
expect(parsed.success, `${preset.nameKey}: ${parsed.error}`).toBe(true);
}
});
it("colorblind preset applies the Okabe-Ito friend-foe colors and theme", () => {
const colorblind = builtinPresets.find(
(p) => p.nameKey === "graphics_setting.preset_colorblind",
);
expect(colorblind).toBeDefined();
const settings = createRenderSettings();
applyGraphicsOverrides(
settings,
GraphicsOverridesSchema.parse(colorblind!.overrides),
);
// Alt-view affiliation borders: self/ally blue family, enemy orange.
expect(settings.affiliation.selfR).toBeCloseTo(0, 2);
expect(settings.affiliation.selfG).toBeCloseTo(0.447, 2);
expect(settings.affiliation.selfB).toBeCloseTo(0.698, 2);
expect(settings.affiliation.allyR).toBeCloseTo(0.337, 2);
expect(settings.affiliation.allyG).toBeCloseTo(0.706, 2);
expect(settings.affiliation.allyB).toBeCloseTo(0.914, 2);
expect(settings.affiliation.enemyR).toBeCloseTo(0.835, 2);
expect(settings.affiliation.enemyG).toBeCloseTo(0.369, 2);
expect(settings.affiliation.enemyB).toBeCloseTo(0, 2);
// Normal-view relationship border tints: friendly blue, enemy orange,
// applied strongly so the cue doesn't rely on subtle hue.
expect(settings.mapOverlay.friendlyTintR).toBeCloseTo(0, 2);
expect(settings.mapOverlay.friendlyTintG).toBeCloseTo(0.447, 2);
expect(settings.mapOverlay.friendlyTintB).toBeCloseTo(0.698, 2);
expect(settings.mapOverlay.embargoTintR).toBeCloseTo(0.835, 2);
expect(settings.mapOverlay.embargoTintG).toBeCloseTo(0.369, 2);
expect(settings.mapOverlay.embargoTintB).toBeCloseTo(0, 2);
expect(settings.mapOverlay.friendlyTintRatio).toBe(0.85);
expect(settings.mapOverlay.embargoTintRatio).toBe(0.85);
// The palette swap rides on the palette enum.
expect(settings.theme).toEqual(createThemeSettings("colorblind"));
});
});
describe("legacy colorblind flag", () => {
it("accessibility.colorblind stored by old clients surfaces as the colorblind palette", async () => {
const { UserSettings } = await import("../src/core/game/UserSettings");
const userSettings = new UserSettings();
// Old clients stored {accessibility:{colorblind:true}}; write it through
// the settings cache in the pre-palette shape.
userSettings.setGraphicsOverrides({
accessibility: { colorblind: true },
} as never);
expect(userSettings.graphicsOverrides().palette).toBe("colorblind");
// A save in the new shape sticks and stops the translation.
userSettings.setGraphicsOverrides({});
expect(userSettings.graphicsOverrides().palette).toBeUndefined();
});
});
describe("migrateLegacyGraphicsSettings", () => {
const userSettings = new UserSettings();
beforeEach(() => {
userSettings.removeCached(GRAPHICS_KEY);
userSettings.removeCached(GRAPHICS_PRESETS_KEY);
});
it("snapshots pre-existing custom overrides into a preset and leaves them active", () => {
userSettings.setGraphicsOverrides({ name: { nameScaleFactor: 2 } });
migrateLegacyGraphicsSettings(userSettings);
expect(userSettings.graphicsOverrides()).toEqual({
name: { nameScaleFactor: 2 },
});
expect(Object.values(userSettings.graphicsPresets())).toEqual([
{ name: { nameScaleFactor: 2 } },
]);
});
it("upgrades a palette-only legacy colorblind config to the full Colorblind preset", () => {
userSettings.setGraphicsOverrides({
accessibility: { colorblind: true },
} as never);
migrateLegacyGraphicsSettings(userSettings);
const colorblind = BUILTIN_PRESETS.find(
(p) => p.nameKey === "graphics_setting.preset_colorblind",
);
expect(userSettings.graphicsOverrides()).toEqual(colorblind!.overrides);
// Matches a built-in, so no phantom saved preset.
expect(userSettings.graphicsPresets()).toEqual({});
});
it("grafts the Colorblind borders onto legacy colorblind configs with other tweaks", () => {
userSettings.setGraphicsOverrides({
accessibility: { colorblind: true },
name: { nameScaleFactor: 2 },
} as never);
migrateLegacyGraphicsSettings(userSettings);
const overrides = userSettings.graphicsOverrides();
expect(overrides.name).toEqual({ nameScaleFactor: 2 });
expect(overrides.palette).toBe("colorblind");
// The Okabe-Ito borders the old colorblind boolean hardcoded.
expect(overrides.affiliation).toEqual({
selfColor: "#0072b2",
allyColor: "#56b4e9",
enemyColor: "#d55e00",
});
expect(overrides.mapOverlay).toEqual({
friendlyTintColor: "#0072b2",
embargoTintColor: "#d55e00",
friendlyTintRatio: 0.85,
embargoTintRatio: 0.85,
});
expect(Object.values(userSettings.graphicsPresets())).toEqual([overrides]);
});
it("stamps fresh profiles with no phantom preset and never runs twice", () => {
migrateLegacyGraphicsSettings(userSettings);
expect(userSettings.graphicsPresets()).toEqual({});
// Custom tweaks made after the stamp are not re-snapshotted.
userSettings.setGraphicsOverrides({ name: { nameScaleFactor: 2 } });
migrateLegacyGraphicsSettings(userSettings);
expect(userSettings.graphicsPresets()).toEqual({});
});
});
describe("parseGraphicsOverridesJson", () => {
it("accepts valid overrides JSON", () => {
expect(
parseGraphicsOverridesJson(
'{"palette":"colorblind","lighting":{"ambient":0.36}}',
),
).toEqual({ palette: "colorblind", lighting: { ambient: 0.36 } });
});
it("rejects invalid JSON and non-objects", () => {
expect(parseGraphicsOverridesJson("not json")).toBeNull();
expect(parseGraphicsOverridesJson("5")).toBeNull();
expect(parseGraphicsOverridesJson("null")).toBeNull();
expect(parseGraphicsOverridesJson("[]")).toBeNull();
});
it("rejects unknown keys instead of stripping them to an empty config", () => {
// The schema strips unknown keys when reading stored data; a paste that
// survives only by stripping must not silently wipe the user's settings.
expect(parseGraphicsOverridesJson('{"nmae":{"nameScaleFactor":2}}')).toBe(
null,
);
expect(parseGraphicsOverridesJson('{"name":{"nameScale":2}}')).toBe(null);
expect(
parseGraphicsOverridesJson('{"accessibility":{"colorblind":true}}'),
).toBe(null);
});
});
+3
View File
@@ -27,6 +27,9 @@ const DYNAMIC_KEY_PATTERNS: RegExp[] = [
/^build_menu\.desc\.[^.]+$/,
/^unit_type\.[^.]+$/,
/^news_box\.(tournament|tutorial|news|warning|firefox_warning)$/,
// Built-in graphics preset names/descriptions are referenced from
// src/client/render/gl/graphics-presets.json, not translateText literals.
/^graphics_setting\.preset_(default|night|colorblind)(_desc)?$/,
];
/**