Skip to content
Open
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
79 changes: 79 additions & 0 deletions map-generator/codegen.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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)
Expand Down
42 changes: 42 additions & 0 deletions map-generator/main.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
package main

import (
"bytes"
"context"
"encoding/json"
"flag"
"fmt"
"image"
_ "image/png"
"log"
"log/slog"
"os"
Expand Down Expand Up @@ -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 {
Expand Down
5 changes: 5 additions & 0 deletions resources/lang/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
49 changes: 48 additions & 1 deletion src/client/ClientGameRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -155,6 +159,7 @@ export function joinLobby(
message.gameMap,
message.gameMapSize,
terrainMapFileLoader,
false, // Layer images loaded off the critical path after game start.
);
resolvePrestart();
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<string>).detail === "true"),
Expand Down
23 changes: 23 additions & 0 deletions src/client/WebGLFrameBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}
Expand Down Expand Up @@ -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;
Expand Down
4 changes: 4 additions & 0 deletions src/client/hud/GameRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down
Loading
Loading