UnityTowerDefense/Assets/_Project/Scripts/UI/Minimap/MinimapTerrainBaker.cs
2026-05-10 22:26:55 -07:00

247 lines
11 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Assets/_Project/Scripts/UI/Minimap/MinimapTerrainBaker.cs
using UnityEngine;
using TD.Core;
using TD.Gameplay;
using TD.Levels;
namespace TD.UI.Minimap
{
/// <summary>
/// Builds the static terrain layer of the WC3-style minimap as a <see cref="Texture2D"/>
/// from the baked <see cref="LevelData"/>. 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.
/// </summary>
/// <remarks>
/// 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 <see cref="MinimapView"/>, so this texture is baked once at
/// level load and never re-baked.
/// </remarks>
public static class MinimapTerrainBaker
{
// ----- Palette ----------------------------------------------------
// Picked for legibility at low resolution. Aim: each tile category reads as a distinct
// color block when scaled up 48×, even on a Steam Deck screen at running brightness.
/// <summary>Tiles outside the playable map area. Fully transparent so the panel
/// background shows through.</summary>
private static readonly Color32 OutOfMap = new Color32(0, 0, 0, 0);
/// <summary>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".</summary>
private static readonly Color32 NeutralInMap = new Color32(46, 46, 51, 255);
/// <summary>Spawner tiles. Bright cyan — pops against every player zone color.</summary>
private static readonly Color32 SpawnerTile = new Color32(102, 217, 255, 255);
/// <summary>Goal tiles. Matches <see cref="PlayerColors.Goal"/> (gold).</summary>
private static readonly Color32 GoalTile = new Color32(224, 176, 32, 255);
// ----- Public API -------------------------------------------------
/// <summary>
/// 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).
/// </summary>
/// <remarks>
/// Caller is responsible for the texture lifetime: assign it to the minimap element and
/// destroy it when no longer needed. Currently <see cref="MinimapView"/> holds the only
/// reference and lets it live for the duration of the match.
/// </remarks>
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 ------------------------------------------------
/// <summary>
/// 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.
/// </summary>
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 ----------------------------------------------
/// <summary>
/// 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.
/// </summary>
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);
}
}
}