Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
16 changes: 16 additions & 0 deletions src/client/ClientGameRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -566,6 +566,22 @@ async function createClientGame(
const graphicsListenerAbort = new AbortController();

view.setShowPatterns(userSettings.territoryPatterns());

// Set up map layers if the map defines any.
if (gameMap.layers && gameMap.layerImages) {
view.setMapLayers(gameMap.layers, gameMap.layerImages);
// Apply saved layer visibility overrides from graphics settings.
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);
}
}
}
}

globalThis.addEventListener(
`${USER_SETTINGS_CHANGED_EVENT}:settings.territoryPatterns`,
(e) => view.setShowPatterns((e as CustomEvent<string>).detail === "true"),
Expand Down
18 changes: 18 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,23 @@ 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.
*/
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;
for (const ref of nukedTiles) {
this.view.markLayerTileDestroyed(layer.id, ref);
}
}
}

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
71 changes: 71 additions & 0 deletions src/client/hud/layers/GraphicsSettingsModal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -331,6 +338,33 @@ 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();
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

private currentHighlightFill(): number {
return (
this.userSettings.graphicsOverrides().mapOverlay?.highlightFillBrighten ??
Expand Down Expand Up @@ -1441,6 +1475,43 @@ export class GraphicsSettingsModal extends LitElement implements Controller {
</div>
</div>

${this.mapLayers.length > 0
? html`
<div
class="px-3 py-1 text-xs font-semibold text-slate-400 uppercase tracking-wider mt-2"
>
${translateText("graphics_setting.section_map_layers")}
</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 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">
${this.isLayerVisible(layer.id)
? translateText("user_setting.on")
: translateText("user_setting.off")}
</div>
</button>
`,
)}
`
: ""}

<div
class="px-3 py-1 text-xs font-semibold text-slate-400 uppercase tracking-wider mt-2"
>
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 @@ -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();

Expand Down
Loading
Loading