diff --git a/resources/lang/en.json b/resources/lang/en.json index 273316c89..a8be16bd6 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -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", diff --git a/src/client/GraphicsPresets.ts b/src/client/GraphicsPresets.ts new file mode 100644 index 000000000..40c04ae98 --- /dev/null +++ b/src/client/GraphicsPresets.ts @@ -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) + .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 } + : {}, + ); +} diff --git a/src/client/LangSelector.ts b/src/client/LangSelector.ts index c38557d87..186f5ec32 100644 --- a/src/client/LangSelector.ts +++ b/src/client/LangSelector.ts @@ -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", diff --git a/src/client/UserSettingModal.ts b/src/client/UserSettingModal.ts index 41a8b06c5..428afd95d 100644 --- a/src/client/UserSettingModal.ts +++ b/src/client/UserSettingModal.ts @@ -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` - - + +
+
+
+ ${translateText("user_setting.graphics_preset_label")} +
+
+ ${translateText("user_setting.graphics_preset_desc")} +
+
+ +
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` +
+ + ${selected.startsWith("user:") + ? html` + + ` + : null} +
+ `; + } +} diff --git a/src/client/hud/layers/GraphicsSettingsModal.ts b/src/client/hud/layers/GraphicsSettingsModal.ts index 1a7225239..09f7524e7 100644 --- a/src/client/hud/layers/GraphicsSettingsModal.ts +++ b/src/client/hud/layers/GraphicsSettingsModal.ts @@ -5,10 +5,15 @@ import { PauseGameIntentEvent } from "src/client/Transport"; import { assetUrl } from "../../../core/AssetUrls"; import { EventBus } from "../../../core/EventBus"; import { UserSettings } from "../../../core/game/UserSettings"; +import "../../components/GraphicsPresetSelector"; import { Controller } from "../../Controller"; -import { translateText } from "../../Utils"; -import type { GraphicsOverrides } from "../../render/gl"; +import { + migrateLegacyGraphicsSettings, + parseGraphicsOverridesJson, +} from "../../GraphicsPresets"; +import { type GraphicsOverrides } from "../../render/gl"; import renderDefaults from "../../render/gl/render-settings.json"; +import { translateText } from "../../Utils"; const settingsIcon = assetUrl("images/SettingIconWhite.svg"); @@ -162,6 +167,23 @@ export class GraphicsSettingsModal extends LitElement implements Controller { @property({ type: Boolean }) wasPausedWhenOpened = false; + @state() + private advancedOpen = false; + + @state() + private presetName = ""; + + @state() + private importText = ""; + + @state() + private importError = false; + + @state() + private copiedJson = false; + + private copyResetTimer: ReturnType | undefined; + init() { this.eventBus.on(ShowGraphicsSettingsModalEvent, (event) => { this.isVisible = event.isVisible; @@ -170,6 +192,7 @@ export class GraphicsSettingsModal extends LitElement implements Controller { this.pauseGame(true); this.requestUpdate(); }); + migrateLegacyGraphicsSettings(this.userSettings); } private pauseGame(pause: boolean) { @@ -580,18 +603,6 @@ export class GraphicsSettingsModal extends LitElement implements Controller { this.requestUpdate(); } - /** Merge a patch into the accessibility graphics overrides and persist it. */ - private patchAccessibility( - patch: Partial, - ) { - const current = this.userSettings.graphicsOverrides(); - this.userSettings.setGraphicsOverrides({ - ...current, - accessibility: { ...current.accessibility, ...patch }, - }); - this.requestUpdate(); - } - private currentSpecialEffects(): boolean { return ( this.userSettings.graphicsOverrides().passEnabled?.fx ?? @@ -637,18 +648,6 @@ export class GraphicsSettingsModal extends LitElement implements Controller { this.patchSmallPlayerGlow({ strength: value }); } - /** Whether colorblind mode is currently enabled. */ - private currentColorblind(): boolean { - return ( - this.userSettings.graphicsOverrides().accessibility?.colorblind ?? false - ); - } - - /** Toggle colorblind-friendly colors. */ - private onToggleColorblind() { - this.patchAccessibility({ colorblind: !this.currentColorblind() }); - } - private onNameScaleChange(event: Event) { const value = parseFloat((event.target as HTMLInputElement).value); this.patchName({ nameScaleFactor: value }); @@ -690,39 +689,153 @@ export class GraphicsSettingsModal extends LitElement implements Controller { this.requestUpdate(); } + private applyPreset(overrides: GraphicsOverrides) { + this.userSettings.setGraphicsOverrides(overrides); + this.requestUpdate(); + } + + private onPresetNameInput(event: Event) { + this.presetName = (event.target as HTMLInputElement).value; + } + + private onSavePreset() { + const name = this.presetName.trim(); + if (!name) return; + this.userSettings.setGraphicsPresets({ + ...this.userSettings.graphicsPresets(), + [name]: this.userSettings.graphicsOverrides(), + }); + this.presetName = ""; + } + + private async onCopyJson() { + const json = JSON.stringify(this.userSettings.graphicsOverrides(), null, 2); + try { + await navigator.clipboard.writeText(json); + } catch { + return; // clipboard unavailable (permissions / insecure context) + } + this.copiedJson = true; + clearTimeout(this.copyResetTimer); + this.copyResetTimer = setTimeout(() => (this.copiedJson = false), 1500); + } + + private onImportTextInput(event: Event) { + this.importText = (event.target as HTMLTextAreaElement).value; + this.importError = false; + } + + private onImportApply() { + const parsed = parseGraphicsOverridesJson(this.importText); + if (parsed === null) { + this.importError = true; + return; + } + this.applyPreset(parsed); + this.importText = ""; + } + + private onToggleAdvanced() { + this.advancedOpen = !this.advancedOpen; + } + + private renderPresets() { + return html` +
+ ${translateText("graphics_setting.section_presets")} +
+ +
+ +
+ `; + } + + private renderPresetTools() { + return html` +
+ ${translateText("graphics_setting.section_custom_presets")} +
+ +
+ + +
+ + + +
+
+ ${translateText("graphics_setting.import_json_label")} +
+
+ ${translateText("graphics_setting.import_json_desc")} +
+ + ${this.importError + ? html`
+ ${translateText("graphics_setting.import_json_invalid")} +
` + : null} + +
+ `; + } + render() { if (!this.isVisible) return null; - const nameScale = this.currentNameScale(); - const nameCull = this.currentNameCull(); - const hoverFade = this.currentHoverFade(); - const hoverGlowWidth = this.currentHoverGlowWidth(); - const hoverGlowAlpha = this.currentHoverGlowAlpha(); - const namesColored = !this.currentDarkNames(); - const iconSize = this.currentIconSize(); - const classicIcons = this.currentClassicIcons(); - const classicNumbers = this.currentClassicNumbers(); - const showDots = this.currentShowDots(); - const navalHighlight = this.currentNavalHighlight(); - const highlightFill = this.currentHighlightFill(); - const highlightBrighten = this.currentHighlightBrighten(); - const highlightThicken = this.currentHighlightThicken(); - const territorySat = this.currentTerritorySat(); - const territoryAlpha = this.currentTerritoryAlpha(); - const coordinateGridOpacity = this.currentCoordinateGridOpacity(); - const railDrawDistance = RAIL_ZOOM_MAX - this.currentRailMinZoom(); - const railThickness = this.currentRailThickness(); - const oceanColor = this.currentOceanColor(); - const sandColor = this.currentSandColor(); - const plainsColor = this.currentPlainsColor(); - const highlandColor = this.currentHighlandColor(); - const mountainColor = this.currentMountainColor(); - const nukeColor = this.currentNukeColor(); - const ambientLevel = this.currentAmbientLevel(); - const unitGlow = this.currentUnitGlow(); - const glowStrength = this.currentGlowStrength(); - const colorblind = this.currentColorblind(); - return html` `; } + + private renderAdvanced() { + const nameScale = this.currentNameScale(); + const nameCull = this.currentNameCull(); + const hoverFade = this.currentHoverFade(); + const hoverGlowWidth = this.currentHoverGlowWidth(); + const hoverGlowAlpha = this.currentHoverGlowAlpha(); + const namesColored = !this.currentDarkNames(); + const iconSize = this.currentIconSize(); + const classicIcons = this.currentClassicIcons(); + const classicNumbers = this.currentClassicNumbers(); + const showDots = this.currentShowDots(); + const navalHighlight = this.currentNavalHighlight(); + const highlightFill = this.currentHighlightFill(); + const highlightBrighten = this.currentHighlightBrighten(); + const highlightThicken = this.currentHighlightThicken(); + const territorySat = this.currentTerritorySat(); + const territoryAlpha = this.currentTerritoryAlpha(); + const coordinateGridOpacity = this.currentCoordinateGridOpacity(); + const railDrawDistance = RAIL_ZOOM_MAX - this.currentRailMinZoom(); + const railThickness = this.currentRailThickness(); + const oceanColor = this.currentOceanColor(); + const sandColor = this.currentSandColor(); + const plainsColor = this.currentPlainsColor(); + const highlandColor = this.currentHighlandColor(); + const mountainColor = this.currentMountainColor(); + const nukeColor = this.currentNukeColor(); + const ambientLevel = this.currentAmbientLevel(); + const unitGlow = this.currentUnitGlow(); + const glowStrength = this.currentGlowStrength(); + + return html` + ${this.renderPresetTools()} + +
+ ${translateText("graphics_setting.section_lighting")} +
+ +
+
+
+ ${translateText("graphics_setting.lighting_ambient_label")} +
+
+ ${translateText("graphics_setting.lighting_ambient_desc")} +
+ +
+
+ ${ambientLevel} +
+
+ +
+
+
+ ${translateText("graphics_setting.lighting_unit_glow_label")} +
+
+ ${translateText("graphics_setting.lighting_unit_glow_desc")} +
+ +
+
${unitGlow}
+
+ +
+ ${translateText("graphics_setting.section_name_labels")} +
+ +
+
+
+ ${translateText("graphics_setting.name_scale_label")} +
+ +
+
+ ${nameScale.toFixed(2)} +
+
+ +
+
+
+ ${translateText("graphics_setting.name_cull_label")} +
+
+ ${translateText("graphics_setting.name_cull_desc")} +
+ +
+
+ ${nameCull.toFixed(3)} +
+
+ +
+
+
+ ${translateText("graphics_setting.hover_fade_label")} +
+
+ ${translateText("graphics_setting.hover_fade_desc")} +
+ +
+
+ ${hoverFade.toFixed(2)} +
+
+ +
+
+
+ ${translateText("graphics_setting.hover_glow_width_label")} +
+
+ ${translateText("graphics_setting.hover_glow_width_desc")} +
+ +
+
+ ${hoverGlowWidth.toFixed(1)} +
+
+ +
+
+
+ ${translateText("graphics_setting.hover_glow_alpha_label")} +
+
+ ${translateText("graphics_setting.hover_glow_alpha_desc")} +
+ +
+
+ ${hoverGlowAlpha.toFixed(2)} +
+
+ + + +
+ ${translateText("graphics_setting.section_structure_icons")} +
+ +
+
+
+ ${translateText("graphics_setting.icon_size_label")} +
+
+ ${translateText("graphics_setting.icon_size_desc")} +
+ +
+
+ ${iconSize.toFixed(0)} +
+
+ + + + + + + +
+ ${translateText("graphics_setting.section_map")} +
+ + + +
+
+
+ ${translateText("graphics_setting.highlight_fill_label")} +
+
+ ${translateText("graphics_setting.highlight_fill_desc")} +
+ +
+
+ ${highlightFill.toFixed(2)} +
+
+ +
+
+
+ ${translateText("graphics_setting.highlight_brighten_label")} +
+
+ ${translateText("graphics_setting.highlight_brighten_desc")} +
+ +
+
+ ${highlightBrighten.toFixed(2)} +
+
+ +
+
+
+ ${translateText("graphics_setting.highlight_thicken_label")} +
+
+ ${translateText("graphics_setting.highlight_thicken_desc")} +
+ +
+
+ ${highlightThicken.toFixed(0)} +
+
+ +
+
+
+ ${translateText("graphics_setting.territory_sat_label")} +
+
+ ${translateText("graphics_setting.territory_sat_desc")} +
+ +
+
+ ${territorySat.toFixed(2)} +
+
+ +
+
+
+ ${translateText("graphics_setting.territory_alpha_label")} +
+
+ ${translateText("graphics_setting.territory_alpha_desc")} +
+ +
+
+ ${territoryAlpha.toFixed(2)} +
+
+ +
+
+
+ ${translateText("graphics_setting.coordinate_grid_opacity_label")} +
+
+ ${translateText("graphics_setting.coordinate_grid_opacity_desc")} +
+ +
+
+ ${coordinateGridOpacity.toFixed(2)} +
+
+ +
+
+
+ ${translateText("graphics_setting.rail_distance_label")} +
+
+ ${translateText("graphics_setting.rail_distance_desc")} +
+ +
+
+ ${railDrawDistance.toFixed(1)} +
+
+ +
+
+
+ ${translateText("graphics_setting.rail_thickness_label")} +
+
+ ${translateText("graphics_setting.rail_thickness_desc")} +
+ +
+
+ ${railThickness.toFixed(1)} +
+
+ +
+ ${translateText("graphics_setting.section_terrain")} +
+ +
+
+
+ ${translateText("graphics_setting.ocean_color_label")} +
+
+ ${translateText("graphics_setting.ocean_color_desc")} +
+
+ + +
+ +
+
+
+ ${translateText("graphics_setting.sand_color_label")} +
+
+ ${translateText("graphics_setting.sand_color_desc")} +
+
+ + +
+ +
+
+
+ ${translateText("graphics_setting.plains_color_label")} +
+
+ ${translateText("graphics_setting.plains_color_desc")} +
+
+ + +
+ +
+
+
+ ${translateText("graphics_setting.highland_color_label")} +
+
+ ${translateText("graphics_setting.highland_color_desc")} +
+
+ + +
+ +
+
+
+ ${translateText("graphics_setting.mountain_color_label")} +
+
+ ${translateText("graphics_setting.mountain_color_desc")} +
+
+ + +
+ +
+
+
+ ${translateText("graphics_setting.nuke_color_label")} +
+
+ ${translateText("graphics_setting.nuke_color_desc")} +
+
+ + +
+ +
+ ${translateText("graphics_setting.section_effects")} +
+ + + + + +
+
+
+ ${translateText("user_setting.highlight_glow_strength_label")} +
+
+ ${translateText("user_setting.highlight_small_players_desc")} +
+ +
+
+ ${Math.round(glowStrength * 100)}% +
+
+ +
+ +
+ `; + } } diff --git a/src/client/render/gl/GraphicsOverrides.ts b/src/client/render/gl/GraphicsOverrides.ts index d17aeeca6..b033723e8 100644 --- a/src/client/render/gl/GraphicsOverrides.ts +++ b/src/client/render/gl/GraphicsOverrides.ts @@ -1,7 +1,16 @@ import { z } from "zod"; +/** + * Selectable theme palettes. Each name maps to a `-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; + +/** User-saved graphics presets: preset name → the overrides it applies. */ +export const GraphicsPresetsSchema = z.record( + z.string(), + GraphicsOverridesSchema, +); + +export type GraphicsPresets = z.infer; diff --git a/src/client/render/gl/RenderOverrides.ts b/src/client/render/gl/RenderOverrides.ts index d46c6b6dc..2429e94ce 100644 --- a/src/client/render/gl/RenderOverrides.ts +++ b/src/client/render/gl/RenderOverrides.ts @@ -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); } } diff --git a/src/client/render/gl/RenderSettings.ts b/src/client/render/gl/RenderSettings.ts index 900cb772a..f1a6561ad 100644 --- a/src/client/render/gl/RenderSettings.ts +++ b/src/client/render/gl/RenderSettings.ts @@ -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; } -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 = { diff --git a/src/client/render/gl/graphics-presets.json b/src/client/render/gl/graphics-presets.json new file mode 100644 index 000000000..d8c5612ef --- /dev/null +++ b/src/client/render/gl/graphics-presets.json @@ -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 + } + } + } +] diff --git a/src/client/render/gl/index.ts b/src/client/render/gl/index.ts index 34a520ee8..4750f3b58 100644 --- a/src/client/render/gl/index.ts +++ b/src/client/render/gl/index.ts @@ -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"; diff --git a/src/client/theme/ThemeProvider.ts b/src/client/theme/ThemeProvider.ts index 20a4f5b70..cc4672544 100644 --- a/src/client/theme/ThemeProvider.ts +++ b/src/client/theme/ThemeProvider.ts @@ -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 { + return Object.fromEntries( + PALETTE_NAMES.map((name) => [ + name, + new SettingsTheme(createThemeSettings(name)), + ]), + ) as Record; +} + export const themeProvider = new ThemeProvider(); diff --git a/src/core/game/UserSettings.ts b/src/core/game/UserSettings.ts index 3b7ecdc77..63aa99b70 100644 --- a/src/core/game/UserSettings.ts +++ b/src/core/game/UserSettings.ts @@ -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 { const raw = this.getString(KEYBINDS_KEY, "{}"); diff --git a/tests/GraphicsPresets.test.ts b/tests/GraphicsPresets.test.ts new file mode 100644 index 000000000..6690f0392 --- /dev/null +++ b/tests/GraphicsPresets.test.ts @@ -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); + }); +}); diff --git a/tests/TranslationSystem.test.ts b/tests/TranslationSystem.test.ts index eaa2f085c..a605eafd9 100644 --- a/tests/TranslationSystem.test.ts +++ b/tests/TranslationSystem.test.ts @@ -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)?$/, ]; /**