diff --git a/index.html b/index.html index d2e4e9270d..4af07ad2d6 100644 --- a/index.html +++ b/index.html @@ -341,6 +341,7 @@ + diff --git a/resources/lang/en.json b/resources/lang/en.json index a9eeffd3eb..da3ae5706e 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -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 (x1–x100)", "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}", diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index 3077065b30..e54ea553b2 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -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).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 | 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; diff --git a/src/client/hud/GameRenderer.ts b/src/client/hud/GameRenderer.ts index eb9cb39447..3c08c477c9 100644 --- a/src/client/hud/GameRenderer.ts +++ b/src/client/hud/GameRenderer.ts @@ -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, diff --git a/src/client/hud/layers/GraphicsSettingsModal.ts b/src/client/hud/layers/GraphicsSettingsModal.ts new file mode 100644 index 0000000000..969691edb8 --- /dev/null +++ b/src/client/hud/layers/GraphicsSettingsModal.ts @@ -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) { + 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` + + `; + } +} diff --git a/src/client/hud/layers/HeadsUpMessage.ts b/src/client/hud/layers/HeadsUpMessage.ts index 7f9b95532d..d7970ea320 100644 --- a/src/client/hud/layers/HeadsUpMessage.ts +++ b/src/client/hud/layers/HeadsUpMessage.ts @@ -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; diff --git a/src/client/hud/layers/SettingsModal.ts b/src/client/hud/layers/SettingsModal.ts index bd9bf48210..7b1be3d8aa 100644 --- a/src/client/hud/layers/SettingsModal.ts +++ b/src/client/hud/layers/SettingsModal.ts @@ -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 { + +
; diff --git a/src/client/render/gl/RenderSettings.ts b/src/client/render/gl/RenderSettings.ts index 628f0ea322..4d2af1bedb 100644 --- a/src/client/render/gl/RenderSettings.ts +++ b/src/client/render/gl/RenderSettings.ts @@ -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); diff --git a/src/client/render/gl/index.ts b/src/client/render/gl/index.ts index 3f30aab33e..45c8ed4971 100644 --- a/src/client/render/gl/index.ts +++ b/src/client/render/gl/index.ts @@ -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"; diff --git a/src/core/game/UserSettings.ts b/src/core/game/UserSettings.ts index ffdcc07287..133747b172 100644 --- a/src/core/game/UserSettings.ts +++ b/src/core/game/UserSettings.ts @@ -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(); @@ -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 { const raw = this.getString(KEYBINDS_KEY, "{}");