Add Graphics Settings modal with live name-label tuning (#4065)

## Description:

- Add a user-facing **Graphics Settings** modal accessible from the
in-game Settings menu, with live preview as sliders change.
- First two knobs: **Name Scale** and **Minimum Name Size** (the
name-cull threshold).
- Overrides stored as a single JSON blob in `localStorage` under
`settings.graphics`, validated by a Zod schema
(`GraphicsOverridesSchema`). Future graphics knobs just extend the
schema + slider list.

## How it fits together

- `generateRenderSettings(overrides)` (`RenderSettings.ts`) — pure
function: clones `render-settings.json` defaults, layers overrides on
top, returns a fresh `RenderSettings`.
- `UserSettings.graphicsOverrides()` / `setGraphicsOverrides()` —
read/write the blob; falls back to `{}` on a missing/corrupt entry.
- `ClientGameRunner` listens for
`USER_SETTINGS_CHANGED_EVENT:settings.graphics`, regenerates, and
`Object.assign`s each category into the live `view.getSettings()` slice
so passes pick up the new values on the next frame (no renderer
reconstruction).
- Modal reads defaults straight from `render-settings.json` so there's
no duplication.


<img width="599" height="515" alt="Screenshot 2026-05-28 at 11 18 43 AM"
src="https://github.com/user-attachments/assets/263d7d91-10d8-4a66-a069-10015c735d60"
/>

## Please complete the following:

- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory
- [x] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced

## Please put your Discord username so you can be contacted if a bug or
regression is found:

evan
This commit is contained in:
Evan
2026-05-28 13:06:43 -07:00
committed by GitHub
parent 8142bc1070
commit 20bc311caf
11 changed files with 401 additions and 7 deletions
+1
View File
@@ -341,6 +341,7 @@
<replay-panel></replay-panel>
</div>
<settings-modal></settings-modal>
<graphics-settings-modal></graphics-settings-modal>
<player-panel></player-panel>
<spawn-timer></spawn-timer>
<immunity-timer></immunity-timer>
+11
View File
@@ -827,6 +827,8 @@
"render_debug_gui": "Render Debug GUI",
"render_debug_gui_desc": "Toggle the renderer tuning panel",
"development_only": "Development Only",
"graphics_settings_label": "Graphics Settings",
"graphics_settings_desc": "Adjust how the map looks",
"easter_writing_speed_label": "Writing Speed Multiplier",
"easter_writing_speed_desc": "Adjust how fast you pretend to code (x1x100)",
"easter_bug_count_label": "Bug Count",
@@ -915,6 +917,15 @@
"sound_effects_volume": "Sound Effects Volume",
"keybind_conflict_error": "The key {key} is already bound to another action."
},
"graphics_setting": {
"title": "Graphics Settings",
"section_name_labels": "Name Labels",
"name_scale_label": "Name Scale",
"name_cull_label": "Minimum name size",
"name_cull_desc": "Hide names smaller than this size",
"reset_label": "Reset to defaults",
"reset_desc": "Clear all graphics overrides"
},
"chat": {
"title": "Quick Chat",
"to": "Sent {user}: {msg}",
+24 -1
View File
@@ -31,6 +31,7 @@ import { GameView, PlayerView } from "../core/game/GameView";
import { loadTerrainMap, TerrainMapData } from "../core/game/TerrainMapLoader";
import {
DARK_MODE_KEY,
GRAPHICS_KEY,
USER_SETTINGS_CHANGED_EVENT,
UserSettings,
} from "../core/game/UserSettings";
@@ -67,7 +68,11 @@ import {
import { createCanvas } from "./Utils";
import { WebGLFrameBuilder } from "./WebGLFrameBuilder";
import { createRenderer, GameRenderer } from "./hud/GameRenderer";
import { createDebugGui, GameView as WebGLGameView } from "./render/gl";
import {
createDebugGui,
generateRenderSettings,
GameView as WebGLGameView,
} from "./render/gl";
import { ALL_UNIT_TYPES, UnitState } from "./render/types";
import { SoundManager } from "./sound/SoundManager";
@@ -479,6 +484,21 @@ async function createClientGame(
(e) => view.setShowPatterns((e as CustomEvent<string>).detail === "true"),
);
const graphicsListenerAbort = new AbortController();
const applyGraphicsOverrides = (): void => {
const generated = generateRenderSettings(
userSettings.graphicsOverrides(),
);
const live = view.getSettings();
Object.assign(live.name, generated.name);
};
applyGraphicsOverrides();
globalThis.addEventListener(
`${USER_SETTINGS_CHANGED_EVENT}:${GRAPHICS_KEY}`,
applyGraphicsOverrides,
{ signal: graphicsListenerAbort.signal },
);
let debugGui: ReturnType<typeof createDebugGui> | null = null;
eventBus.on(ToggleRenderDebugGuiEvent, () => {
if (debugGui === null) {
@@ -524,6 +544,7 @@ async function createClientGame(
soundManager,
userSettings,
webglBuilder,
graphicsListenerAbort,
);
} catch (err) {
soundManager.dispose();
@@ -557,6 +578,7 @@ export class ClientGameRunner {
private soundManager: SoundManager,
private userSettings: UserSettings,
private webglBuilder: WebGLFrameBuilder | null = null,
private graphicsListenerAbort: AbortController | null = null,
) {
this.lastMessageTime = Date.now();
}
@@ -813,6 +835,7 @@ export class ClientGameRunner {
public stop() {
this.soundManager.dispose();
this.graphicsListenerAbort?.abort();
if (!this.isActive) return;
this.isActive = false;
+11
View File
@@ -24,6 +24,7 @@ import { EmojiTable } from "./layers/EmojiTable";
import { EventsDisplay } from "./layers/EventsDisplay";
import { GameLeftSidebar } from "./layers/GameLeftSidebar";
import { GameRightSidebar } from "./layers/GameRightSidebar";
import { GraphicsSettingsModal } from "./layers/GraphicsSettingsModal";
import { HeadsUpMessage } from "./layers/HeadsUpMessage";
import { ImmunityTimer } from "./layers/ImmunityTimer";
import { InGamePromo } from "./layers/InGamePromo";
@@ -190,6 +191,15 @@ export function createRenderer(
settingsModal.userSettings = userSettings;
settingsModal.eventBus = eventBus;
const graphicsSettingsModal = document.querySelector(
"graphics-settings-modal",
) as GraphicsSettingsModal;
if (!(graphicsSettingsModal instanceof GraphicsSettingsModal)) {
console.error("graphics settings modal not found");
}
graphicsSettingsModal.userSettings = userSettings;
graphicsSettingsModal.eventBus = eventBus;
const unitDisplay = document.querySelector("unit-display") as UnitDisplay;
if (!(unitDisplay instanceof UnitDisplay)) {
console.error("unit display not found");
@@ -309,6 +319,7 @@ export function createRenderer(
winModal,
replayPanel,
settingsModal,
graphicsSettingsModal,
teamStats,
playerPanel,
headsUpMessage,
@@ -0,0 +1,256 @@
import { html, LitElement } from "lit";
import { customElement, property, query, state } from "lit/decorators.js";
import { crazyGamesSDK } from "src/client/CrazyGamesSDK";
import { PauseGameIntentEvent } from "src/client/Transport";
import { assetUrl } from "../../../core/AssetUrls";
import { EventBus } from "../../../core/EventBus";
import { UserSettings } from "../../../core/game/UserSettings";
import { Controller } from "../../Controller";
import { translateText } from "../../Utils";
import type { GraphicsOverrides } from "../../render/gl";
import renderDefaults from "../../render/gl/render-settings.json";
const settingsIcon = assetUrl("images/SettingIconWhite.svg");
const NAME_SCALE_MIN = 0.2;
const NAME_SCALE_MAX = 1.5;
const NAME_SCALE_STEP = 0.05;
const NAME_CULL_MIN = 0;
const NAME_CULL_MAX = 0.05;
const NAME_CULL_STEP = 0.001;
export class ShowGraphicsSettingsModalEvent {
constructor(
public readonly isVisible: boolean = true,
public readonly shouldPause: boolean = false,
public readonly isPaused: boolean = false,
) {}
}
@customElement("graphics-settings-modal")
export class GraphicsSettingsModal extends LitElement implements Controller {
public eventBus: EventBus;
public userSettings: UserSettings;
@state()
private isVisible: boolean = false;
@query(".modal-overlay")
private modalOverlay!: HTMLElement;
@property({ type: Boolean })
shouldPause = false;
@property({ type: Boolean })
wasPausedWhenOpened = false;
init() {
this.eventBus.on(ShowGraphicsSettingsModalEvent, (event) => {
this.isVisible = event.isVisible;
this.shouldPause = event.shouldPause;
this.wasPausedWhenOpened = event.isPaused;
this.pauseGame(true);
this.requestUpdate();
});
}
private pauseGame(pause: boolean) {
if (this.shouldPause && !this.wasPausedWhenOpened) {
if (pause) {
crazyGamesSDK.gameplayStop();
} else {
crazyGamesSDK.gameplayStart();
}
this.eventBus.emit(new PauseGameIntentEvent(pause));
}
}
createRenderRoot() {
return this;
}
connectedCallback() {
super.connectedCallback();
window.addEventListener("click", this.handleOutsideClick, true);
window.addEventListener("keydown", this.handleKeyDown);
}
disconnectedCallback() {
window.removeEventListener("click", this.handleOutsideClick, true);
window.removeEventListener("keydown", this.handleKeyDown);
super.disconnectedCallback();
}
private handleOutsideClick = (event: MouseEvent) => {
if (
this.isVisible &&
this.modalOverlay &&
event.target === this.modalOverlay
) {
this.closeModal();
}
};
private handleKeyDown = (event: KeyboardEvent) => {
if (this.isVisible && event.key === "Escape") {
this.closeModal();
}
};
public closeModal() {
this.isVisible = false;
this.requestUpdate();
this.pauseGame(false);
}
private currentNameScale(): number {
return (
this.userSettings.graphicsOverrides().name?.nameScaleFactor ??
renderDefaults.name.nameScaleFactor
);
}
private currentNameCull(): number {
return (
this.userSettings.graphicsOverrides().name?.cullThreshold ??
renderDefaults.name.cullThreshold
);
}
private patchName(patch: Partial<GraphicsOverrides["name"]>) {
const current = this.userSettings.graphicsOverrides();
this.userSettings.setGraphicsOverrides({
...current,
name: { ...current.name, ...patch },
});
this.requestUpdate();
}
private onNameScaleChange(event: Event) {
const value = parseFloat((event.target as HTMLInputElement).value);
this.patchName({ nameScaleFactor: value });
}
private onNameCullChange(event: Event) {
const value = parseFloat((event.target as HTMLInputElement).value);
this.patchName({ cullThreshold: value });
}
private onResetClick() {
this.userSettings.setGraphicsOverrides({});
this.requestUpdate();
}
render() {
if (!this.isVisible) return null;
const nameScale = this.currentNameScale();
const nameCull = this.currentNameCull();
return html`
<div
class="modal-overlay fixed inset-0 bg-black/60 backdrop-blur-xs z-2000 flex items-center justify-center p-4"
@contextmenu=${(e: Event) => e.preventDefault()}
>
<div
class="bg-slate-800 border border-slate-600 rounded-lg max-w-md w-full max-h-[80vh] overflow-y-auto"
>
<div
class="flex items-center justify-between p-4 border-b border-slate-600"
>
<div class="flex items-center gap-2">
<img
src=${settingsIcon}
alt="graphicsSettings"
width="24"
height="24"
class="align-middle"
/>
<h2 class="text-xl font-semibold text-white">
${translateText("graphics_setting.title")}
</h2>
</div>
<button
class="text-slate-400 hover:text-white text-2xl font-bold leading-none"
@click=${this.closeModal}
>
×
</button>
</div>
<div class="p-4 flex flex-col gap-3">
<div
class="px-3 py-1 text-xs font-semibold text-slate-400 uppercase tracking-wider"
>
${translateText("graphics_setting.section_name_labels")}
</div>
<div
class="flex gap-3 items-center w-full text-left p-3 hover:bg-slate-700 rounded-sm text-white transition-colors"
>
<div class="flex-1">
<div class="font-medium">
${translateText("graphics_setting.name_scale_label")}
</div>
<input
type="range"
min=${NAME_SCALE_MIN}
max=${NAME_SCALE_MAX}
step=${NAME_SCALE_STEP}
.value=${String(nameScale)}
@input=${this.onNameScaleChange}
class="w-full border border-slate-500 rounded-lg"
/>
</div>
<div class="text-sm text-slate-400 w-12 text-right">
${nameScale.toFixed(2)}
</div>
</div>
<div
class="flex gap-3 items-center w-full text-left p-3 hover:bg-slate-700 rounded-sm text-white transition-colors"
>
<div class="flex-1">
<div class="font-medium">
${translateText("graphics_setting.name_cull_label")}
</div>
<div class="text-sm text-slate-400">
${translateText("graphics_setting.name_cull_desc")}
</div>
<input
type="range"
min=${NAME_CULL_MIN}
max=${NAME_CULL_MAX}
step=${NAME_CULL_STEP}
.value=${String(nameCull)}
@input=${this.onNameCullChange}
class="w-full border border-slate-500 rounded-lg"
/>
</div>
<div class="text-sm text-slate-400 w-12 text-right">
${nameCull.toFixed(3)}
</div>
</div>
<div class="border-t border-slate-600 pt-3 mt-4">
<button
class="flex gap-3 items-center w-full text-left p-3 hover:bg-slate-700 rounded-sm text-white transition-colors"
@click=${this.onResetClick}
>
<div class="flex-1">
<div class="font-medium">
${translateText("graphics_setting.reset_label")}
</div>
<div class="text-sm text-slate-400">
${translateText("graphics_setting.reset_desc")}
</div>
</div>
</button>
</div>
</div>
</div>
</div>
`;
}
}
+3 -3
View File
@@ -83,9 +83,9 @@ export class HeadsUpMessage extends LitElement implements Controller {
tick() {
const updates = this.game.updatesSinceLastTick();
if (updates && updates[GameUpdateType.GamePaused].length > 0) {
const pauseUpdate = updates[GameUpdateType.GamePaused][0];
this.isPaused = pauseUpdate.paused;
const pauseUpdates = updates?.[GameUpdateType.GamePaused];
if (pauseUpdates && pauseUpdates.length > 0) {
this.isPaused = pauseUpdates[pauseUpdates.length - 1].paused;
}
const showImmunityHudDuration = 10 * 10;
+34 -2
View File
@@ -16,6 +16,7 @@ import {
SetBackgroundMusicVolumeEvent,
SetSoundEffectsVolumeEvent,
} from "../../sound/Sounds";
import { ShowGraphicsSettingsModalEvent } from "./GraphicsSettingsModal";
const structureIcon = assetUrl("images/CityIconWhite.svg");
const cursorPriceIcon = assetUrl("images/CursorPriceIconWhite.svg");
const darkModeIcon = assetUrl("images/DarkModeIconWhite.svg");
@@ -104,10 +105,10 @@ export class SettingsModal extends LitElement implements Controller {
this.requestUpdate();
}
public closeModal() {
public closeModal({ keepPause = false }: { keepPause?: boolean } = {}) {
this.isVisible = false;
this.requestUpdate();
this.pauseGame(false);
if (!keepPause) this.pauseGame(false);
}
private pauseGame(pause: boolean) {
@@ -183,6 +184,17 @@ export class SettingsModal extends LitElement implements Controller {
this.closeModal();
}
private onGraphicsSettingsButtonClick() {
this.eventBus.emit(
new ShowGraphicsSettingsModalEvent(
true,
this.shouldPause,
this.wasPausedWhenOpened,
),
);
this.closeModal({ keepPause: true });
}
private onExitButtonClick() {
// redirect to the home page
window.location.href = "/";
@@ -510,6 +522,26 @@ export class SettingsModal extends LitElement implements Controller {
</div>
</button>
<button
class="flex gap-3 items-center w-full text-left p-3 hover:bg-slate-700 rounded-sm text-white transition-colors"
@click="${this.onGraphicsSettingsButtonClick}"
>
<img
src=${settingsIcon}
alt="graphicsSettings"
width="20"
height="20"
/>
<div class="flex-1">
<div class="font-medium">
${translateText("user_setting.graphics_settings_label")}
</div>
<div class="text-sm text-slate-400">
${translateText("user_setting.graphics_settings_desc")}
</div>
</div>
</button>
<div class="border-t border-slate-600 pt-3 mt-4">
<div
class="px-3 py-1 text-xs font-semibold text-slate-400 uppercase tracking-wider"
+14
View File
@@ -0,0 +1,14 @@
import { z } from "zod";
export const GraphicsOverridesSchema = z
.object({
name: z
.object({
nameScaleFactor: z.number(),
cullThreshold: z.number(),
})
.partial(),
})
.partial();
export type GraphicsOverrides = z.infer<typeof GraphicsOverridesSchema>;
+18
View File
@@ -1,3 +1,4 @@
import { GraphicsOverrides } from "./GraphicsOverrides";
import defaults from "./render-settings.json";
export interface RenderSettings {
@@ -260,6 +261,23 @@ export function createRenderSettings(): RenderSettings {
return JSON.parse(JSON.stringify(defaults)) as RenderSettings;
}
/**
* Generate a fresh RenderSettings by layering user overrides on top of the
* render-settings.json defaults. Pure does not mutate any input.
*/
export function generateRenderSettings(
overrides: GraphicsOverrides,
): RenderSettings {
const settings = createRenderSettings();
if (overrides.name?.nameScaleFactor !== undefined) {
settings.name.nameScaleFactor = overrides.name.nameScaleFactor;
}
if (overrides.name?.cullThreshold !== undefined) {
settings.name.cullThreshold = overrides.name.cullThreshold;
}
return settings;
}
/** Dump current settings to a downloadable JSON file. */
export function dumpSettings(settings: RenderSettings): void {
const json = JSON.stringify(settings, null, 2);
+7 -1
View File
@@ -9,8 +9,14 @@ export type {
RadialMenuSelectEvent,
} from "./Events";
export { GameView } from "./GameView";
export { GraphicsOverridesSchema } from "./GraphicsOverrides";
export type { GraphicsOverrides } from "./GraphicsOverrides";
export type { SpawnCenter } from "./passes/SpawnOverlayPass";
export { createRenderSettings, dumpSettings } from "./RenderSettings";
export {
createRenderSettings,
dumpSettings,
generateRenderSettings,
} from "./RenderSettings";
export type { RenderSettings } from "./RenderSettings";
export { deepAssign, deepDiff } from "./SettingsUtils";
export { buildTerrainRGBA, getPaletteSize } from "./utils/ColorUtils";
+22
View File
@@ -1,3 +1,7 @@
import {
GraphicsOverrides,
GraphicsOverridesSchema,
} from "../../client/render/gl/GraphicsOverrides";
import { Cosmetics } from "../CosmeticSchemas";
import { PlayerPattern } from "../Schemas";
@@ -53,6 +57,7 @@ export const COLOR_KEY = "settings.territoryColor";
export const DARK_MODE_KEY = "settings.darkMode";
export const PERFORMANCE_OVERLAY_KEY = "settings.performanceOverlay";
export const KEYBINDS_KEY = "settings.keybinds";
export const GRAPHICS_KEY = "settings.graphics";
export class UserSettings {
private static cache = new Map<string, string | null>();
@@ -354,6 +359,23 @@ export class UserSettings {
this.setFloat("settings.attackRatio", value);
}
// Returns {} if missing, unparseable, or fails schema validation.
graphicsOverrides(): GraphicsOverrides {
const raw = this.getString(GRAPHICS_KEY, "");
if (!raw) return {};
try {
const parsed = GraphicsOverridesSchema.safeParse(JSON.parse(raw));
if (parsed.success) return parsed.data;
} catch {
// fall through
}
return {};
}
setGraphicsOverrides(value: GraphicsOverrides): void {
this.setString(GRAPHICS_KEY, JSON.stringify(value));
}
// In case localStorage was manually edited to be invalid, return an empty object
parsedUserKeybinds(): Record<string, any> {
const raw = this.getString(KEYBINDS_KEY, "{}");