Files
OpenFrontIO/src/client/hud/layers/SettingsModal.ts
T
Evan 20bc311caf 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
2026-05-28 13:06:43 -07:00

620 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 {
AlternateViewEvent,
RefreshGraphicsEvent,
ToggleRenderDebugGuiEvent,
} from "../../InputHandler";
import { translateText } from "../../Utils";
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");
const emojiIcon = assetUrl("images/EmojiIconWhite.svg");
const exitIcon = assetUrl("images/ExitIconWhite.svg");
const explosionIcon = assetUrl("images/ExplosionIconWhite.svg");
const mouseIcon = assetUrl("images/MouseIconWhite.svg");
const ninjaIcon = assetUrl("images/NinjaIconWhite.svg");
const settingsIcon = assetUrl("images/SettingIconWhite.svg");
const sirenIcon = assetUrl("images/SirenIconWhite.svg");
const swordIcon = assetUrl("images/SwordIconWhite.svg");
const treeIcon = assetUrl("images/TreeIconWhite.svg");
const musicIcon = assetUrl("images/music.svg");
export class ShowSettingsModalEvent {
constructor(
public readonly isVisible: boolean = true,
public readonly shouldPause: boolean = false,
public readonly isPaused: boolean = false,
) {}
}
@customElement("settings-modal")
export class SettingsModal extends LitElement implements Controller {
public eventBus: EventBus;
public userSettings: UserSettings;
@state()
private isVisible: boolean = false;
@state()
private alternateView: boolean = false;
@query(".modal-overlay")
private modalOverlay!: HTMLElement;
@property({ type: Boolean })
shouldPause = false;
@property({ type: Boolean })
wasPausedWhenOpened = false;
init() {
this.eventBus.on(ShowSettingsModalEvent, (event) => {
this.isVisible = event.isVisible;
this.shouldPause = event.shouldPause;
this.wasPausedWhenOpened = event.isPaused;
this.pauseGame(true);
});
}
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 openModal() {
this.isVisible = true;
this.requestUpdate();
}
public closeModal({ keepPause = false }: { keepPause?: boolean } = {}) {
this.isVisible = false;
this.requestUpdate();
if (!keepPause) this.pauseGame(false);
}
private pauseGame(pause: boolean) {
if (this.shouldPause && !this.wasPausedWhenOpened) {
if (pause) {
crazyGamesSDK.gameplayStop();
} else {
crazyGamesSDK.gameplayStart();
}
this.eventBus.emit(new PauseGameIntentEvent(pause));
}
}
private onTerrainButtonClick() {
this.alternateView = !this.alternateView;
this.eventBus.emit(new AlternateViewEvent(this.alternateView));
this.requestUpdate();
}
private onToggleEmojisButtonClick() {
this.userSettings.toggleEmojis();
this.requestUpdate();
}
private onToggleStructureSpritesButtonClick() {
this.userSettings.toggleStructureSprites();
this.requestUpdate();
}
private onToggleSpecialEffectsButtonClick() {
this.userSettings.toggleFxLayer();
this.requestUpdate();
}
private onToggleAlertFrameButtonClick() {
this.userSettings.toggleAlertFrame();
this.requestUpdate();
}
private onToggleDarkModeButtonClick() {
this.userSettings.toggleDarkMode();
this.eventBus.emit(new RefreshGraphicsEvent());
this.requestUpdate();
}
private onToggleRandomNameModeButtonClick() {
this.userSettings.toggleRandomName();
this.requestUpdate();
}
private onToggleLeftClickOpensMenu() {
this.userSettings.toggleLeftClickOpenMenu();
this.requestUpdate();
}
private onToggleCursorCostLabelButtonClick() {
this.userSettings.toggleCursorCostLabel();
this.requestUpdate();
}
private onToggleAttackingTroopsOverlayButtonClick() {
this.userSettings.toggleAttackingTroopsOverlay();
this.requestUpdate();
}
private onTogglePerformanceOverlayButtonClick() {
this.userSettings.togglePerformanceOverlay();
this.requestUpdate();
}
private onRenderDebugGuiButtonClick() {
this.eventBus.emit(new ToggleRenderDebugGuiEvent());
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 = "/";
}
private onVolumeChange(event: Event) {
const volume = parseFloat((event.target as HTMLInputElement).value) / 100;
this.userSettings.setBackgroundMusicVolume(volume);
this.eventBus.emit(new SetBackgroundMusicVolumeEvent(volume));
this.requestUpdate();
}
private onSoundEffectsVolumeChange(event: Event) {
const volume = parseFloat((event.target as HTMLInputElement).value) / 100;
this.userSettings.setSoundEffectsVolume(volume);
this.eventBus.emit(new SetSoundEffectsVolumeEvent(volume));
this.requestUpdate();
}
render() {
if (!this.isVisible) {
return null;
}
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="settings"
width="24"
height="24"
class="align-middle"
/>
<h2 class="text-xl font-semibold text-white">
${translateText("user_setting.tab_basic")}
</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="flex gap-3 items-center w-full text-left p-3 hover:bg-slate-700 rounded-sm text-white transition-colors"
>
<img src=${musicIcon} alt="musicIcon" width="20" height="20" />
<div class="flex-1">
<div class="font-medium">
${translateText("user_setting.background_music_volume")}
</div>
<input
type="range"
min="0"
max="100"
.value=${this.userSettings.backgroundMusicVolume() * 100}
@input=${this.onVolumeChange}
class="w-full border border-slate-500 rounded-lg"
/>
</div>
<div class="text-sm text-slate-400">
${Math.round(this.userSettings.backgroundMusicVolume() * 100)}%
</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"
>
<img
src=${musicIcon}
alt="soundEffectsIcon"
width="20"
height="20"
/>
<div class="flex-1">
<div class="font-medium">
${translateText("user_setting.sound_effects_volume")}
</div>
<input
type="range"
min="0"
max="100"
.value=${this.userSettings.soundEffectsVolume() * 100}
@input=${this.onSoundEffectsVolumeChange}
class="w-full border border-slate-500 rounded-lg"
/>
</div>
<div class="text-sm text-slate-400">
${Math.round(this.userSettings.soundEffectsVolume() * 100)}%
</div>
</div>
<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.onTerrainButtonClick}"
>
<img src=${treeIcon} alt="treeIcon" width="20" height="20" />
<div class="flex-1">
<div class="font-medium">
${translateText("user_setting.toggle_terrain")}
</div>
<div class="text-sm text-slate-400">
${translateText("user_setting.toggle_view_desc")}
</div>
</div>
<div class="text-sm text-slate-400">
${this.alternateView
? translateText("user_setting.on")
: translateText("user_setting.off")}
</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.onToggleEmojisButtonClick}"
>
<img src=${emojiIcon} alt="emojiIcon" width="20" height="20" />
<div class="flex-1">
<div class="font-medium">
${translateText("user_setting.emojis_label")}
</div>
<div class="text-sm text-slate-400">
${translateText("user_setting.emojis_desc")}
</div>
</div>
<div class="text-sm text-slate-400">
${this.userSettings.emojis()
? translateText("user_setting.on")
: translateText("user_setting.off")}
</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.onToggleDarkModeButtonClick}"
>
<img
src=${darkModeIcon}
alt="darkModeIcon"
width="20"
height="20"
/>
<div class="flex-1">
<div class="font-medium">
${translateText("user_setting.dark_mode_label")}
</div>
<div class="text-sm text-slate-400">
${translateText("user_setting.dark_mode_desc")}
</div>
</div>
<div class="text-sm text-slate-400">
${this.userSettings.darkMode()
? translateText("user_setting.on")
: translateText("user_setting.off")}
</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.onToggleSpecialEffectsButtonClick}"
>
<img
src=${explosionIcon}
alt="specialEffects"
width="20"
height="20"
/>
<div class="flex-1">
<div class="font-medium">
${translateText("user_setting.special_effects_label")}
</div>
<div class="text-sm text-slate-400">
${translateText("user_setting.special_effects_desc")}
</div>
</div>
<div class="text-sm text-slate-400">
${this.userSettings.fxLayer()
? translateText("user_setting.on")
: translateText("user_setting.off")}
</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.onToggleAlertFrameButtonClick}"
>
<img src=${sirenIcon} alt="alertFrame" width="20" height="20" />
<div class="flex-1">
<div class="font-medium">
${translateText("user_setting.alert_frame_label")}
</div>
<div class="text-sm text-slate-400">
${translateText("user_setting.alert_frame_desc")}
</div>
</div>
<div class="text-sm text-slate-400">
${this.userSettings.alertFrame()
? translateText("user_setting.on")
: translateText("user_setting.off")}
</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.onToggleStructureSpritesButtonClick}"
>
<img
src=${structureIcon}
alt="structureSprites"
width="20"
height="20"
/>
<div class="flex-1">
<div class="font-medium">
${translateText("user_setting.structure_sprites_label")}
</div>
<div class="text-sm text-slate-400">
${translateText("user_setting.structure_sprites_desc")}
</div>
</div>
<div class="text-sm text-slate-400">
${this.userSettings.structureSprites()
? translateText("user_setting.on")
: translateText("user_setting.off")}
</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.onToggleAttackingTroopsOverlayButtonClick}"
>
<img src=${swordIcon} alt="swordIcon" width="20" height="20" />
<div class="flex-1">
<div class="font-medium">
${translateText(
"user_setting.attacking_troops_overlay_label",
)}
</div>
<div class="text-sm text-slate-400">
${translateText("user_setting.attacking_troops_overlay_desc")}
</div>
</div>
<div class="text-sm text-slate-400">
${this.userSettings.attackingTroopsOverlay()
? translateText("user_setting.on")
: translateText("user_setting.off")}
</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.onToggleCursorCostLabelButtonClick}"
>
<img
src=${cursorPriceIcon}
alt="cursorCostLabel"
width="20"
height="20"
/>
<div class="flex-1">
<div class="font-medium">
${translateText("user_setting.cursor_cost_label_label")}
</div>
<div class="text-sm text-slate-400">
${translateText("user_setting.cursor_cost_label_desc")}
</div>
</div>
<div class="text-sm text-slate-400">
${this.userSettings.cursorCostLabel()
? translateText("user_setting.on")
: translateText("user_setting.off")}
</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.onToggleRandomNameModeButtonClick}"
>
<img src=${ninjaIcon} alt="ninjaIcon" width="20" height="20" />
<div class="flex-1">
<div class="font-medium">
${translateText("user_setting.anonymous_names_label")}
</div>
<div class="text-sm text-slate-400">
${translateText("user_setting.anonymous_names_desc")}
</div>
</div>
<div class="text-sm text-slate-400">
${this.userSettings.anonymousNames()
? translateText("user_setting.on")
: translateText("user_setting.off")}
</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.onToggleLeftClickOpensMenu}"
>
<img src=${mouseIcon} alt="mouseIcon" width="20" height="20" />
<div class="flex-1">
<div class="font-medium">
${translateText("user_setting.left_click_menu")}
</div>
<div class="text-sm text-slate-400">
${translateText("user_setting.left_click_desc")}
</div>
</div>
<div class="text-sm text-slate-400">
${this.userSettings.leftClickOpensMenu()
? translateText("user_setting.on")
: translateText("user_setting.off")}
</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"
>
${translateText("user_setting.development_only")}
</div>
<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.onTogglePerformanceOverlayButtonClick}"
>
<img
src=${settingsIcon}
alt="performanceIcon"
width="20"
height="20"
/>
<div class="flex-1">
<div class="font-medium">
${translateText("user_setting.performance_overlay_label")}
</div>
<div class="text-sm text-slate-400">
${translateText("user_setting.performance_overlay_desc")}
</div>
</div>
<div class="text-sm text-slate-400">
${this.userSettings.performanceOverlay()
? translateText("user_setting.on")
: translateText("user_setting.off")}
</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.onRenderDebugGuiButtonClick}"
>
<img
src=${settingsIcon}
alt="renderDebugGui"
width="20"
height="20"
/>
<div class="flex-1">
<div class="font-medium">
${translateText("user_setting.render_debug_gui")}
</div>
<div class="text-sm text-slate-400">
${translateText("user_setting.render_debug_gui_desc")}
</div>
</div>
</button>
</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-red-600/20 rounded-sm text-red-400 transition-colors"
@click="${this.onExitButtonClick}"
>
<img src=${exitIcon} alt="exitIcon" width="20" height="20" />
<div class="flex-1">
<div class="font-medium">
${translateText("user_setting.exit_game_label")}
</div>
<div class="text-sm text-slate-400">
${translateText("user_setting.exit_game_info")}
</div>
</div>
</button>
</div>
</div>
</div>
</div>
`;
}
}