Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions resources/lang/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
16 changes: 16 additions & 0 deletions src/client/controllers/MapLayerController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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) =>
Expand All @@ -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);
}
}
}
}
3 changes: 3 additions & 0 deletions src/client/hud/GameRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down
117 changes: 96 additions & 21 deletions src/client/hud/layers/GraphicsSettingsModal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -1509,29 +1547,66 @@ export class GraphicsSettingsModal extends LitElement implements Controller {
</div>
${this.mapLayers.map(
(layer) => html`
<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.onToggleLayer(layer.id)}
>
<div class="flex-1">
<div class="font-medium">${this.layerName(layer.id)}</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.onToggleLayer(layer.id)}
>
<div class="flex-1">
<div class="font-medium">${this.layerName(layer.id)}</div>
<div class="text-sm text-slate-400">
${layer.placement === "land"
? translateText(
"graphics_setting.layer_placement_land",
)
: translateText(
"graphics_setting.layer_placement_water",
)}
${layer.nukeable
? ` · ${translateText("graphics_setting.layer_nukeable")}`
: ""}
</div>
</div>
<div class="text-sm text-slate-400">
${layer.placement === "land"
? translateText("graphics_setting.layer_placement_land")
: translateText(
"graphics_setting.layer_placement_water",
)}
${layer.nukeable
? ` · ${translateText("graphics_setting.layer_nukeable")}`
: ""}
${this.isLayerVisible(layer.id)
? translateText("user_setting.on")
: translateText("user_setting.off")}
</div>
</div>
<div class="text-sm text-slate-400">
${this.isLayerVisible(layer.id)
? translateText("user_setting.on")
: translateText("user_setting.off")}
</div>
</button>
</button>
${this.isLayerVisible(layer.id)
? html`
<div
class="flex gap-3 items-center w-full text-left px-3 pb-2 text-white"
>
<div class="flex-1">
<div class="text-sm text-slate-300">
${translateText(
"graphics_setting.layer_alpha_label",
)}
</div>
<input
type="range"
min="0"
max="1"
step="0.01"
aria-label=${`${this.layerName(layer.id)} ${translateText("graphics_setting.layer_alpha_label")}`}
.value=${String(
this.getLayerAlpha(layer.id, layer.alpha),
)}
@input=${(e: Event) =>
this.onLayerAlphaSliderChange(layer.id, e)}
class="w-full border border-slate-500 rounded-lg"
/>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
</div>
<div class="text-sm text-slate-400 w-12 text-right">
${this.getLayerAlpha(layer.id, layer.alpha).toFixed(
2,
)}
</div>
</div>
`
: ""}
</div>
`,
)}
`
Expand Down
2 changes: 2 additions & 0 deletions src/client/render/gl/GraphicsOverrides.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
11 changes: 11 additions & 0 deletions src/client/render/gl/MapRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export class MapRenderer {
private storedLayerImages: Map<string, ImageBitmap> = new Map();
// Layer state that survives context loss (GPU textures do not).
private layerVisibility = new Map<string, boolean>();
private layerAlpha = new Map<string, number>();
private layerDestroyedMasks = new Map<string, Uint8Array>();

/**
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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.
Expand Down
5 changes: 5 additions & 0 deletions src/client/render/gl/Renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
9 changes: 9 additions & 0 deletions src/client/render/gl/passes/MapLayerPass.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,15 @@ export class MapLayerPass {
private uPlacement: WebGLUniformLocation;
private uNukeable: WebGLUniformLocation;
private uVisible: WebGLUniformLocation;
private uAlpha: WebGLUniformLocation;
private uLayerTex: WebGLUniformLocation;
private uTerrainBytes: WebGLUniformLocation;
private uDestroyedMask: WebGLUniformLocation;

/** 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,
Expand All @@ -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(
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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);
Expand Down
5 changes: 4 additions & 1 deletion src/client/render/gl/shaders/map-layer/layer.frag.glsl
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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);
}
15 changes: 14 additions & 1 deletion src/core/game/TerrainMapLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

export interface Nation {
Expand Down Expand Up @@ -123,14 +128,22 @@ 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") {
throw new Error(
`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)`,
);
}
}
}

Expand Down
Loading
Loading