mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-23 18:57:13 +00:00
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:
@@ -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 }
|
||||
: {},
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user