diff --git a/resources/lang/en.json b/resources/lang/en.json index 0d224b40bd..e08d16aabd 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -771,6 +771,7 @@ "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", + "layer_alpha_label": "Opacity", "layer_nukeable": "Nukeable", "layer_placement_land": "Land layer", "layer_placement_water": "Water layer", diff --git a/src/client/controllers/MapLayerController.ts b/src/client/controllers/MapLayerController.ts index e1236c67a5..f224f3b036 100644 --- a/src/client/controllers/MapLayerController.ts +++ b/src/client/controllers/MapLayerController.ts @@ -34,6 +34,7 @@ export class MapLayerController implements Controller { // Images already loaded (e.g. from cache) — set up immediately. this.view.setMapLayers(this.gameMap.layers, this.gameMap.layerImages); this.applyVisibility(); + this.applyAlpha(); } else { // Layer images loaded off the critical path. Start fetching now; // the renderer tolerates missing layers (warn + skip) until they @@ -48,6 +49,7 @@ export class MapLayerController implements Controller { if (!this.abortSignal.aborted) { this.view.setMapLayers(this.gameMap.layers!, images); this.applyVisibility(); + this.applyAlpha(); } }) .catch((e) => @@ -66,4 +68,18 @@ export class MapLayerController implements Controller { } } } + + private applyAlpha() { + const overrides = this.userSettings.graphicsOverrides(); + if (!this.gameMap.layers) return; + for (const layer of this.gameMap.layers) { + const alpha = overrides.mapLayerAlpha?.[layer.id]; + if (alpha !== undefined) { + this.view.setLayerAlpha(layer.id, alpha); + } else if (layer.alpha !== undefined) { + // Apply manifest default when no user override exists. + this.view.setLayerAlpha(layer.id, layer.alpha); + } + } + } } diff --git a/src/client/hud/GameRenderer.ts b/src/client/hud/GameRenderer.ts index c1a5d558d0..afdc8f51c9 100644 --- a/src/client/hud/GameRenderer.ts +++ b/src/client/hud/GameRenderer.ts @@ -203,6 +203,9 @@ export function createRenderer( graphicsSettingsModal.onLayerVisibilityChange = (layerId, visible) => { view.setLayerVisible(layerId, visible); }; + graphicsSettingsModal.onLayerAlphaChange = (layerId, alpha) => { + view.setLayerAlpha(layerId, alpha); + }; const unitDisplay = document.querySelector("unit-display") as UnitDisplay; if (!(unitDisplay instanceof UnitDisplay)) { diff --git a/src/client/hud/layers/GraphicsSettingsModal.ts b/src/client/hud/layers/GraphicsSettingsModal.ts index ba562ced2d..6a1fedf1ba 100644 --- a/src/client/hud/layers/GraphicsSettingsModal.ts +++ b/src/client/hud/layers/GraphicsSettingsModal.ts @@ -161,6 +161,9 @@ export class GraphicsSettingsModal extends LitElement implements Controller { public onLayerVisibilityChange: | ((layerId: string, visible: boolean) => void) | null = null; + /** Callback to set layer alpha on the renderer. */ + public onLayerAlphaChange: ((layerId: string, alpha: number) => void) | null = + null; @state() private isVisible: boolean = false; @@ -373,6 +376,41 @@ export class GraphicsSettingsModal extends LitElement implements Controller { for (const layer of this.mapLayers) { this.onLayerVisibilityChange?.(layer.id, this.isLayerVisible(layer.id)); } + this.syncLayerAlpha(); + } + + // ---- Map-layer alpha ---- + + private getLayerAlpha(layerId: string, manifestDefault?: number): number { + const overrides = this.userSettings.graphicsOverrides(); + const alpha = overrides.mapLayerAlpha?.[layerId]; + if (alpha !== undefined) return alpha; + return manifestDefault ?? 1; + } + + private onLayerAlphaSliderChange(layerId: string, event: Event) { + const alpha = parseFloat((event.target as HTMLInputElement).value); + const current = this.userSettings.graphicsOverrides(); + const currentAlpha = current.mapLayerAlpha ?? {}; + this.userSettings.setGraphicsOverrides({ + ...current, + mapLayerAlpha: { ...currentAlpha, [layerId]: alpha }, + }); + this.onLayerAlphaChange?.(layerId, alpha); + this.requestUpdate(); + } + + /** + * Re-apply layer alpha from current overrides to the renderer. + * Called after reset or preset import so the WebGL passes stay in sync. + */ + private syncLayerAlpha() { + for (const layer of this.mapLayers) { + this.onLayerAlphaChange?.( + layer.id, + this.getLayerAlpha(layer.id, layer.alpha), + ); + } } private currentHighlightFill(): number { @@ -1509,29 +1547,66 @@ export class GraphicsSettingsModal extends LitElement implements Controller { ${this.mapLayers.map( (layer) => html` - + + ${this.isLayerVisible(layer.id) + ? html` +
+
+
+ ${translateText( + "graphics_setting.layer_alpha_label", + )} +
+ + this.onLayerAlphaSliderChange(layer.id, e)} + class="w-full border border-slate-500 rounded-lg" + /> +
+
+ ${this.getLayerAlpha(layer.id, layer.alpha).toFixed( + 2, + )} +
+
+ ` + : ""} + `, )} ` diff --git a/src/client/render/gl/GraphicsOverrides.ts b/src/client/render/gl/GraphicsOverrides.ts index 0187dd1f8f..7fa60f9264 100644 --- a/src/client/render/gl/GraphicsOverrides.ts +++ b/src/client/render/gl/GraphicsOverrides.ts @@ -105,6 +105,8 @@ export const GraphicsOverridesSchema = z .partial(), /** Per-layer visibility toggles keyed by layer id. */ mapLayerVisibility: z.record(z.string(), z.boolean()), + /** Per-layer alpha (opacity 0–1) keyed by layer id. */ + mapLayerAlpha: z.record(z.string(), z.number().min(0).max(1)), }) .partial(); diff --git a/src/client/render/gl/MapRenderer.ts b/src/client/render/gl/MapRenderer.ts index 5b6c95c630..75f719ebd2 100644 --- a/src/client/render/gl/MapRenderer.ts +++ b/src/client/render/gl/MapRenderer.ts @@ -43,6 +43,7 @@ export class MapRenderer { private storedLayerImages: Map = new Map(); // Layer state that survives context loss (GPU textures do not). private layerVisibility = new Map(); + private layerAlpha = new Map(); private layerDestroyedMasks = new Map(); /** @@ -119,6 +120,10 @@ export class MapRenderer { for (const [id, vis] of this.layerVisibility) { this.renderer?.setLayerVisible(id, vis); } + // Re-apply alpha overrides. + for (const [id, alpha] of this.layerAlpha) { + this.renderer?.setLayerAlpha(id, alpha); + } // Re-apply destroyed masks. for (const [id, mask] of this.layerDestroyedMasks) { this.renderer?.setLayerDestroyedMask(id, mask); @@ -290,6 +295,12 @@ export class MapRenderer { this.renderer?.setLayerVisible(layerId, visible); } + /** Set the alpha multiplier for a single map layer (0–1). */ + setLayerAlpha(layerId: string, alpha: number): void { + this.layerAlpha.set(layerId, alpha); + this.renderer?.setLayerAlpha(layerId, alpha); + } + /** Batch-mark tiles as destroyed for a nukeable layer. */ markLayerTilesDestroyed(layerId: string, tileIndices: number[]): void { // Accumulate into the CPU-side mask for context-restore. diff --git a/src/client/render/gl/Renderer.ts b/src/client/render/gl/Renderer.ts index d46668b5de..ffae574f1f 100644 --- a/src/client/render/gl/Renderer.ts +++ b/src/client/render/gl/Renderer.ts @@ -1425,6 +1425,11 @@ export class GPURenderer { this.mapLayerPasses.get(layerId)?.setVisible(visible); } + /** Set the alpha multiplier for a single layer (0–1). */ + setLayerAlpha(layerId: string, alpha: number): void { + this.mapLayerPasses.get(layerId)?.setAlpha(alpha); + } + /** * Mark tiles as destroyed for a nukeable layer. Called when a nuke * detonates; batches all tile updates into a single GPU upload. diff --git a/src/client/render/gl/passes/MapLayerPass.ts b/src/client/render/gl/passes/MapLayerPass.ts index 39d4db2228..19a0d8f033 100644 --- a/src/client/render/gl/passes/MapLayerPass.ts +++ b/src/client/render/gl/passes/MapLayerPass.ts @@ -25,6 +25,7 @@ export class MapLayerPass { private uPlacement: WebGLUniformLocation; private uNukeable: WebGLUniformLocation; private uVisible: WebGLUniformLocation; + private uAlpha: WebGLUniformLocation; private uLayerTex: WebGLUniformLocation; private uTerrainBytes: WebGLUniformLocation; private uDestroyedMask: WebGLUniformLocation; @@ -32,6 +33,7 @@ export class MapLayerPass { /** CPU-side copy of the destroyed mask for context-restore re-uploads. */ private destroyedData: Uint8Array; private _visible = true; + private _alpha = 1.0; constructor( private gl: WebGL2RenderingContext, @@ -55,6 +57,7 @@ export class MapLayerPass { this.uPlacement = gl.getUniformLocation(this.program, "uPlacement")!; this.uNukeable = gl.getUniformLocation(this.program, "uNukeable")!; this.uVisible = gl.getUniformLocation(this.program, "uVisible")!; + this.uAlpha = gl.getUniformLocation(this.program, "uAlpha")!; this.uLayerTex = gl.getUniformLocation(this.program, "uLayerTex")!; this.uTerrainBytes = gl.getUniformLocation(this.program, "uTerrainBytes")!; this.uDestroyedMask = gl.getUniformLocation( @@ -99,6 +102,11 @@ export class MapLayerPass { this._visible = visible; } + /** Set the alpha multiplier for this layer (0–1). */ + setAlpha(alpha: number): void { + this._alpha = Math.max(0, Math.min(1, alpha)); + } + /** * Upload a per-tile destroyed mask. Each element is 0 (intact) or 1 * (destroyed by a nuke). Only meaningful for nukeable layers. @@ -253,6 +261,7 @@ export class MapLayerPass { gl.uniform1i(this.uPlacement, this.placement); gl.uniform1i(this.uNukeable, this.nukeable ? 1 : 0); gl.uniform1f(this.uVisible, this._visible ? 1.0 : 0.0); + gl.uniform1f(this.uAlpha, this._alpha); gl.bindVertexArray(this.vao); gl.drawArrays(gl.TRIANGLES, 0, 6); diff --git a/src/client/render/gl/shaders/map-layer/layer.frag.glsl b/src/client/render/gl/shaders/map-layer/layer.frag.glsl index 28fda968fa..d02c849cd3 100644 --- a/src/client/render/gl/shaders/map-layer/layer.frag.glsl +++ b/src/client/render/gl/shaders/map-layer/layer.frag.glsl @@ -21,6 +21,9 @@ uniform int uNukeable; // 0.0 = hidden (user toggle), 1.0 = visible. uniform float uVisible; +// Per-layer alpha multiplier (0–1). Manifest default × user slider. +uniform float uAlpha; + in vec2 vUV; out vec4 fragColor; @@ -51,5 +54,5 @@ void main() { vec4 layer = texture(uLayerTex, vUV); if (layer.a < 0.01) discard; - fragColor = layer; + fragColor = vec4(layer.rgb, layer.a * uAlpha); } diff --git a/src/core/game/TerrainMapLoader.ts b/src/core/game/TerrainMapLoader.ts index 5734ece362..248a903cb8 100644 --- a/src/core/game/TerrainMapLoader.ts +++ b/src/core/game/TerrainMapLoader.ts @@ -46,6 +46,11 @@ export interface MapLayer { placement: LayerPlacement; /** If true, the layer is permanently destroyed in nuke impact radii. */ nukeable?: boolean; + /** + * Default opacity for this layer (0–1). Used as the initial value for the + * player's layer-alpha slider. Omit to default to 1 (fully opaque). + */ + alpha?: number; } export interface Nation { @@ -123,7 +128,7 @@ export async function loadTerrainMap( const layers = manifest.layers; - // Validate layer placements at game start. + // Validate layer placements and alpha at game start. if (layers) { for (const layer of layers) { if (layer.placement !== "land" && layer.placement !== "water") { @@ -131,6 +136,14 @@ export async function loadTerrainMap( `Map ${map}: layer "${layer.id}" has invalid placement "${layer.placement}" (must be "land" or "water")`, ); } + if ( + layer.alpha !== undefined && + (!Number.isFinite(layer.alpha) || layer.alpha < 0 || layer.alpha > 1) + ) { + throw new Error( + `Map ${map}: layer "${layer.id}" has invalid alpha ${layer.alpha} (must be a finite number between 0 and 1)`, + ); + } } } diff --git a/tests/MapLayers.test.ts b/tests/MapLayers.test.ts index efcabc3f10..a263eae121 100644 --- a/tests/MapLayers.test.ts +++ b/tests/MapLayers.test.ts @@ -1,6 +1,11 @@ import { describe, expect, test } from "vitest"; import { GraphicsOverridesSchema } from "../src/client/render/gl/GraphicsOverrides"; -import type { MapLayer } from "../src/core/game/TerrainMapLoader"; +import { GameMapSize, GameMapType } from "../src/core/game/Game"; +import type { GameMapLoader, MapData } from "../src/core/game/GameMapLoader"; +import { + loadTerrainMap, + type MapLayer, +} from "../src/core/game/TerrainMapLoader"; import { validateLayer } from "./util/layerValidation"; describe("Map layer feature", () => { @@ -29,6 +34,44 @@ describe("Map layer feature", () => { }).success, ).toBe(false); }); + + test("accepts mapLayerAlpha overrides", () => { + const cases = [ + { mapLayerAlpha: {} }, + { mapLayerAlpha: { forests: 0.7 } }, + { mapLayerAlpha: { forests: 0, deserts: 1 } }, + { mapLayerAlpha: { rivers: 0.5, coasts: 0.3 } }, + ]; + for (const c of cases) { + expect(GraphicsOverridesSchema.safeParse(c).success).toBe(true); + } + }); + + test("rejects out-of-range mapLayerAlpha values", () => { + expect( + GraphicsOverridesSchema.safeParse({ + mapLayerAlpha: { forests: -0.1 }, + }).success, + ).toBe(false); + expect( + GraphicsOverridesSchema.safeParse({ + mapLayerAlpha: { forests: 1.1 }, + }).success, + ).toBe(false); + }); + + test("rejects invalid mapLayerAlpha value types", () => { + expect( + GraphicsOverridesSchema.safeParse({ + mapLayerAlpha: { forests: "0.7" }, + }).success, + ).toBe(false); + expect( + GraphicsOverridesSchema.safeParse({ + mapLayerAlpha: { forests: true }, + }).success, + ).toBe(false); + }); }); describe("MapLayer type", () => { @@ -51,6 +94,23 @@ describe("Map layer feature", () => { expect(layer.nukeable).toBeUndefined(); }); + test("layer with alpha stores the value", () => { + const layer: MapLayer = { + id: "rivers", + placement: "water", + alpha: 0.6, + }; + expect(layer.alpha).toBe(0.6); + }); + + test("layer without alpha defaults to undefined", () => { + const layer: MapLayer = { + id: "deserts", + placement: "water", + }; + expect(layer.alpha).toBeUndefined(); + }); + test("placement must be land or water", () => { const landLayer: MapLayer = { id: "a", placement: "land" }; const waterLayer: MapLayer = { id: "b", placement: "water" }; @@ -182,5 +242,174 @@ describe("Map layer feature", () => { validateLayer({ id: "b", placement: "water" }, 0, "test", seen2), ).toHaveLength(0); }); + + test("valid alpha value is accepted", () => { + const seen = new Set(); + expect( + validateLayer( + { id: "a", placement: "land", alpha: 0.5 }, + 0, + "test", + seen, + ), + ).toHaveLength(0); + }); + + test("alpha boundary values are accepted", () => { + const seen1 = new Set(); + expect( + validateLayer( + { id: "a", placement: "land", alpha: 0 }, + 0, + "test", + seen1, + ), + ).toHaveLength(0); + const seen2 = new Set(); + expect( + validateLayer( + { id: "b", placement: "land", alpha: 1 }, + 0, + "test", + seen2, + ), + ).toHaveLength(0); + }); + + test("negative alpha is rejected", () => { + const seen = new Set(); + const errors = validateLayer( + { id: "a", placement: "land", alpha: -0.1 }, + 0, + "test", + seen, + ); + expect(errors.some((e) => e.includes("between 0 and 1"))).toBe(true); + }); + + test("alpha greater than 1 is rejected", () => { + const seen = new Set(); + const errors = validateLayer( + { id: "a", placement: "land", alpha: 1.5 }, + 0, + "test", + seen, + ); + expect(errors.some((e) => e.includes("between 0 and 1"))).toBe(true); + }); + + test("non-numeric alpha is rejected", () => { + const seen = new Set(); + const errors = validateLayer( + { id: "a", placement: "land", alpha: "0.5" }, + 0, + "test", + seen, + ); + expect(errors.some((e) => e.includes("must be a finite number"))).toBe( + true, + ); + }); + + test("NaN alpha is rejected", () => { + const seen = new Set(); + const errors = validateLayer( + { id: "a", placement: "land", alpha: NaN }, + 0, + "test", + seen, + ); + expect(errors.some((e) => e.includes("must be a finite number"))).toBe( + true, + ); + }); + + test("undefined alpha is accepted", () => { + const seen = new Set(); + expect( + validateLayer({ id: "a", placement: "land" }, 0, "test", seen), + ).toHaveLength(0); + }); + }); + + describe("loadTerrainMap alpha validation", () => { + function makeLoader( + layers: MapLayer[], + width = 2, + height = 2, + ): GameMapLoader { + const bin = new Uint8Array(width * height); + // Set one tile as land (bit 7 = 1). + bin[0] = 0x80; + const manifest = { + name: "test", + map: { width, height, num_land_tiles: 1 }, + map4x: { width, height, num_land_tiles: 1 }, + map16x: { width, height, num_land_tiles: 1 }, + nations: [], + layers, + }; + const mapData: MapData = { + mapBin: () => Promise.resolve(bin), + map4xBin: () => Promise.resolve(bin), + map16xBin: () => Promise.resolve(bin), + manifest: () => Promise.resolve(manifest as never), + webpPath: "", + layerPng: () => + Promise.resolve(new ImageData(1, 1) as unknown as ImageBitmap), + }; + return { + getMapData: () => mapData, + }; + } + + test("throws on alpha below 0", async () => { + const loader = makeLoader([ + { id: "bad", placement: "land", alpha: -0.5 }, + ]); + await expect( + loadTerrainMap(GameMapType.World, GameMapSize.Normal, loader, false), + ).rejects.toThrow("invalid alpha"); + }); + + test("throws on alpha above 1", async () => { + const loader = makeLoader([{ id: "bad", placement: "land", alpha: 1.5 }]); + await expect( + loadTerrainMap(GameMapType.World, GameMapSize.Normal, loader, false), + ).rejects.toThrow("invalid alpha"); + }); + + test("throws on NaN alpha", async () => { + const loader = makeLoader([{ id: "bad", placement: "land", alpha: NaN }]); + await expect( + loadTerrainMap(GameMapType.World, GameMapSize.Normal, loader, false), + ).rejects.toThrow("invalid alpha"); + }); + + test("accepts valid alpha values", async () => { + const loader = makeLoader([ + { id: "good", placement: "land", alpha: 0 }, + { id: "good2", placement: "water", alpha: 0.7 }, + { id: "good3", placement: "land", alpha: 1 }, + ]); + const data = await loadTerrainMap( + GameMapType.World, + GameMapSize.Normal, + loader, + false, + ); + expect(data.layers).toHaveLength(3); + }); + + test("accepts layers without alpha (undefined)", async () => { + const loader = makeLoader([{ id: "noalpha", placement: "land" }]); + const data = await loadTerrainMap( + GameMapType.Europe, + GameMapSize.Normal, + loader, + false, + ); + expect(data.layers).toHaveLength(1); + }); }); }); diff --git a/tests/util/layerValidation.ts b/tests/util/layerValidation.ts index 1708b58351..69e235e0b8 100644 --- a/tests/util/layerValidation.ts +++ b/tests/util/layerValidation.ts @@ -9,6 +9,7 @@ export interface LayerDefinition { id: unknown; placement: unknown; nukeable?: unknown; + alpha?: unknown; } /** @@ -54,6 +55,15 @@ export function validateLayer( `${prefix} "nukeable" must be a boolean if present, got ${typeof layer.nukeable}`, ); } + if (layer.alpha !== undefined) { + if (typeof layer.alpha !== "number" || !Number.isFinite(layer.alpha)) { + errors.push( + `${prefix} "alpha" must be a finite number if present, got ${layer.alpha}`, + ); + } else if (layer.alpha < 0 || layer.alpha > 1) { + errors.push(`${prefix} "alpha" (${layer.alpha}) must be between 0 and 1`); + } + } return errors; }