// Assets/_Project/Scripts/UI/Minimap/MinimapTerrainBaker.cs using UnityEngine; using TD.Core; using TD.Gameplay; using TD.Levels; namespace TD.UI.Minimap { /// /// Builds the static terrain layer of the WC3-style minimap as a /// from the baked . One pixel per tile; the UI element scales the /// texture up with Point filtering so the result reads as crisp, chunky map abstraction /// rather than a tiny blurred photograph. /// /// /// The terrain layer represents what cannot change during a match: zone ownership, spawner /// locations, goal locations, and the map shape itself. Towers and enemies are drawn on a /// separate dynamic overlay by , so this texture is baked once at /// level load and never re-baked. /// public static class MinimapTerrainBaker { // ----- Palette ---------------------------------------------------- // Picked for legibility at low resolution. Aim: each tile category reads as a distinct // color block when scaled up 4–8×, even on a Steam Deck screen at running brightness. /// Tiles outside the playable map area. Fully transparent so the panel /// background shows through. private static readonly Color32 OutOfMap = new Color32(0, 0, 0, 0); /// In-map tiles that are not owned by any player and not part of a goal / /// spawner. Dark neutral grey — reads as "playable but unallocated". private static readonly Color32 NeutralInMap = new Color32(46, 46, 51, 255); /// Spawner tiles. Bright cyan — pops against every player zone color. private static readonly Color32 SpawnerTile = new Color32(102, 217, 255, 255); /// Goal tiles. Matches (gold). private static readonly Color32 GoalTile = new Color32(224, 176, 32, 255); // ----- Public API ------------------------------------------------- /// /// Bakes the current level into a Texture2D and returns it. Returns null if the loader /// is not ready. The returned texture has FilterMode.Point and is non-readable after /// bake (CPU-side pixel data is released). /// /// /// Caller is responsible for the texture lifetime: assign it to the minimap element and /// destroy it when no longer needed. Currently holds the only /// reference and lets it live for the duration of the match. /// public static Texture2D Bake(LevelLoader loader) { if (loader == null || !loader.IsLoaded) { Debug.LogWarning("[MinimapTerrainBaker] LevelLoader not ready; cannot bake terrain."); return null; } var data = loader.LevelData; int w = data.GridSize.x; int h = data.GridSize.y; if (w <= 0 || h <= 0) { Debug.LogWarning($"[MinimapTerrainBaker] Invalid grid size {data.GridSize}; cannot bake."); return null; } // RGBA32 supports alpha for out-of-map transparency. mipChain off — the texture // is rendered at near-1:1 in the UI and mipmaps would only blur the abstract look. var tex = new Texture2D(w, h, TextureFormat.RGBA32, mipChain: false, linear: false) { filterMode = FilterMode.Point, wrapMode = TextureWrapMode.Clamp, name = "MinimapTerrain" }; var pixels = new Color32[w * h]; // Pass 1: base tile color from MapArea + Owner. // Texture y=0 is the BOTTOM row in Unity convention. World z and grid y both // increase northward, so a tile at grid-y=0 should land at the bottom of the // texture. That's exactly what `pixels[y * w + x] = ...` gives us, since SetPixels32 // treats index 0 as bottom-left. No flip needed during bake — the flip happens at // display time (UI y-axis points down, world z points up; MinimapView handles it). for (int y = 0; y < h; y++) { for (int x = 0; x < w; x++) { int idx = y * w + x; pixels[idx] = ResolveBaseColor(data, idx); } } // Pass 2: stamp special tile categories on top — spawners and goals override base. // PlayerZones[].LeakExits intentionally NOT painted: leak exits are conceptually // part of the source player's zone (an enemy boundary, not a player-visible // landmark), and visually highlighting them would clutter the minimap. StampZoneSpawners(pixels, data); StampGoals(pixels, data); tex.SetPixels32(pixels); tex.Apply(updateMipmaps: false, makeNoLongerReadable: true); LogBakeSummary(data, w, h); return tex; } // ----- Diagnostics ------------------------------------------------ /// /// One-shot log of the bake breakdown. Useful for diagnosing apparent asymmetries in /// the rendered minimap — a symmetric authoring should produce mirrored counts on /// opposite edges. Per-player min/max columns reveal exactly where each zone starts /// and ends across the grid. /// private static void LogBakeSummary(LevelData data, int w, int h) { int outOfMap = 0, neutralInMap = 0, spawnerTiles = 0, goalTiles = 0; // Per-slot owned-tile bounds (1..9). int[] ownedCount = new int[10]; int[] minX = new int[10]; int[] maxX = new int[10]; int[] minY = new int[10]; int[] maxY = new int[10]; for (int i = 0; i < 10; i++) { minX[i] = int.MaxValue; minY[i] = int.MaxValue; maxX[i] = int.MinValue; maxY[i] = int.MinValue; } for (int y = 0; y < h; y++) for (int x = 0; x < w; x++) { int idx = y * w + x; bool inMap = data.MapAreaGrid != null && data.MapAreaGrid[idx]; if (!inMap) { outOfMap++; continue; } PlayerSlot s = data.OwnerGrid != null ? data.OwnerGrid[idx] : PlayerSlot.None; if (s == PlayerSlot.None) { neutralInMap++; continue; } int si = (int)s; if (si < 0 || si > 9) continue; ownedCount[si]++; if (x < minX[si]) minX[si] = x; if (x > maxX[si]) maxX[si] = x; if (y < minY[si]) minY[si] = y; if (y > maxY[si]) maxY[si] = y; } if (data.PlayerZones != null) foreach (var z in data.PlayerZones) if (z?.Spawners != null) foreach (var sp in z.Spawners) if (sp?.TileArea != null) spawnerTiles += sp.TileArea.Length; if (data.Goals != null) foreach (var g in data.Goals) if (g?.TileArea != null) goalTiles += g.TileArea.Length; var sb = new System.Text.StringBuilder(256); sb.AppendFormat("[MinimapTerrainBaker] '{0}' baked. Grid {1}×{2} origin {3}. ", data.MapName, w, h, data.GridOriginTile); sb.AppendFormat("Tiles: outOfMap={0}, neutralInMap={1}, spawner={2}, goal={3}.", outOfMap, neutralInMap, spawnerTiles, goalTiles); for (int i = 1; i <= 9; i++) { if (ownedCount[i] == 0) continue; sb.AppendFormat(" P{0}={1} (x:{2}–{3}, y:{4}–{5})", i, ownedCount[i], minX[i], maxX[i], minY[i], maxY[i]); } Debug.Log(sb.ToString()); } // ----- Pass implementations --------------------------------------- private static Color32 ResolveBaseColor(LevelData data, int idx) { // MapAreaGrid may be null on levels baked before that field existed; treat as // "not in map" so we render transparent rather than a misleading neutral block. bool inMap = data.MapAreaGrid != null && idx < data.MapAreaGrid.Length && data.MapAreaGrid[idx]; if (!inMap) return OutOfMap; PlayerSlot owner = (data.OwnerGrid != null && idx < data.OwnerGrid.Length) ? data.OwnerGrid[idx] : PlayerSlot.None; if (owner == PlayerSlot.None) return NeutralInMap; return ToZoneColor(PlayerColors.Get(owner)); } private static void StampZoneSpawners(Color32[] pixels, LevelData data) { if (data.PlayerZones == null) return; foreach (var zone in data.PlayerZones) { if (zone == null || zone.Spawners == null) continue; foreach (var spawner in zone.Spawners) { if (spawner == null || spawner.TileArea == null) continue; foreach (var tile in spawner.TileArea) PaintTile(pixels, data, tile, SpawnerTile); } } } private static void StampGoals(Color32[] pixels, LevelData data) { if (data.Goals == null) return; foreach (var goal in data.Goals) { if (goal == null || goal.TileArea == null) continue; foreach (var tile in goal.TileArea) PaintTile(pixels, data, tile, GoalTile); } } private static void PaintTile(Color32[] pixels, LevelData data, Vector2Int worldTile, Color32 color) { int x = worldTile.x - data.GridOriginTile.x; int y = worldTile.y - data.GridOriginTile.y; if (x < 0 || x >= data.GridSize.x || y < 0 || y >= data.GridSize.y) return; pixels[y * data.GridSize.x + x] = color; } // ----- Color helpers ---------------------------------------------- /// /// Translates a saturated player gizmo color into the dimmer, slightly desaturated tone /// used for zone fill on the minimap. The full-saturation original is reserved for the /// player's icons (towers, builders) so units pop against their own zone tint. /// private static Color32 ToZoneColor(Color full) { // Desaturate ~45% toward equal-luminance grey, then darken to ~70% brightness. float lum = full.r * 0.299f + full.g * 0.587f + full.b * 0.114f; float r = Mathf.Lerp(full.r, lum, 0.45f) * 0.70f; float g = Mathf.Lerp(full.g, lum, 0.45f) * 0.70f; float b = Mathf.Lerp(full.b, lum, 0.45f) * 0.70f; return new Color32( (byte)Mathf.RoundToInt(r * 255f), (byte)Mathf.RoundToInt(g * 255f), (byte)Mathf.RoundToInt(b * 255f), 255); } } }