diff --git a/map-generator/codegen.go b/map-generator/codegen.go index 1897f4afcd..4e9416de63 100644 --- a/map-generator/codegen.go +++ b/map-generator/codegen.go @@ -59,6 +59,15 @@ type mapInfo struct { Nations []struct { Name string `json:"name"` } `json:"nations"` + // Map layers rendered between terrain and territory. + Layers []mapLayer `json:"layers"` +} + +// mapLayer represents a single map layer definition from info.json. +type mapLayer struct { + ID string `json:"id"` + Placement string `json:"placement"` + Nukeable bool `json:"nukeable"` } // hasCategory reports whether the map lists the given category. @@ -213,6 +222,31 @@ func loadMapInfos() ([]mapInfo, error) { } seen[category] = true } + // Validate layers. + { + layerIDs := make(map[string]bool) + for i, layer := range info.Layers { + if layer.ID == "" { + return nil, fmt.Errorf("map %s: info.json layers[%d] \"id\" must not be empty", m.Name, i) + } + if layer.ID == "image" { + return nil, fmt.Errorf("map %s: info.json layers[%d] \"id\" must not be \"image\"", m.Name, i) + } + // Check alphanumeric + hyphen. + for _, ch := range layer.ID { + if !((ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '-') { + return nil, fmt.Errorf("map %s: info.json layers[%d] \"id\" (%q) must be alphanumeric (hyphens allowed)", m.Name, i, layer.ID) + } + } + if layerIDs[layer.ID] { + return nil, fmt.Errorf("map %s: info.json layers[%d] duplicate layer id %q", m.Name, i, layer.ID) + } + layerIDs[layer.ID] = true + if layer.Placement != "land" && layer.Placement != "water" { + return nil, fmt.Errorf("map %s: info.json layers[%d] \"placement\" (%q) must be \"land\" or \"water\"", m.Name, i, layer.Placement) + } + } + } infos = append(infos, info) } return infos, nil @@ -277,12 +311,24 @@ func generateMapsTS(infos []mapInfo) error { b.WriteString(" themes?: string[];\n") b.WriteString(" /** Custom tribe entry: a string (random spawn) or an object with name and coordinates. */\n") b.WriteString(" customTribes?: CustomTribe[];\n") + b.WriteString(" /** Map layers rendered between terrain and territory. */\n") + b.WriteString(" layers?: MapLayer[];\n") b.WriteString("}\n\n") b.WriteString("export interface CustomTribe {\n") b.WriteString(" name: string;\n") b.WriteString(" coordinates?: [number, number];\n") b.WriteString("}\n\n") + b.WriteString("export type LayerPlacement = \"land\" | \"water\";\n\n") + b.WriteString("export interface MapLayer {\n") + b.WriteString(" /** Unique identifier — also the PNG filename (without extension). */\n") + b.WriteString(" id: string;\n") + b.WriteString(" /** Whether the layer sits on land or water tiles. */\n") + b.WriteString(" placement: LayerPlacement;\n") + b.WriteString(" /** If true, the layer is permanently destroyed in nuke impact radii. */\n") + b.WriteString(" nukeable?: boolean;\n") + b.WriteString("}\n\n") + b.WriteString("export const maps: readonly MapInfo[] = [\n") for _, info := range infos { b.WriteString(" {\n") @@ -329,6 +375,20 @@ func generateMapsTS(infos []mapInfo) error { } b.WriteString("],\n") } + if len(info.Layers) > 0 { + b.WriteString(" layers: [") + for i, layer := range info.Layers { + if i > 0 { + b.WriteString(", ") + } + nukStr := "" + if layer.Nukeable { + nukStr = ", nukeable: true" + } + b.WriteString(fmt.Sprintf("{id: %q, placement: %q%s}", layer.ID, layer.Placement, nukStr)) + } + b.WriteString("],\n") + } b.WriteString(" },\n") } b.WriteString("];\n") @@ -381,6 +441,25 @@ func generateEnJSON(infos []mapInfo) error { } root["map"] = section + // Update the "map_layers" section with layer display names. + // Keyed by layer id; the value is the layer id (used as default display name). + layersSection := make(map[string]interface{}) + if existing, ok := root["map_layers"].(map[string]interface{}); ok { + for key, value := range existing { + layersSection[key] = value + } + } + // Collect all layer IDs across all maps. + for _, info := range infos { + for _, layer := range info.Layers { + if _, exists := layersSection[layer.ID]; !exists { + // Default display name: the layer id itself. + layersSection[layer.ID] = layer.ID + } + } + } + root["map_layers"] = layersSection + var b bytes.Buffer enc := json.NewEncoder(&b) enc.SetEscapeHTML(false) diff --git a/map-generator/main.go b/map-generator/main.go index 454874d715..d19159b045 100644 --- a/map-generator/main.go +++ b/map-generator/main.go @@ -1,10 +1,13 @@ package main import ( + "bytes" "context" "encoding/json" "flag" "fmt" + "image" + _ "image/png" "log" "log/slog" "os" @@ -158,6 +161,45 @@ func processMap(ctx context.Context, name string, isTest bool) error { return fmt.Errorf("failed to write thumbnail for %s: %w", name, err) } + // Copy layer PNGs and validate them. + if layersRaw, ok := manifest["layers"]; ok { + if layersArr, ok := layersRaw.([]interface{}); ok { + for _, layerRaw := range layersArr { + layer, ok := layerRaw.(map[string]interface{}) + if !ok { + continue + } + layerID, _ := layer["id"].(string) + if layerID == "" { + continue + } + // Validate placement. + placement, _ := layer["placement"].(string) + if placement != "land" && placement != "water" { + return fmt.Errorf("map %s: layer %q has invalid placement %q (must be \"land\" or \"water\")", name, layerID, placement) + } + // Copy the layer PNG. + srcPng := filepath.Join(inputMapDir, name, layerID+".png") + dstPng := filepath.Join(mapDir, layerID+".png") + pngData, err := os.ReadFile(srcPng) + if err != nil { + return fmt.Errorf("map %s: layer %q PNG not found at %s: %w", name, layerID, srcPng, err) + } + // Validate dimensions match image.png. + img, _, err := image.DecodeConfig(bytes.NewReader(pngData)) + if err != nil { + return fmt.Errorf("map %s: layer %q PNG failed to decode: %w", name, layerID, err) + } + if img.Width != result.Map.Width || img.Height != result.Map.Height { + return fmt.Errorf("map %s: layer %q PNG dimensions (%dx%d) do not match map (%dx%d)", name, layerID, img.Width, img.Height, result.Map.Width, result.Map.Height) + } + if err := os.WriteFile(dstPng, pngData, 0644); err != nil { + return fmt.Errorf("failed to write layer PNG for %s/%s: %w", name, layerID, err) + } + } + } + } + // Serialize the updated manifest to JSON updatedManifest, err := json.MarshalIndent(manifest, "", " ") if err != nil { diff --git a/resources/lang/en.json b/resources/lang/en.json index fca6b03991..bfeb70673d 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -645,6 +645,9 @@ "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_nukeable": "Nukeable", + "layer_placement_land": "Land layer", + "layer_placement_water": "Water layer", "lighting_ambient_desc": "Darkens the map and adds a glow around structures (0 = off)", "lighting_ambient_label": "Ambient light", "lighting_unit_glow_desc": "How far the glow spreads around units and structures", @@ -685,6 +688,7 @@ "section_effects": "Effects", "section_lighting": "Lighting", "section_map": "Map", + "section_map_layers": "Map Layers", "section_name_labels": "Name Labels", "section_presets": "Presets", "section_structure_icons": "Structure Icons", @@ -1116,6 +1120,7 @@ "search_results": "Search Results", "unfavorite": "Remove from favourites" }, + "map_layers": {}, "marketing_consent": { "body": "News and updates by email. Change it anytime in your account.", "dismiss": "Dismiss", diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index 9f99bd91ca..e99a80a3a3 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -27,7 +27,11 @@ import { HashUpdate, WinUpdate, } from "../core/game/GameUpdates"; -import { loadTerrainMap, TerrainMapData } from "../core/game/TerrainMapLoader"; +import { + loadLayerImages, + loadTerrainMap, + TerrainMapData, +} from "../core/game/TerrainMapLoader"; import { GRAPHICS_KEY, USER_SETTINGS_CHANGED_EVENT, @@ -155,6 +159,7 @@ export function joinLobby( message.gameMap, message.gameMapSize, terrainMapFileLoader, + false, // Layer images loaded off the critical path after game start. ); resolvePrestart(); } @@ -513,6 +518,7 @@ async function createClientGame( lobbyConfig.gameStartInfo.config.gameMap, lobbyConfig.gameStartInfo.config.gameMapSize, mapLoader, + false, // Layer images loaded off the critical path after game start. ); } // Kick off the font-atlas fetch so it overlaps with worker init; the @@ -566,6 +572,47 @@ async function createClientGame( const graphicsListenerAbort = new AbortController(); view.setShowPatterns(userSettings.territoryPatterns()); + + // Set up map layers if the map defines any. + if (gameMap.layers && gameMap.layers.length > 0) { + const applyLayerVisibility = () => { + const overrides = userSettings.graphicsOverrides(); + if (overrides.mapLayerVisibility) { + for (const layer of gameMap.layers!) { + const vis = overrides.mapLayerVisibility[layer.id]; + if (vis !== undefined) { + view.setLayerVisible(layer.id, vis); + } + } + } + }; + + if (gameMap.layerImages) { + // Images already loaded (e.g. from cache) — set up immediately. + view.setMapLayers(gameMap.layers, gameMap.layerImages); + applyLayerVisibility(); + } else { + // Layer images loaded off the critical path. Start fetching now; + // the renderer tolerates missing layers (warn + skip) until they + // arrive. + loadLayerImages( + lobbyConfig.gameStartInfo.config.gameMap, + lobbyConfig.gameStartInfo.config.gameMapSize, + mapLoader, + gameMap.layers, + ) + .then((images) => { + if (!graphicsListenerAbort.signal.aborted) { + view.setMapLayers(gameMap.layers!, images); + applyLayerVisibility(); + } + }) + .catch((e) => + console.warn("[ClientGameRunner] Failed to load layer images:", e), + ); + } + } + globalThis.addEventListener( `${USER_SETTINGS_CHANGED_EVENT}:settings.territoryPatterns`, (e) => view.setShowPatterns((e as CustomEvent).detail === "true"), diff --git a/src/client/WebGLFrameBuilder.ts b/src/client/WebGLFrameBuilder.ts index ee7c2ec744..df92449416 100644 --- a/src/client/WebGLFrameBuilder.ts +++ b/src/client/WebGLFrameBuilder.ts @@ -211,6 +211,7 @@ export class WebGLFrameBuilder { this.syncSpawnOverlay(gameView); this.syncSmallPlayerGlow(gameView); this.syncTerrainDeltas(gameView); + this.syncNukeImpacts(gameView); this.resolveDeadUnitExplosions(gameView); uploadFrameData(this.view, gameView.frameData()); } @@ -290,6 +291,28 @@ export class WebGLFrameBuilder { this.view.applyTerrainDelta(refs, this.terrainDeltaBytes); } + /** + * Mark nukeable layer tiles as destroyed from this tick's nuke impacts. + * Uses the full blast radius (both land and water tiles), not just the + * terrain-changed subset. Batches tile updates per layer for a single + * GPU texture upload per nukeable layer. + */ + private syncNukeImpacts(gameView: GameView): void { + const nukedTiles = gameView.recentlyNukedTiles(); + if (nukedTiles.length === 0) return; + const layers = gameView.layers(); + for (const layer of layers) { + if (!layer.nukeable) continue; + // Filter blast-radius tiles to only those matching this layer's + // placement. A water layer only needs water tiles destroyed; land + // tiles in the blast radius are invisible to it (shader discards). + const wantLand = layer.placement === "land"; + const tiles = nukedTiles.filter((t) => gameView.isLand(t) === wantLand); + if (tiles.length === 0) continue; + this.view.markLayerTilesDestroyed(layer.id, tiles); + } + } + private syncLocalPlayer(gameView: GameView): void { const me = gameView.myPlayer(); const sid = me?.smallID() ?? 0; diff --git a/src/client/hud/GameRenderer.ts b/src/client/hud/GameRenderer.ts index 6a5c8a472f..53310de9c1 100644 --- a/src/client/hud/GameRenderer.ts +++ b/src/client/hud/GameRenderer.ts @@ -196,6 +196,10 @@ export function createRenderer( } graphicsSettingsModal.userSettings = userSettings; graphicsSettingsModal.eventBus = eventBus; + graphicsSettingsModal.mapLayers = game.layers(); + graphicsSettingsModal.onLayerVisibilityChange = (layerId, visible) => { + view.setLayerVisible(layerId, visible); + }; 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 09f7524e72..09a0a1ea8e 100644 --- a/src/client/hud/layers/GraphicsSettingsModal.ts +++ b/src/client/hud/layers/GraphicsSettingsModal.ts @@ -4,6 +4,7 @@ import { crazyGamesSDK } from "src/client/CrazyGamesSDK"; import { PauseGameIntentEvent } from "src/client/Transport"; import { assetUrl } from "../../../core/AssetUrls"; import { EventBus } from "../../../core/EventBus"; +import type { MapLayer } from "../../../core/game/TerrainMapLoader"; import { UserSettings } from "../../../core/game/UserSettings"; import "../../components/GraphicsPresetSelector"; import { Controller } from "../../Controller"; @@ -154,6 +155,12 @@ export class ShowGraphicsSettingsModalEvent { export class GraphicsSettingsModal extends LitElement implements Controller { public eventBus: EventBus; public userSettings: UserSettings; + /** Map layers for the current game (set by GameRenderer on game start). */ + public mapLayers: MapLayer[] = []; + /** Callback to toggle layer visibility on the renderer. */ + public onLayerVisibilityChange: + | ((layerId: string, visible: boolean) => void) + | null = null; @state() private isVisible: boolean = false; @@ -331,6 +338,43 @@ export class GraphicsSettingsModal extends LitElement implements Controller { this.patchMapOverlay({ navalHighlight: !this.currentNavalHighlight() }); } + // ---- Map-layer visibility ---- + + private isLayerVisible(layerId: string): boolean { + const overrides = this.userSettings.graphicsOverrides(); + // Default to visible if no override is set. + return overrides.mapLayerVisibility?.[layerId] ?? true; + } + + private layerName(layerId: string): string { + const key = `map_layers.${layerId}`; + const translated = translateText(key); + // translateText returns the key itself when the translation is missing. + return translated === key ? layerId : translated; + } + + private onToggleLayer(layerId: string) { + const current = this.userSettings.graphicsOverrides(); + const currentVis = current.mapLayerVisibility ?? {}; + const newVis = { ...currentVis, [layerId]: !this.isLayerVisible(layerId) }; + this.userSettings.setGraphicsOverrides({ + ...current, + mapLayerVisibility: newVis, + }); + this.onLayerVisibilityChange?.(layerId, newVis[layerId]); + this.requestUpdate(); + } + + /** + * Re-apply layer visibility from current overrides to the renderer. + * Called after reset or preset import so the WebGL passes stay in sync. + */ + private syncLayerVisibility() { + for (const layer of this.mapLayers) { + this.onLayerVisibilityChange?.(layer.id, this.isLayerVisible(layer.id)); + } + } + private currentHighlightFill(): number { return ( this.userSettings.graphicsOverrides().mapOverlay?.highlightFillBrighten ?? @@ -686,11 +730,13 @@ export class GraphicsSettingsModal extends LitElement implements Controller { private onResetClick() { this.userSettings.setGraphicsOverrides({}); + this.syncLayerVisibility(); this.requestUpdate(); } private applyPreset(overrides: GraphicsOverrides) { this.userSettings.setGraphicsOverrides(overrides); + this.syncLayerVisibility(); this.requestUpdate(); } @@ -1441,6 +1487,43 @@ export class GraphicsSettingsModal extends LitElement implements Controller { + ${this.mapLayers.length > 0 + ? html` +
+ ${translateText("graphics_setting.section_map_layers")} +
+ ${this.mapLayers.map( + (layer) => html` + + `, + )} + ` + : ""} +
diff --git a/src/client/render/gl/GraphicsOverrides.ts b/src/client/render/gl/GraphicsOverrides.ts index b033723e82..bbe5be6000 100644 --- a/src/client/render/gl/GraphicsOverrides.ts +++ b/src/client/render/gl/GraphicsOverrides.ts @@ -100,6 +100,8 @@ export const GraphicsOverridesSchema = z falloffPower: z.number(), }) .partial(), + /** Per-layer visibility toggles keyed by layer id. */ + mapLayerVisibility: z.record(z.string(), z.boolean()), }) .partial(); diff --git a/src/client/render/gl/MapRenderer.ts b/src/client/render/gl/MapRenderer.ts index 71e53a7ee4..ee1e375f43 100644 --- a/src/client/render/gl/MapRenderer.ts +++ b/src/client/render/gl/MapRenderer.ts @@ -12,6 +12,7 @@ */ import type { Config } from "../../../core/configuration/Config"; +import type { MapLayer } from "../../../core/game/TerrainMapLoader"; import type { SpiralRibbon } from "../frame/SpiralTrails"; import type { AttackRingInput, @@ -36,6 +37,12 @@ import type { RenderSettings } from "./RenderSettings"; export class MapRenderer { private renderer: GPURenderer | null = null; private resizeObs: ResizeObserver | null = null; + // Stored layer data for context-restore re-creation. + private storedLayers: MapLayer[] = []; + private storedLayerImages: Map = new Map(); + // Layer state that survives context loss (GPU textures do not). + private layerVisibility = new Map(); + private layerDestroyedMasks = new Map(); /** * Called after a lost WebGL context is restored and the renderer has been @@ -104,6 +111,18 @@ export class MapRenderer { private handleContextRestored = () => { this.initRenderer(); + // Re-apply stored layers to the new renderer. + if (this.storedLayers.length > 0 && this.storedLayerImages.size > 0) { + this.renderer?.setMapLayers(this.storedLayers, this.storedLayerImages); + // Re-apply visibility overrides. + for (const [id, vis] of this.layerVisibility) { + this.renderer?.setLayerVisible(id, vis); + } + // Re-apply destroyed masks. + for (const [id, mask] of this.layerDestroyedMasks) { + this.renderer?.setLayerDestroyedMask(id, mask); + } + } this.onContextRestored?.(); }; @@ -248,6 +267,41 @@ export class MapRenderer { this.renderer?.updateSmallPlayerGlow(set); } + // ---- Map layers ---- + + /** Set up map-layer passes from the loaded layer data. */ + setMapLayers(layers: MapLayer[], images: Map): void { + this.storedLayers = layers; + this.storedLayerImages = images; + this.renderer?.setMapLayers(layers, images); + } + + /** Toggle visibility of a single map layer. */ + setLayerVisible(layerId: string, visible: boolean): void { + this.layerVisibility.set(layerId, visible); + this.renderer?.setLayerVisible(layerId, visible); + } + + /** Batch-mark tiles as destroyed for a nukeable layer. */ + markLayerTilesDestroyed(layerId: string, tileIndices: number[]): void { + // Accumulate into the CPU-side mask for context-restore. + let mask = this.layerDestroyedMasks.get(layerId); + if (!mask) { + mask = new Uint8Array(this.header.mapWidth * this.header.mapHeight); + this.layerDestroyedMasks.set(layerId, mask); + } + for (const t of tileIndices) { + if (t >= 0 && t < mask.length) mask[t] = 1; + } + this.renderer?.markLayerTilesDestroyed(layerId, tileIndices); + } + + /** Bulk-update the destroyed mask for a nukeable layer. */ + setLayerDestroyedMask(layerId: string, mask: Uint8Array): void { + this.layerDestroyedMasks.set(layerId, new Uint8Array(mask)); + this.renderer?.setLayerDestroyedMask(layerId, mask); + } + // ---- Selection box ---- /** Set multiple selected units (multi-select). Pass [] to clear. */ diff --git a/src/client/render/gl/Renderer.ts b/src/client/render/gl/Renderer.ts index bfe8dac148..cb939e4031 100644 --- a/src/client/render/gl/Renderer.ts +++ b/src/client/render/gl/Renderer.ts @@ -10,6 +10,7 @@ */ import type { Config } from "../../../core/configuration/Config"; +import type { MapLayer } from "../../../core/game/TerrainMapLoader"; import type { SpiralRibbon } from "../frame/SpiralTrails"; import type { AttackRingInput, @@ -38,6 +39,7 @@ import { FalloutBloomPass } from "./passes/FalloutBloomPass"; import { FalloutLightPass } from "./passes/FalloutLightPass"; import { FxPass } from "./passes/fx-pass"; import { LightmapPass } from "./passes/LightmapPass"; +import { MapLayerPass } from "./passes/MapLayerPass"; import { MoveIndicatorPass } from "./passes/MoveIndicatorPass"; import { NamePass } from "./passes/name-pass"; import { NightCompositePass } from "./passes/NightCompositePass"; @@ -147,6 +149,17 @@ export class GPURenderer { private smallPlayerGlowPass: SmallPlayerGlowPass; private inSpawnPhase = false; + // Map-layer passes keyed by layer id, drawn between terrain and territory. + private mapLayerPasses: Map = new Map(); + /** R8UI terrain-bytes texture shared by all layer passes. */ + private terrainBytesTex: WebGLTexture | null = null; + /** Stored layer definitions for context-restore re-creation. */ + private storedLayers: MapLayer[] = []; + /** Stored layer images for context-restore re-creation. */ + private storedLayerImages: Map = new Map(); + /** Scratch buffer for per-tile terrain byte uploads (avoids allocations). */ + private terrainDeltaScratch = new Uint8Array(1); + private paletteTex: WebGLTexture; private paletteData: Float32Array; // Per-player trail-effect palette, keyed by smallID (RGBA32F, @@ -270,6 +283,17 @@ export class GPURenderer { }, ); + // --- Terrain bytes R8UI texture (shared by map-layer passes) --- + this.terrainBytesTex = createTexture2D(gl, { + width: mapW, + height: mapH, + internalFormat: gl.R8UI, + format: gl.RED_INTEGER, + type: gl.UNSIGNED_BYTE, + data: terrainBytes, + filter: gl.NEAREST, + }); + // --- Shared palette texture (RGBA32F, 4096×2) --- this.paletteData = paletteData; const palW = getPaletteSize(); @@ -930,6 +954,43 @@ export class GPURenderer { if (refs.length === 0) return; this.terrainPass.applyTerrainDelta(refs, terrainBytes); this.railroadPass.applyTerrainDelta(refs, terrainBytes); + // Update the shared R8UI terrain-bytes texture used by map-layer passes. + if (!this.terrainBytesTex) return; + const gl = this.gl; + gl.bindTexture(gl.TEXTURE_2D, this.terrainBytesTex); + gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1); + // Full-map fast path: single texSubImage2D instead of per-tile uploads. + if (refs.length === this.mapW * this.mapH) { + gl.texSubImage2D( + gl.TEXTURE_2D, + 0, + 0, + 0, + this.mapW, + this.mapH, + gl.RED_INTEGER, + gl.UNSIGNED_BYTE, + terrainBytes, + ); + return; + } + for (let i = 0; i < refs.length; i++) { + const ref = refs[i]; + const x = ref % this.mapW; + const y = Math.floor(ref / this.mapW); + this.terrainDeltaScratch[0] = terrainBytes[i]; + gl.texSubImage2D( + gl.TEXTURE_2D, + 0, + x, + y, + 1, + 1, + gl.RED_INTEGER, + gl.UNSIGNED_BYTE, + this.terrainDeltaScratch, + ); + } } /** @@ -1250,6 +1311,10 @@ export class GPURenderer { if (pe.terrain) this.terrainPass.draw(cam); gl.enable(gl.BLEND); gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); + // Map layers sit between terrain and territory. + for (const layerPass of this.mapLayerPasses.values()) { + layerPass.draw(cam); + } if (pe.territory) this.territoryPass.draw(cam); } @@ -1303,12 +1368,78 @@ export class GPURenderer { gl.disable(gl.BLEND); } + // --------------------------------------------------------------------------- + // Map-layer management + // --------------------------------------------------------------------------- + + /** + * Create map-layer passes from the loaded layer data. Called once at game + * start (and again after context restore). Layers are drawn in array order + * between the terrain and territory passes. + */ + setMapLayers(layers: MapLayer[], images: Map): void { + // Dispose any previous layer passes. + for (const p of this.mapLayerPasses.values()) p.dispose(); + this.mapLayerPasses.clear(); + this.storedLayers = layers; + this.storedLayerImages = images; + + if (!this.terrainBytesTex) return; + const gl = this.gl; + + for (const layer of layers) { + const image = images.get(layer.id); + if (!image) { + console.warn(`[Renderer] Layer image "${layer.id}" not found`); + continue; + } + const placement: 0 | 1 = layer.placement === "water" ? 1 : 0; + const pass = new MapLayerPass( + gl, + this.terrainBytesTex, + image, + this.mapW, + this.mapH, + placement, + layer.nukeable ?? false, + ); + this.mapLayerPasses.set(layer.id, pass); + } + } + + /** Toggle visibility of a single layer (driven by graphics settings). */ + setLayerVisible(layerId: string, visible: boolean): void { + this.mapLayerPasses.get(layerId)?.setVisible(visible); + } + + /** + * Mark tiles as destroyed for a nukeable layer. Called when a nuke + * detonates; batches all tile updates into a single GPU upload. + */ + markLayerTilesDestroyed(layerId: string, tileIndices: number[]): void { + this.mapLayerPasses.get(layerId)?.markTilesDestroyed(tileIndices); + } + + /** + * Bulk-destroy tiles for a nukeable layer (used when replaying or restoring + * state). + */ + setLayerDestroyedMask(layerId: string, mask: Uint8Array): void { + this.mapLayerPasses.get(layerId)?.updateDestroyedMask(mask); + } + // --------------------------------------------------------------------------- // Lifecycle // --------------------------------------------------------------------------- dispose(): void { this.stopLoop(); + for (const p of this.mapLayerPasses.values()) p.dispose(); + this.mapLayerPasses.clear(); + if (this.terrainBytesTex) { + this.gl.deleteTexture(this.terrainBytesTex); + this.terrainBytesTex = null; + } this.terrainPass.dispose(); this.territoryPass.dispose(); this.trailPass.dispose(); diff --git a/src/client/render/gl/passes/MapLayerPass.ts b/src/client/render/gl/passes/MapLayerPass.ts new file mode 100644 index 0000000000..39d4db2228 --- /dev/null +++ b/src/client/render/gl/passes/MapLayerPass.ts @@ -0,0 +1,273 @@ +/** + * MapLayerPass — renders a single map-layer image between terrain and territory. + * + * Each layer is a full-size PNG (same dimensions as image.png) placed above + * the terrain tiles but below player territory. The fragment shader discards + * pixels whose land/water type does not match the layer's "placement" and + * (for nukeable layers) pixels that have been destroyed by a nuke. + */ + +import layerFragSrc from "../shaders/map-layer/layer.frag.glsl?raw"; +import layerVertSrc from "../shaders/map-layer/layer.vert.glsl?raw"; +import { + createMapQuad, + createProgram, + createTexture2D, + shaderSrc, +} from "../utils/GlUtils"; + +export class MapLayerPass { + private program: WebGLProgram; + private layerTex: WebGLTexture; + private destroyedTex: WebGLTexture; + private vao: WebGLVertexArrayObject; + private uCamera: WebGLUniformLocation; + private uPlacement: WebGLUniformLocation; + private uNukeable: WebGLUniformLocation; + private uVisible: 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; + + constructor( + private gl: WebGL2RenderingContext, + /** Terrain-bytes R8UI texture (shared across all layer passes). */ + private terrainBytesTex: WebGLTexture, + /** Layer RGBA image (ImageBitmap loaded from the layer PNG). */ + image: ImageBitmap, + private mapW: number, + private mapH: number, + /** 0 = land layer, 1 = water layer. */ + private placement: 0 | 1, + /** Whether the layer is destroyed by nukes. */ + private nukeable: boolean, + ) { + this.program = createProgram( + gl, + shaderSrc(layerVertSrc, { MAP_W: mapW, MAP_H: mapH }), + shaderSrc(layerFragSrc, { MAP_W: mapW, MAP_H: mapH }), + ); + this.uCamera = gl.getUniformLocation(this.program, "uCamera")!; + this.uPlacement = gl.getUniformLocation(this.program, "uPlacement")!; + this.uNukeable = gl.getUniformLocation(this.program, "uNukeable")!; + this.uVisible = gl.getUniformLocation(this.program, "uVisible")!; + this.uLayerTex = gl.getUniformLocation(this.program, "uLayerTex")!; + this.uTerrainBytes = gl.getUniformLocation(this.program, "uTerrainBytes")!; + this.uDestroyedMask = gl.getUniformLocation( + this.program, + "uDestroyedMask", + )!; + + // Layer RGBA texture from the ImageBitmap. + this.layerTex = createTexture2D(gl, { + width: image.width, + height: image.height, + internalFormat: gl.RGBA8, + format: gl.RGBA, + type: gl.UNSIGNED_BYTE, + data: null, + filter: gl.NEAREST, + }); + gl.bindTexture(gl.TEXTURE_2D, this.layerTex); + gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, gl.RGBA, gl.UNSIGNED_BYTE, image); + + // Destroyed-mask texture (R8UI, one byte per tile). Starts all-zero. + this.destroyedData = new Uint8Array(mapW * mapH); + this.destroyedTex = createTexture2D(gl, { + width: mapW, + height: mapH, + internalFormat: gl.R8UI, + format: gl.RED_INTEGER, + type: gl.UNSIGNED_BYTE, + data: this.destroyedData, + filter: gl.NEAREST, + }); + + this.vao = createMapQuad(gl, mapW, mapH); + } + + // ------------------------------------------------------------------ + // Public API + // ------------------------------------------------------------------ + + /** Show/hide this layer (driven by graphics settings). */ + setVisible(visible: boolean): void { + this._visible = visible; + } + + /** + * Upload a per-tile destroyed mask. Each element is 0 (intact) or 1 + * (destroyed by a nuke). Only meaningful for nukeable layers. + */ + updateDestroyedMask(data: Uint8Array): void { + if (data.length !== this.mapW * this.mapH) return; + this.destroyedData.set(data); + const gl = this.gl; + gl.bindTexture(gl.TEXTURE_2D, this.destroyedTex); + gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1); + gl.texSubImage2D( + gl.TEXTURE_2D, + 0, + 0, + 0, + this.mapW, + this.mapH, + gl.RED_INTEGER, + gl.UNSIGNED_BYTE, + this.destroyedData, + ); + } + + /** + * Mark a single tile as destroyed (for incremental nuke updates). + * More efficient than re-uploading the full mask for each nuke. + */ + markTileDestroyed(tileIndex: number): void { + if (tileIndex < 0 || tileIndex >= this.mapW * this.mapH) return; + if (this.destroyedData[tileIndex] === 1) return; // already destroyed + this.destroyedData[tileIndex] = 1; + const gl = this.gl; + gl.bindTexture(gl.TEXTURE_2D, this.destroyedTex); + gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1); + // Upload just the single byte. + gl.texSubImage2D( + gl.TEXTURE_2D, + 0, + tileIndex % this.mapW, + Math.floor(tileIndex / this.mapW), + 1, + 1, + gl.RED_INTEGER, + gl.UNSIGNED_BYTE, + new Uint8Array([1]), + ); + } + + /** + * Batch-mark multiple tiles as destroyed. Binds the texture once and + * uploads all new tile bytes in a single call, avoiding per-tile + * rebind overhead. + */ + markTilesDestroyed(tileIndices: number[]): void { + // Update CPU-side mask and find the bounding box of new destroyed tiles. + let minX = this.mapW, + minY = this.mapH, + maxX = -1, + maxY = -1; + let anyNew = false; + for (const tileIndex of tileIndices) { + if (tileIndex < 0 || tileIndex >= this.mapW * this.mapH) continue; + if (this.destroyedData[tileIndex] === 1) continue; + this.destroyedData[tileIndex] = 1; + const x = tileIndex % this.mapW; + const y = (tileIndex - x) / this.mapW; + if (x < minX) minX = x; + if (x > maxX) maxX = x; + if (y < minY) minY = y; + if (y > maxY) maxY = y; + anyNew = true; + } + if (!anyNew) return; + // Single sub-region upload instead of per-tile 1×1 calls. + const gl = this.gl; + const w = maxX - minX + 1; + const h = maxY - minY + 1; + const buf = new Uint8Array(w * h); + for (let row = minY; row <= maxY; row++) { + const srcOff = row * this.mapW + minX; + const dstOff = (row - minY) * w; + buf.set(this.destroyedData.subarray(srcOff, srcOff + w), dstOff); + } + gl.bindTexture(gl.TEXTURE_2D, this.destroyedTex); + gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1); + gl.texSubImage2D( + gl.TEXTURE_2D, + 0, + minX, + minY, + w, + h, + gl.RED_INTEGER, + gl.UNSIGNED_BYTE, + buf, + ); + } + + /** Re-upload all GPU resources (used after WebGL context restore). */ + restoreFrom(image: ImageBitmap): void { + const gl = this.gl; + // Re-create layer texture. + gl.deleteTexture(this.layerTex); + this.layerTex = createTexture2D(gl, { + width: image.width, + height: image.height, + internalFormat: gl.RGBA8, + format: gl.RGBA, + type: gl.UNSIGNED_BYTE, + data: null, + filter: gl.NEAREST, + }); + gl.bindTexture(gl.TEXTURE_2D, this.layerTex); + gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, gl.RGBA, gl.UNSIGNED_BYTE, image); + // Re-upload destroyed mask. + gl.deleteTexture(this.destroyedTex); + this.destroyedTex = createTexture2D(gl, { + width: this.mapW, + height: this.mapH, + internalFormat: gl.R8UI, + format: gl.RED_INTEGER, + type: gl.UNSIGNED_BYTE, + data: this.destroyedData, + filter: gl.NEAREST, + }); + } + + // ------------------------------------------------------------------ + // Draw + // ------------------------------------------------------------------ + + draw(cam: Float32Array): void { + const gl = this.gl; + gl.useProgram(this.program); + + // Texture unit 0 — layer RGBA. + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, this.layerTex); + // Texture unit 1 — terrain bytes (R8UI, shared). + gl.activeTexture(gl.TEXTURE1); + gl.bindTexture(gl.TEXTURE_2D, this.terrainBytesTex); + // Texture unit 2 — destroyed mask (R8UI). + gl.activeTexture(gl.TEXTURE2); + gl.bindTexture(gl.TEXTURE_2D, this.destroyedTex); + + // Sampler bindings (cached uniform locations). + gl.uniform1i(this.uLayerTex, 0); + gl.uniform1i(this.uTerrainBytes, 1); + gl.uniform1i(this.uDestroyedMask, 2); + + gl.uniformMatrix3fv(this.uCamera, false, cam); + 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.bindVertexArray(this.vao); + gl.drawArrays(gl.TRIANGLES, 0, 6); + gl.bindVertexArray(null); + } + + // ------------------------------------------------------------------ + // Cleanup + // ------------------------------------------------------------------ + + dispose(): void { + const gl = this.gl; + gl.deleteProgram(this.program); + gl.deleteTexture(this.layerTex); + gl.deleteTexture(this.destroyedTex); + gl.deleteVertexArray(this.vao); + } +} diff --git a/src/client/render/gl/passes/RailroadPass.ts b/src/client/render/gl/passes/RailroadPass.ts index a315c72fd2..00c0389b57 100644 --- a/src/client/render/gl/passes/RailroadPass.ts +++ b/src/client/render/gl/passes/RailroadPass.ts @@ -129,6 +129,8 @@ export class RailroadPass { private localPlayerID = 0; private localRailColor: [number, number, number] = [0.75, 0.75, 0.75]; + /** Scratch buffer for per-tile terrain byte uploads (avoids allocations). */ + private terrainDeltaScratch = new Uint8Array(1); constructor( private gl: WebGL2RenderingContext, @@ -246,12 +248,26 @@ export class RailroadPass { const gl = this.gl; gl.bindTexture(gl.TEXTURE_2D, this.terrainTex); gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1); - const scratch = new Uint8Array(1); + // Full-map fast path: single texSubImage2D instead of per-tile uploads. + if (refs.length === this.mapW * this.mapH) { + gl.texSubImage2D( + gl.TEXTURE_2D, + 0, + 0, + 0, + this.mapW, + this.mapH, + gl.RED_INTEGER, + gl.UNSIGNED_BYTE, + bytes, + ); + return; + } for (let i = 0; i < refs.length; i++) { const ref = refs[i]; const x = ref % this.mapW; const y = (ref - x) / this.mapW; - scratch[0] = bytes[i]; + this.terrainDeltaScratch[0] = bytes[i]; gl.texSubImage2D( gl.TEXTURE_2D, 0, @@ -261,7 +277,7 @@ export class RailroadPass { 1, gl.RED_INTEGER, gl.UNSIGNED_BYTE, - scratch, + this.terrainDeltaScratch, ); } } diff --git a/src/client/render/gl/passes/TerrainPass.ts b/src/client/render/gl/passes/TerrainPass.ts index 02d0394cee..fff8a5164a 100644 --- a/src/client/render/gl/passes/TerrainPass.ts +++ b/src/client/render/gl/passes/TerrainPass.ts @@ -109,6 +109,11 @@ export class TerrainPass { */ applyTerrainDelta(refs: readonly number[], bytes: Uint8Array): void { if (refs.length === 0) return; + // Full-map fast path: rebuild the entire RGBA texture in one upload. + if (refs.length === this.mapW * this.mapH) { + this.setTerrainColors(this.terrainColors); + return; + } const gl = this.gl; gl.bindTexture(gl.TEXTURE_2D, this.tex); gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1); diff --git a/src/client/render/gl/shaders/map-layer/layer.frag.glsl b/src/client/render/gl/shaders/map-layer/layer.frag.glsl new file mode 100644 index 0000000000..28fda968fa --- /dev/null +++ b/src/client/render/gl/shaders/map-layer/layer.frag.glsl @@ -0,0 +1,55 @@ +#version 300 es +precision highp float; +precision highp usampler2D; + +// Layer RGBA texture (PNG with transparency). +uniform sampler2D uLayerTex; + +// Raw terrain bytes (R8UI): bit 7 = isLand. +uniform usampler2D uTerrainBytes; + +// Per-tile destroyed mask (R8UI): 0 = intact, 1 = destroyed. +// Only sampled when uNukeable > 0. +uniform usampler2D uDestroyedMask; + +// 0 = land layer (visible only on land tiles), 1 = water layer. +uniform int uPlacement; + +// 0 = permanent, 1 = destroyed in nuke blast radius. +uniform int uNukeable; + +// 0.0 = hidden (user toggle), 1.0 = visible. +uniform float uVisible; + +in vec2 vUV; +out vec4 fragColor; + +void main() { + // User toggle. + if (uVisible < 0.5) discard; + + ivec2 tc = ivec2( + int(vUV.x * float(MAP_W)), + int(vUV.y * float(MAP_H)) + ); + tc = clamp(tc, ivec2(0), ivec2(MAP_W - 1, MAP_H - 1)); + + // Land/water placement check: bit 7 of terrain byte = isLand. + uint terrainByte = texelFetch(uTerrainBytes, tc, 0).r; + bool isLand = (terrainByte & 0x80u) != 0u; + + if (uPlacement == 0 && !isLand) discard; // land layer on water tile + if (uPlacement == 1 && isLand) discard; // water layer on land tile + + // Nukeable: skip destroyed tiles. + if (uNukeable == 1) { + uint destroyed = texelFetch(uDestroyedMask, tc, 0).r; + if (destroyed > 0u) discard; + } + + // Sample layer texture. + vec4 layer = texture(uLayerTex, vUV); + if (layer.a < 0.01) discard; + + fragColor = layer; +} diff --git a/src/client/render/gl/shaders/map-layer/layer.vert.glsl b/src/client/render/gl/shaders/map-layer/layer.vert.glsl new file mode 100644 index 0000000000..36cca31f09 --- /dev/null +++ b/src/client/render/gl/shaders/map-layer/layer.vert.glsl @@ -0,0 +1,16 @@ +#version 300 es +precision highp float; + +layout(location = 0) in vec2 aPos; + +uniform mat3 uCamera; + +out vec2 vUV; + +void main() { + vec3 clip = uCamera * vec3(aPos, 1.0); + gl_Position = vec4(clip.xy, 0.0, 1.0); + + // aPos ranges [0, mapW] × [0, mapH] — normalize to [0,1] UV. + vUV = aPos / vec2(float(MAP_W), float(MAP_H)); +} diff --git a/src/client/view/GameView.ts b/src/client/view/GameView.ts index 23dd41f035..f334906944 100644 --- a/src/client/view/GameView.ts +++ b/src/client/view/GameView.ts @@ -84,6 +84,7 @@ export class GameView implements GameMap { private _teams = new Map(); private updatedTiles: TileRef[] = []; private updatedTerrainTiles: TileRef[] = []; + private nukeImpactTiles: TileRef[] = []; /** * Active units grouped by owner smallID, built lazily at most once per * tick. Keeps per-player unit queries (PlayerView.units) at O(own units) @@ -158,6 +159,7 @@ export class GameView implements GameMap { this._cosmetics = new Map( humans.map((h) => [h.clientID, h.cosmetics ?? {}]), ); + for (const nation of this._mapData.nations) { // Nations don't have client ids, so we use their name as the key instead. this._cosmetics.set(nation.name, { @@ -298,6 +300,10 @@ export class GameView implements GameMap { } } + // Nuke blast-radius tiles (for nukeable layer destruction). + const packedNukes = gu.packedNukeImpacts; + this.nukeImpactTiles = packedNukes ? Array.from(packedNukes) : []; + if (gu.packedMotionPlans) { const records = unpackMotionPlans(gu.packedMotionPlans); this.applyMotionPlanRecords(records); @@ -938,6 +944,9 @@ export class GameView implements GameMap { recentlyUpdatedTerrainTiles(): TileRef[] { return this.updatedTerrainTiles; } + recentlyNukedTiles(): TileRef[] { + return this.nukeImpactTiles; + } nearbyUnits( tile: TileRef, @@ -1168,6 +1177,10 @@ export class GameView implements GameMap { numLandTiles(): number { return this._map.numLandTiles(); } + /** Map layers defined in the map's info.json, if any. */ + layers(): import("../../core/game/TerrainMapLoader").MapLayer[] { + return this._mapData.layers ?? []; + } isValidCoord(x: number, y: number): boolean { return this._map.isValidCoord(x, y); } diff --git a/src/core/GameRunner.ts b/src/core/GameRunner.ts index 8c3c153bbd..0ab0e580be 100644 --- a/src/core/GameRunner.ts +++ b/src/core/GameRunner.ts @@ -43,6 +43,7 @@ export async function createGameRunner( gameStart.config.gameMap, gameStart.config.gameMapSize, mapLoader, + false, // Worker never renders layers — skip image loading to save memory. ); const random = new PseudoRandom(simpleHash(gameStart.gameID)); @@ -197,6 +198,9 @@ export class GameRunner { const packedMotionPlans = this.game.drainPackedMotionPlans(); const packedPlayerUpdates = this.game.drainPackedPlayerUpdates(); const packedAttackUpdates = this.game.drainPackedAttackUpdates(); + const nukeImpactTiles = this.game.drainNukeImpacts(); + const packedNukeImpacts = + nukeImpactTiles.length > 0 ? new Uint32Array(nukeImpactTiles) : undefined; this.callBack({ tick: this.game.ticks(), @@ -204,6 +208,7 @@ export class GameRunner { ...(packedMotionPlans ? { packedMotionPlans } : {}), ...(packedPlayerUpdates ? { packedPlayerUpdates } : {}), ...(packedAttackUpdates ? { packedAttackUpdates } : {}), + ...(packedNukeImpacts ? { packedNukeImpacts } : {}), updates: updates, ...(viewDataChanged ? { playerNameViewData: this.playerViewData } : {}), tickExecutionDuration: tickExecutionDuration, diff --git a/src/core/execution/NukeExecution.ts b/src/core/execution/NukeExecution.ts index d4eaefcdbc..f92abb0fff 100644 --- a/src/core/execution/NukeExecution.ts +++ b/src/core/execution/NukeExecution.ts @@ -369,6 +369,9 @@ export class NukeExecution implements Execution { if (mg.isLand(tile)) { mg.queueWaterConversion(tile); } + + // Record every tile in the blast radius for nukeable layer destruction. + mg.queueNukeImpact(tile); } // Then compute the explosion effect on each player diff --git a/src/core/game/BinaryLoaderGameMapLoader.ts b/src/core/game/BinaryLoaderGameMapLoader.ts index fce7522d7b..d7ebf375ac 100644 --- a/src/core/game/BinaryLoaderGameMapLoader.ts +++ b/src/core/game/BinaryLoaderGameMapLoader.ts @@ -56,6 +56,16 @@ export class BinaryLoaderGameMapLoader implements GameMapLoader { }), ), webpPath: mapAssetUrl("thumbnail.webp"), + layerPng: (layerId: string) => + fetch(mapAssetUrl(`${layerId}.png`)) + .then((res) => { + if (!res.ok) + throw new Error( + `Failed to load ${mapAssetUrl(`${layerId}.png`)}`, + ); + return res.blob(); + }) + .then((blob) => createImageBitmap(blob)), } satisfies MapData; this.maps.set(map, mapData); diff --git a/src/core/game/FetchGameMapLoader.ts b/src/core/game/FetchGameMapLoader.ts index f7b0e575e3..ac72e14608 100644 --- a/src/core/game/FetchGameMapLoader.ts +++ b/src/core/game/FetchGameMapLoader.ts @@ -31,6 +31,8 @@ export class FetchGameMapLoader implements GameMapLoader { map16xBin: () => this.loadBinaryFromUrl(this.url(fileName, "map16x.bin")), manifest: () => this.loadJsonFromUrl(this.url(fileName, "manifest.json")), webpPath: this.url(fileName, "thumbnail.webp"), + layerPng: (layerId: string) => + this.loadImageBitmap(this.url(fileName, `${layerId}.png`)), } satisfies MapData; this.maps.set(map, mapData); @@ -72,4 +74,18 @@ export class FetchGameMapLoader implements GameMapLoader { return response.json(); } + + private async loadImageBitmap(url: string): Promise { + const startTime = performance.now(); + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Failed to load ${url}: ${response.statusText}`); + } + const blob = await response.blob(); + const bitmap = await createImageBitmap(blob); + console.log( + `[MapLoader] ${url}: ${(performance.now() - startTime).toFixed(0)}ms`, + ); + return bitmap; + } } diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index 51b23d2c81..5a79b04c5c 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -861,6 +861,12 @@ export interface Game extends GameMap { /** Queue a land tile for conversion to water (batched every few ticks). Tile must be unowned. */ queueWaterConversion(tile: TileRef): void; + + /** Queue a tile that was inside a nuke blast radius (for nukeable layer destruction). */ + queueNukeImpact(tile: TileRef): void; + + /** Drain all tiles from nuke impacts this tick. Called once per tick. */ + drainNukeImpacts(): TileRef[]; } export interface PlayerActions { diff --git a/src/core/game/GameImpl.ts b/src/core/game/GameImpl.ts index bfb93de781..ecd8b20442 100644 --- a/src/core/game/GameImpl.ts +++ b/src/core/game/GameImpl.ts @@ -116,6 +116,8 @@ export class GameImpl implements Game { private _waterManager: WaterManager; private _sharedWaterCache: SharedWaterCache; private _teamGameSpawnAreas: TeamGameSpawnAreas | undefined; + /** Tiles from nuke blast radii this tick, drained by the renderer. */ + private _nukeImpactQueue: TileRef[] = []; constructor( private _humans: PlayerInfo[], @@ -289,6 +291,16 @@ export class GameImpl implements Game { this._waterManager.queueTile(tile); } + queueNukeImpact(tile: TileRef): void { + this._nukeImpactQueue.push(tile); + } + + drainNukeImpacts(): TileRef[] { + const tiles = this._nukeImpactQueue; + this._nukeImpactQueue = []; + return tiles; + } + unit(id: number): Unit | undefined { return this._unitMap.get(id); } diff --git a/src/core/game/GameMapLoader.ts b/src/core/game/GameMapLoader.ts index f57bc9d932..21d0f3b0ba 100644 --- a/src/core/game/GameMapLoader.ts +++ b/src/core/game/GameMapLoader.ts @@ -11,4 +11,6 @@ export interface MapData { map16xBin: () => Promise; manifest: () => Promise; webpPath: string; + /** Load a map layer PNG by layer id. Returns an ImageBitmap. */ + layerPng: (layerId: string) => Promise; } diff --git a/src/core/game/GameUpdates.ts b/src/core/game/GameUpdates.ts index fd93aa47ee..493da2770e 100644 --- a/src/core/game/GameUpdates.ts +++ b/src/core/game/GameUpdates.ts @@ -64,6 +64,12 @@ export interface GameUpdateViewData { playerNameViewData?: Record; tickExecutionDuration?: number; pendingTurns?: number; + /** + * Packed tile refs that were inside a nuke blast radius this tick. + * Used by the renderer to mark nukeable layer tiles as destroyed. + * Absent when no nukes detonated. + */ + packedNukeImpacts?: Uint32Array; } export interface ErrorUpdate { diff --git a/src/core/game/Maps.gen.ts b/src/core/game/Maps.gen.ts index 7b7f5b61c5..6eb216ac26 100644 --- a/src/core/game/Maps.gen.ts +++ b/src/core/game/Maps.gen.ts @@ -176,6 +176,8 @@ export interface MapInfo { themes?: string[]; /** Custom tribe entry: a string (random spawn) or an object with name and coordinates. */ customTribes?: CustomTribe[]; + /** Map layers rendered between terrain and territory. */ + layers?: MapLayer[]; } export interface CustomTribe { @@ -183,6 +185,17 @@ export interface CustomTribe { coordinates?: [number, number]; } +export type LayerPlacement = "land" | "water"; + +export interface MapLayer { + /** Unique identifier — also the PNG filename (without extension). */ + id: string; + /** Whether the layer sits on land or water tiles. */ + placement: LayerPlacement; + /** If true, the layer is permanently destroyed in nuke impact radii. */ + nukeable?: boolean; +} + export const maps: readonly MapInfo[] = [ { id: "Achiran", diff --git a/src/core/game/TerrainMapLoader.ts b/src/core/game/TerrainMapLoader.ts index 69e8b98882..5734ece362 100644 --- a/src/core/game/TerrainMapLoader.ts +++ b/src/core/game/TerrainMapLoader.ts @@ -8,6 +8,10 @@ export type TerrainMapData = { gameMap: GameMap; miniGameMap: GameMap; teamGameSpawnAreas?: TeamGameSpawnAreas; + /** Map layers from the manifest, if any. */ + layers?: MapLayer[]; + /** Pre-loaded layer PNG images keyed by layer id. */ + layerImages?: Map; }; const loadedMaps = new Map(); @@ -29,6 +33,19 @@ export interface MapManifest { // the remainder is generated procedurally. additionalNations?: AdditionalNation[]; teamGameSpawnAreas?: TeamGameSpawnAreas; + /** Optional map layers rendered between terrain and territory. */ + layers?: MapLayer[]; +} + +export type LayerPlacement = "land" | "water"; + +export interface MapLayer { + /** Unique identifier — also the PNG filename (without extension). */ + id: string; + /** Whether the layer sits on land or water tiles. */ + placement: LayerPlacement; + /** If true, the layer is permanently destroyed in nuke impact radii. */ + nukeable?: boolean; } export interface Nation { @@ -47,6 +64,9 @@ export async function loadTerrainMap( map: GameMapType, mapSize: GameMapSize, terrainMapFileLoader: GameMapLoader, + /** Whether to load layer PNG images inline. The Web Worker path should + * pass false — it never renders layers and should not retain ImageBitmaps. */ + loadImages: boolean = true, ): Promise { const cacheKey = `${map}:${mapSize}`; const cached = loadedMaps.get(cacheKey); @@ -101,17 +121,104 @@ export async function loadTerrainMap( teamGameSpawnAreas = scaled; } + const layers = manifest.layers; + + // Validate layer placements 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")`, + ); + } + } + } + + // Load layer PNG images if requested and the manifest defines layers. + // For Compact maps, downsample to map4x dimensions to match the game map. + // When loadImages=false (e.g. Web Worker), skip image loading — the caller + // can use loadLayerImages() separately. + let layerImages: Map | undefined; + if (loadImages && layers && layers.length > 0) { + layerImages = new Map(); + const compactW = + mapSize === GameMapSize.Compact ? manifest.map4x.width : undefined; + const compactH = + mapSize === GameMapSize.Compact ? manifest.map4x.height : undefined; + await Promise.all( + layers.map(async (layer) => { + try { + let img = await mapFiles.layerPng(layer.id); + if (compactW !== undefined && compactH !== undefined) { + img = await createImageBitmap(img, { + resizeWidth: compactW, + resizeHeight: compactH, + resizeQuality: "high", + }); + } + layerImages!.set(layer.id, img); + } catch (e) { + console.warn( + `[MapLoader] Failed to load layer "${layer.id}" for map ${map}: ${e}`, + ); + } + }), + ); + } + const result = { nations: manifest.nations, additionalNations: manifest.additionalNations ?? [], gameMap: gameMap, miniGameMap: miniMap, teamGameSpawnAreas, + layers, + layerImages, }; loadedMaps.set(cacheKey, result); return result; } +/** + * Load layer PNG images for a map that already has layer definitions. + * Call this off the critical path (after the game has started) and pass + * the result to `Renderer.setMapLayers()`. + */ +export async function loadLayerImages( + map: GameMapType, + mapSize: GameMapSize, + terrainMapFileLoader: GameMapLoader, + layers: MapLayer[], +): Promise> { + const mapFiles = terrainMapFileLoader.getMapData(map); + const manifest = await mapFiles.manifest(); + const images = new Map(); + const compactW = + mapSize === GameMapSize.Compact ? manifest.map4x.width : undefined; + const compactH = + mapSize === GameMapSize.Compact ? manifest.map4x.height : undefined; + await Promise.all( + layers.map(async (layer) => { + try { + let img = await mapFiles.layerPng(layer.id); + if (compactW !== undefined && compactH !== undefined) { + img = await createImageBitmap(img, { + resizeWidth: compactW, + resizeHeight: compactH, + resizeQuality: "high", + }); + } + images.set(layer.id, img); + } catch (e) { + console.warn( + `[MapLoader] Failed to load layer "${layer.id}" for map ${map}: ${e}`, + ); + } + }), + ); + return images; +} + export async function genTerrainFromBin( mapData: MapMetadata, data: Uint8Array, diff --git a/src/core/worker/Worker.worker.ts b/src/core/worker/Worker.worker.ts index 6a5acb8e63..35c157652a 100644 --- a/src/core/worker/Worker.worker.ts +++ b/src/core/worker/Worker.worker.ts @@ -119,6 +119,9 @@ function sendGameUpdateBatch(gameUpdates: GameUpdateViewData[]): void { if (gu.packedAttackUpdates) { transfers.push(gu.packedAttackUpdates.buffer); } + if (gu.packedNukeImpacts) { + transfers.push(gu.packedNukeImpacts.buffer); + } } ctx.postMessage( diff --git a/tests/MapConsistency.test.ts b/tests/MapConsistency.test.ts index 207d58586a..c4dfa7beb7 100644 --- a/tests/MapConsistency.test.ts +++ b/tests/MapConsistency.test.ts @@ -7,6 +7,7 @@ import { MapInfo, maps, } from "../src/core/game/Game"; +import { validateLayer } from "./util/layerValidation"; // ── Helpers ────────────────────────────────────────────────────────────────── @@ -80,7 +81,7 @@ function normalizeCustomTribes(raw: unknown): CustomTribe[] | undefined { // ── Tests ──────────────────────────────────────────────────────────────────── describe("Map consistency", () => { - test("Every GameMapType has map-generator assets (image.png + info.json only)", () => { + test("Every GameMapType has map-generator assets (image.png + info.json + layer PNGs only)", () => { const errors: string[] = []; for (const key of allMapKeys) { const folder = toFolderName(key); @@ -94,15 +95,39 @@ describe("Map consistency", () => { } const files = fs.readdirSync(dir).sort(); - const expected = ["image.png", "info.json"]; - if ( - files.length !== expected.length || - !files.every((f, i) => f === expected[i]) - ) { + // image.png and info.json are always required. + if (!files.includes("image.png")) { errors.push( - `${key}: expected [${expected.join(", ")}] but found [${files.join(", ")}]`, + `${key}: missing "image.png" in map-generator/assets/maps/${folder}/`, ); } + if (!files.includes("info.json")) { + errors.push( + `${key}: missing "info.json" in map-generator/assets/maps/${folder}/`, + ); + } + + // Build the set of expected PNGs: image.png + layer PNGs. + const info = readInfoJson(key); + const layers = (info?.layers as Array<{ id: string }> | undefined) ?? []; + const allowedPngs = new Set(["image.png"]); + for (const layer of layers) { + allowedPngs.add(`${layer.id}.png`); + } + + for (const file of files) { + if (file === "info.json") continue; + if (file.endsWith(".png") && !allowedPngs.has(file)) { + errors.push( + `${key}: unexpected file "${file}" in map-generator/assets/maps/${folder}/`, + ); + } + if (!file.endsWith(".png") && file !== "info.json") { + errors.push( + `${key}: unexpected file "${file}" in map-generator/assets/maps/${folder}/`, + ); + } + } } if (errors.length > 0) { throw new Error("Map generator asset violations:\n" + errors.join("\n")); @@ -433,4 +458,165 @@ describe("Map consistency", () => { ); } }); + + // ── Layer validation ──────────────────────────────────────────────────── + + test("Layer definitions in info.json are valid", () => { + const errors: string[] = []; + + for (const key of allMapKeys) { + const info = readInfoJson(key); + if (info === null) continue; + const layers = info.layers as + | Array<{ id: string; placement: string; nukeable?: boolean }> + | undefined; + if (!layers || !Array.isArray(layers)) continue; + + const ids = new Set(); + for (let i = 0; i < layers.length; i++) { + errors.push(...validateLayer(layers[i], i, key, ids)); + } + } + if (errors.length > 0) { + throw new Error( + "Layer definition violations in info.json:\n" + errors.join("\n"), + ); + } + }); + + test("Layer PNGs exist in map-generator and resources for every defined layer", () => { + const errors: string[] = []; + + for (const key of allMapKeys) { + const info = readInfoJson(key); + if (info === null) continue; + const layers = info.layers as + | Array<{ id: string; placement: string }> + | undefined; + if (!layers || !Array.isArray(layers)) continue; + + const folder = toFolderName(key); + const genDir = path.join(MAP_GEN_MAPS, folder); + const resDir = path.join(RESOURCES_MAPS, folder); + + for (const layer of layers) { + const genPng = path.join(genDir, `${layer.id}.png`); + if (!fs.existsSync(genPng)) { + errors.push( + `${key}: layer PNG "${layer.id}.png" missing in map-generator/assets/maps/${folder}/`, + ); + } + const resPng = path.join(resDir, `${layer.id}.png`); + if (!fs.existsSync(resPng)) { + errors.push( + `${key}: layer PNG "${layer.id}.png" missing in resources/maps/${folder}/`, + ); + } + } + } + if (errors.length > 0) { + throw new Error("Missing layer PNGs:\n" + errors.join("\n")); + } + }); + + test("No unreferenced PNGs in map-generator asset folders", () => { + const errors: string[] = []; + + for (const key of allMapKeys) { + const info = readInfoJson(key); + if (info === null) continue; + const folder = toFolderName(key); + const genDir = path.join(MAP_GEN_MAPS, folder); + + const layers = (info.layers as Array<{ id: string }> | undefined) ?? []; + const allowedPngs = new Set(["image.png"]); + for (const layer of layers) { + allowedPngs.add(`${layer.id}.png`); + } + + const files = fs.readdirSync(genDir); + for (const file of files) { + if (file.endsWith(".png") && !allowedPngs.has(file)) { + errors.push( + `${key}: unexpected PNG "${file}" in map-generator/assets/maps/${folder}/ (not image.png or a defined layer)`, + ); + } + } + } + if (errors.length > 0) { + throw new Error("Unreferenced PNGs:\n" + errors.join("\n")); + } + }); + + test("Layers in manifest.json match info.json", () => { + const errors: string[] = []; + + for (const key of allMapKeys) { + const info = readInfoJson(key); + const manifestPath = path.join( + RESOURCES_MAPS, + toFolderName(key), + "manifest.json", + ); + if (info === null || !fs.existsSync(manifestPath)) continue; + + const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); + const infoLayers = + (info.layers as Array> | undefined) ?? []; + const manifestLayers = + (manifest.layers as Array> | undefined) ?? []; + + if (infoLayers.length !== manifestLayers.length) { + errors.push( + `${key}: "layers" count mismatch — info.json has ${infoLayers.length}, manifest.json has ${manifestLayers.length}`, + ); + continue; + } + + for (let i = 0; i < infoLayers.length; i++) { + // Compare by serializing sorted keys (Go marshals keys alphabetically). + const normalize = (obj: Record) => + JSON.stringify(obj, Object.keys(obj).sort()); + if (normalize(infoLayers[i]) !== normalize(manifestLayers[i])) { + errors.push( + `${key}: layers[${i}] mismatch — info.json ${JSON.stringify(infoLayers[i])} vs manifest.json ${JSON.stringify(manifestLayers[i])}`, + ); + } + } + } + if (errors.length > 0) { + throw new Error( + "Layer data mismatches between info.json and manifest.json:\n" + + errors.join("\n"), + ); + } + }); + + test("Layer names exist in en.json map_layers section", () => { + const enContent = JSON.parse(fs.readFileSync(EN_JSON, "utf8")); + const mapLayersSection = + (enContent.map_layers as Record | undefined) ?? {}; + + const errors: string[] = []; + for (const key of allMapKeys) { + const info = readInfoJson(key); + if (info === null) continue; + const layers = info.layers as Array<{ id: string }> | undefined; + if (!layers || !Array.isArray(layers)) continue; + + for (const layer of layers) { + if (mapLayersSection[layer.id] === undefined) { + errors.push( + `${key}: layer "${layer.id}" is missing from en.json map_layers section`, + ); + } + } + } + if (errors.length > 0) { + throw new Error( + "Layer names missing from en.json (run `npm run gen-maps`):\n" + + errors.join("\n"), + ); + } + }); }); diff --git a/tests/MapLayers.test.ts b/tests/MapLayers.test.ts new file mode 100644 index 0000000000..efcabc3f10 --- /dev/null +++ b/tests/MapLayers.test.ts @@ -0,0 +1,186 @@ +import { describe, expect, test } from "vitest"; +import { GraphicsOverridesSchema } from "../src/client/render/gl/GraphicsOverrides"; +import type { MapLayer } from "../src/core/game/TerrainMapLoader"; +import { validateLayer } from "./util/layerValidation"; + +describe("Map layer feature", () => { + describe("GraphicsOverridesSchema", () => { + test("accepts mapLayerVisibility overrides", () => { + const cases = [ + { mapLayerVisibility: {} }, + { mapLayerVisibility: { forests: true } }, + { mapLayerVisibility: { forests: false } }, + { mapLayerVisibility: { forests: true, deserts: false } }, + ]; + for (const c of cases) { + expect(GraphicsOverridesSchema.safeParse(c).success).toBe(true); + } + }); + + test("rejects invalid mapLayerVisibility value types", () => { + expect( + GraphicsOverridesSchema.safeParse({ + mapLayerVisibility: { forests: "yes" }, + }).success, + ).toBe(false); + expect( + GraphicsOverridesSchema.safeParse({ + mapLayerVisibility: { forests: 1 }, + }).success, + ).toBe(false); + }); + }); + + describe("MapLayer type", () => { + test("layer with all fields", () => { + const layer: MapLayer = { + id: "forests", + placement: "land", + nukeable: true, + }; + expect(layer.id).toBe("forests"); + expect(layer.placement).toBe("land"); + expect(layer.nukeable).toBe(true); + }); + + test("layer without nukeable defaults to undefined", () => { + const layer: MapLayer = { + id: "deserts", + placement: "water", + }; + expect(layer.nukeable).toBeUndefined(); + }); + + test("placement must be land or water", () => { + const landLayer: MapLayer = { id: "a", placement: "land" }; + const waterLayer: MapLayer = { id: "b", placement: "water" }; + expect(landLayer.placement).toBe("land"); + expect(waterLayer.placement).toBe("water"); + }); + }); + + describe("Layer validation rules", () => { + test("valid id is accepted", () => { + const seen = new Set(); + const errors = validateLayer( + { id: "forests", placement: "land" }, + 0, + "test", + seen, + ); + expect(errors).toHaveLength(0); + }); + + test("hyphenated id is accepted", () => { + const seen = new Set(); + const errors = validateLayer( + { id: "dense-forests", placement: "land" }, + 0, + "test", + seen, + ); + expect(errors).toHaveLength(0); + }); + + test("numeric id is accepted", () => { + const seen = new Set(); + const errors = validateLayer( + { id: "123", placement: "water" }, + 0, + "test", + seen, + ); + expect(errors).toHaveLength(0); + }); + + test("id with spaces is rejected", () => { + const seen = new Set(); + const errors = validateLayer( + { id: "my layer", placement: "land" }, + 0, + "test", + seen, + ); + expect(errors.length).toBeGreaterThan(0); + expect(errors[0]).toContain("alphanumeric"); + }); + + test("id with underscores is rejected", () => { + const seen = new Set(); + const errors = validateLayer( + { id: "layer_id", placement: "land" }, + 0, + "test", + seen, + ); + expect(errors.length).toBeGreaterThan(0); + }); + + test("id with dots is rejected", () => { + const seen = new Set(); + const errors = validateLayer( + { id: "layer.id", placement: "land" }, + 0, + "test", + seen, + ); + expect(errors.length).toBeGreaterThan(0); + }); + + test("empty id is rejected", () => { + const seen = new Set(); + const errors = validateLayer( + { id: "", placement: "land" }, + 0, + "test", + seen, + ); + expect(errors.some((e) => e.includes("must not be empty"))).toBe(true); + }); + + test("reserved id 'image' is rejected", () => { + const seen = new Set(); + const errors = validateLayer( + { id: "image", placement: "land" }, + 0, + "test", + seen, + ); + expect(errors.some((e) => e.includes("reserved"))).toBe(true); + }); + + test("duplicate id is rejected", () => { + const seen = new Set(); + seen.add("forests"); + const errors = validateLayer( + { id: "forests", placement: "land" }, + 1, + "test", + seen, + ); + expect(errors.some((e) => e.includes("duplicate"))).toBe(true); + }); + + test("invalid placement 'air' is rejected", () => { + const seen = new Set(); + const errors = validateLayer( + { id: "clouds", placement: "air" }, + 0, + "test", + seen, + ); + expect(errors.some((e) => e.includes("must be"))).toBe(true); + }); + + test("valid placements 'land' and 'water' are accepted", () => { + const seen1 = new Set(); + expect( + validateLayer({ id: "a", placement: "land" }, 0, "test", seen1), + ).toHaveLength(0); + const seen2 = new Set(); + expect( + validateLayer({ id: "b", placement: "water" }, 0, "test", seen2), + ).toHaveLength(0); + }); + }); +}); diff --git a/tests/core/executions/NukeExecution.test.ts b/tests/core/executions/NukeExecution.test.ts index fdc8ba961c..217a64bf94 100644 --- a/tests/core/executions/NukeExecution.test.ts +++ b/tests/core/executions/NukeExecution.test.ts @@ -244,4 +244,124 @@ describe("NukeExecution", () => { expect(player.isTraitor()).toBe(true); expect(player.isAlliedWith(otherPlayer)).toBe(false); }); + + test("drainNukeImpacts returns all queued tiles after detonation and empty on subsequent drain", () => { + player.buildUnit(UnitType.MissileSilo, game.ref(1, 1), {}); + + // No nukes yet — drain should be empty. + expect(game.drainNukeImpacts()).toHaveLength(0); + + game.addExecution( + new NukeExecution( + UnitType.AtomBomb, + player, + game.ref(50, 50), + game.ref(1, 1), + ), + ); + executeTicks(game, 200); + + // After detonation, drainNukeImpacts should return all blast-radius tiles. + const impacts = game.drainNukeImpacts(); + expect(impacts.length).toBeGreaterThan(0); + + // The target tile (50,50) must be among the impacted tiles. + const targetRef = game.ref(50, 50); + expect(impacts).toContain(targetRef); + + // With inner=outer=10 the blast is a filled circle of radius 10. + // pi*10^2 ~= 314 tiles; require at least 200 (conservative lower bound + // accounting for impassable terrain and map edges). + expect(impacts.length).toBeGreaterThanOrEqual(200); + + // Every returned tile ref should be a valid number. + for (const ref of impacts) { + expect(typeof ref).toBe("number"); + expect(ref).toBeGreaterThanOrEqual(0); + } + + // A second drain should be empty (queue was consumed). + expect(game.drainNukeImpacts()).toHaveLength(0); + }); + + test("drainNukeImpacts returns queued tiles for HydrogenBomb detonation", () => { + player.buildUnit(UnitType.MissileSilo, game.ref(1, 1), {}); + + expect(game.drainNukeImpacts()).toHaveLength(0); + + game.addExecution( + new NukeExecution( + UnitType.HydrogenBomb, + player, + game.ref(50, 50), + game.ref(1, 1), + ), + ); + executeTicks(game, 300); + + const impacts = game.drainNukeImpacts(); + expect(impacts.length).toBeGreaterThan(0); + + // Target tile must be present. + expect(impacts).toContain(game.ref(50, 50)); + + // HydrogenBomb has a larger blast radius than AtomBomb. + expect(impacts.length).toBeGreaterThanOrEqual(200); + + for (const ref of impacts) { + expect(typeof ref).toBe("number"); + expect(ref).toBeGreaterThanOrEqual(0); + } + + expect(game.drainNukeImpacts()).toHaveLength(0); + }); + + test("drainNukeImpacts returns queued tiles for water nukes", async () => { + const waterGame = await setup( + "big_plains", + { infiniteGold: true, instantBuild: true, waterNukes: true }, + [new PlayerInfo("player", PlayerType.Human, "client_id1", "player_id")], + ); + (waterGame.config() as TestConfig).nukeMagnitudes = vi.fn(() => ({ + inner: 10, + outer: 10, + })); + + const waterPlayer = waterGame.player("player_id"); + waterPlayer.conquer(waterGame.ref(1, 1)); + waterPlayer.buildUnit(UnitType.MissileSilo, waterGame.ref(1, 1), {}); + + expect(waterGame.drainNukeImpacts()).toHaveLength(0); + + waterGame.addExecution( + new NukeExecution( + UnitType.AtomBomb, + waterPlayer, + waterGame.ref(50, 50), + waterGame.ref(1, 1), + ), + ); + executeTicks(waterGame, 200); + + const impacts = waterGame.drainNukeImpacts(); + expect(impacts.length).toBeGreaterThan(0); + + // Target tile must be present. + expect(impacts).toContain(waterGame.ref(50, 50)); + + // A tile on the blast boundary (distance exactly 10 from center). + expect(impacts).toContain(waterGame.ref(40, 50)); + + // With inner=outer=10 the blast is a filled circle of radius 10. + // pi*10^2 ~= 314 tiles; require at least 200 (conservative lower bound + // accounting for impassable terrain and map edges). + expect(impacts.length).toBeGreaterThanOrEqual(200); + + for (const ref of impacts) { + expect(typeof ref).toBe("number"); + expect(ref).toBeGreaterThanOrEqual(0); + } + + expect(waterGame.drainNukeImpacts()).toHaveLength(0); + }); }); diff --git a/tests/perf/fullgame/NodeGameMapLoader.ts b/tests/perf/fullgame/NodeGameMapLoader.ts index 7a4c998fc4..f2c9af35e8 100644 --- a/tests/perf/fullgame/NodeGameMapLoader.ts +++ b/tests/perf/fullgame/NodeGameMapLoader.ts @@ -30,6 +30,9 @@ export class NodeGameMapLoader implements GameMapLoader { fs.readFileSync(path.join(dir, "manifest.json"), "utf8"), ) as MapManifest, webpPath: path.join(dir, "thumbnail.webp"), + layerPng: async (_layerId: string) => { + throw new Error("Layer PNGs are not supported in NodeGameMapLoader"); + }, }; } } diff --git a/tests/util/layerValidation.ts b/tests/util/layerValidation.ts new file mode 100644 index 0000000000..1708b58351 --- /dev/null +++ b/tests/util/layerValidation.ts @@ -0,0 +1,59 @@ +/** + * Shared layer validation logic used by both MapConsistency and MapLayers tests. + * Mirrors the validation in map-generator/codegen.go. + */ + +const VALID_ID_RE = /^[a-zA-Z0-9-]+$/; + +export interface LayerDefinition { + id: unknown; + placement: unknown; + nukeable?: unknown; +} + +/** + * Validate a single layer definition. Returns an array of error strings + * (empty if the layer is valid). `index` and `mapName` are used only for + * error messages. + */ +export function validateLayer( + layer: LayerDefinition, + index: number, + mapName: string, + seenIds: Set, +): string[] { + const errors: string[] = []; + const prefix = `${mapName}: layers[${index}]`; + + if (typeof layer.id !== "string") { + errors.push(`${prefix} "id" must be a string, got ${typeof layer.id}`); + return errors; + } + if (layer.id === "") { + errors.push(`${prefix} "id" must not be empty`); + } + if (layer.id === "image") { + errors.push(`${prefix} "id" must not be "image" (reserved)`); + } + if (layer.id !== "" && !VALID_ID_RE.test(layer.id)) { + errors.push( + `${prefix} "id" (${JSON.stringify(layer.id)}) must be alphanumeric (hyphens allowed)`, + ); + } + if (seenIds.has(layer.id)) { + errors.push(`${prefix} duplicate layer id ${JSON.stringify(layer.id)}`); + } + seenIds.add(layer.id); + if (layer.placement !== "land" && layer.placement !== "water") { + errors.push( + `${prefix} "placement" (${JSON.stringify(layer.placement)}) must be "land" or "water"`, + ); + } + if (layer.nukeable !== undefined && typeof layer.nukeable !== "boolean") { + errors.push( + `${prefix} "nukeable" must be a boolean if present, got ${typeof layer.nukeable}`, + ); + } + + return errors; +}