mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-23 06:03:05 +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:
+24
-3
@@ -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",
|
||||
|
||||
@@ -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 }
|
||||
: {},
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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>;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -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";
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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, "{}");
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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)?$/,
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user