Files
OpenFrontIO/src/client/GraphicsPresets.ts
T
1360b27bbd 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>
2026-07-21 16:11:43 -07:00

106 lines
3.9 KiB
TypeScript

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 }
: {},
);
}