mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-23 06:53:02 +00:00
## 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>
106 lines
3.9 KiB
TypeScript
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 }
|
|
: {},
|
|
);
|
|
}
|