mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-23 19:20:18 +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>
178 lines
7.1 KiB
TypeScript
178 lines
7.1 KiB
TypeScript
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);
|
|
});
|
|
});
|