// 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);
}
}
}