UnityTowerDefense/Assets/_Project/Scripts/Editor/Levels/LevelBakePipeline.cs

1408 lines
No EOL
63 KiB
C#
Raw 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.

using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
using TD.Core;
namespace TD.Levels.Editor
{
/// <summary>
/// Editor-only bake pipeline that runs the seven-phase algorithm against a
/// <see cref="LevelAuthoring"/> in the active scene and writes the result into the
/// <see cref="LevelData"/> ScriptableObject pointed to by <c>authoring.targetAsset</c>.
/// </summary>
/// <remarks>
/// Phases:
/// 1. Volume Discovery (scoped + orphan scan, canonical sort)
/// 2. Pre-Validation (cheap checks, all errors collected before aborting)
/// 3. Tile Rasterization (per-volume tile sets, per-zone unions, per-goal sets)
/// 4. Grid Composition (PlacementGrid + WalkabilityGrid; "Invalid wins")
/// 5. Spatial Validation (overlap, adjacency, connectivity)
/// 6. Output Assembly (in-memory LevelData; thumbnail; hash; flat grids; nested data)
/// 7. Commit (field-by-field copy onto target asset, save, refresh)
///
/// Failed bakes (any hard error in any phase) do NOT modify the existing target asset on
/// disk. Failure state lives only in <see cref="LevelAuthoringEditorState"/>'s transient
/// flag, not on the asset itself.
/// </remarks>
public static class LevelBakePipeline
{
// -------------------------------------------------------------------
// Public entry point
// -------------------------------------------------------------------
/// <summary>
/// Runs the bake. Returns true on success (with or without warnings); false on hard error.
/// On failure, <paramref name="authoring"/>.targetAsset is left untouched on disk.
/// </summary>
public static bool Bake(LevelAuthoring authoring)
{
var report = new BakeReport();
if (authoring == null)
{
Debug.LogError("[LevelBake] Cannot bake: LevelAuthoring is null.");
return false;
}
// -- Phase 1: Volume Discovery -------------------------------
var ctx = new BakeContext { Authoring = authoring };
Phase1_Discover(ctx, report);
if (report.HasErrors) return Abort(report);
// -- Phase 2: Pre-Validation ---------------------------------
Phase2_PreValidate(ctx, report);
if (report.HasErrors) return Abort(report);
// -- Phase 3: Tile Rasterization -----------------------------
Phase3_Rasterize(ctx, report);
if (report.HasErrors) return Abort(report);
// -- Phase 4: Grid Composition -------------------------------
Phase4_ComposeGrids(ctx, report);
if (report.HasErrors) return Abort(report);
// -- Phase 5: Spatial Validation -----------------------------
Phase5_SpatialValidate(ctx, report);
if (report.HasErrors) return Abort(report);
// -- Phase 6: Output Assembly --------------------------------
Phase6_AssembleOutput(ctx, report);
if (report.HasErrors) return Abort(report);
// -- Phase 7: Commit -----------------------------------------
Phase7_Commit(ctx, report);
if (report.HasErrors) return Abort(report);
// Success path: log warnings (if any) and a summary.
LevelAuthoringEditorState.HasUncommittedFailure = false;
BakeOutcome outcome = report.WarningCount > 0
? BakeOutcome.SuccessWithWarnings
: BakeOutcome.Success;
Debug.Log($"[LevelBake] {outcome} — '{ctx.Authoring.mapName}' " +
$"({ctx.PlayerZoneVolumes.Count} player zones, {ctx.SpawnerVolumes.Count} spawners, " +
$"{ctx.LeakExitVolumes.Count} leak exits, {ctx.GoalVolumes.Count} goals, " +
$"{ctx.MapAreaVolumes.Count} map areas; " +
$"grid {ctx.GridSize.x}×{ctx.GridSize.y}; {report.WarningCount} warnings)");
if (report.WarningCount > 0)
{
Debug.LogWarning($"[LevelBake] Bake produced {report.WarningCount} warning(s):\n{report.Format()}");
}
return true;
}
// Helper: log full report as error and set the transient failure flag.
private static bool Abort(BakeReport report)
{
LevelAuthoringEditorState.HasUncommittedFailure = true;
Debug.LogError($"[LevelBake] FAILED. Existing target asset (if any) was not modified.\n{report.Format()}");
return false;
}
// -------------------------------------------------------------------
// PHASE 1 — Volume Discovery
// -------------------------------------------------------------------
private static void Phase1_Discover(BakeContext ctx, BakeReport report)
{
// Defensive — already null-checked at entry, but Phase 1 is also responsible per the spec.
if (ctx.Authoring == null)
{
report.Error("P1", "LevelAuthoring reference is null.");
return;
}
var rootTransform = ctx.Authoring.transform;
// Scoped scan: only volumes parented under _LevelAuthoring's transform are part of the bake.
ctx.PlayerZoneVolumes = rootTransform.GetComponentsInChildren<PlayerZoneVolume>(includeInactive: false).ToList();
ctx.SpawnerVolumes = rootTransform.GetComponentsInChildren<SpawnerVolume>(includeInactive: false).ToList();
ctx.LeakExitVolumes = rootTransform.GetComponentsInChildren<LeakExitVolume>(includeInactive: false).ToList();
ctx.GoalVolumes = rootTransform.GetComponentsInChildren<GoalVolume>(includeInactive: false).ToList();
ctx.MapAreaVolumes = rootTransform.GetComponentsInChildren<MapAreaVolume>(includeInactive: false).ToList();
ctx.AllVolumes = new List<VolumeBase>();
ctx.AllVolumes.AddRange(ctx.PlayerZoneVolumes);
ctx.AllVolumes.AddRange(ctx.SpawnerVolumes);
ctx.AllVolumes.AddRange(ctx.LeakExitVolumes);
ctx.AllVolumes.AddRange(ctx.GoalVolumes);
ctx.AllVolumes.AddRange(ctx.MapAreaVolumes);
// Full-scene scan: catch volumes that exist but aren't parented under _LevelAuthoring.
var orphanCandidates = UnityEngine.Object.FindObjectsByType<VolumeBase>(FindObjectsInactive.Exclude);
var includedSet = new HashSet<VolumeBase>(ctx.AllVolumes);
foreach (var v in orphanCandidates)
{
if (v == null || includedSet.Contains(v)) continue;
Debug.LogWarning($"[LevelAuthoring] Orphaned volume excluded from bake: '{v.name}' " +
$"({v.GetType().Name}) is not parented under '{rootTransform.name}' and was skipped.");
}
// Canonical sort by hierarchy path with sibling-index disambiguation. Used for both the
// hash input ordering and stable iteration in subsequent phases.
ctx.AllVolumes.Sort((a, b) => string.CompareOrdinal(CanonicalPath(a, rootTransform), CanonicalPath(b, rootTransform)));
ctx.PlayerZoneVolumes.Sort((a, b) => string.CompareOrdinal(CanonicalPath(a, rootTransform), CanonicalPath(b, rootTransform)));
ctx.SpawnerVolumes.Sort((a, b) => string.CompareOrdinal(CanonicalPath(a, rootTransform), CanonicalPath(b, rootTransform)));
ctx.LeakExitVolumes.Sort((a, b) => string.CompareOrdinal(CanonicalPath(a, rootTransform), CanonicalPath(b, rootTransform)));
ctx.GoalVolumes.Sort((a, b) => string.CompareOrdinal(CanonicalPath(a, rootTransform), CanonicalPath(b, rootTransform)));
ctx.MapAreaVolumes.Sort((a, b) => string.CompareOrdinal(CanonicalPath(a, rootTransform), CanonicalPath(b, rootTransform)));
}
// Builds the canonical path string for a transform, relative to the root, with sibling
// indices appended to each segment. Example: "Spawners/Spawner[3]/Inner[0]".
// Used for both the canonical sort and the hash input.
private static string CanonicalPath(Component component, Transform root)
{
if (component == null) return "<null>";
var t = component.transform;
var segments = new List<string>();
while (t != null && t != root)
{
int siblingIndex = t.GetSiblingIndex();
segments.Add($"{t.name}[{siblingIndex}]");
t = t.parent;
}
segments.Reverse();
return string.Join("/", segments);
}
// -------------------------------------------------------------------
// PHASE 2 — Pre-Validation (cheap checks, no tile rasterization yet)
// -------------------------------------------------------------------
private static readonly HashSet<int> ValidPlayerCounts = new HashSet<int> { 1, 2, 3, 4, 5, 9 };
private static void Phase2_PreValidate(BakeContext ctx, BakeReport report)
{
var auth = ctx.Authoring;
// P2-13: targetAsset is non-null
if (auth.targetAsset == null)
{
report.Error("P2-13", "LevelAuthoring.targetAsset is not set. Assign a LevelData ScriptableObject before baking.");
}
// P2-14: mapName is non-empty
if (string.IsNullOrWhiteSpace(auth.mapName))
{
report.Error("P2-14", "Map name is empty.");
}
// P2-15: playerCount is in {1, 2, 3, 4, 5, 9}
if (!ValidPlayerCounts.Contains(auth.playerCount))
{
report.Error("P2-15", $"Player count must be one of {{1, 2, 3, 4, 5, 9}}; got {auth.playerCount}.");
}
// P2-2.5: expectedGoalCount >= 1
if (auth.expectedGoalCount < 1)
{
report.Error("P2-2.5", $"expectedGoalCount must be ≥ 1; got {auth.expectedGoalCount}.");
}
// P2-2: GoalVolume count equals expectedGoalCount
if (ctx.GoalVolumes.Count != auth.expectedGoalCount)
{
report.Error("P2-2", $"Found {ctx.GoalVolumes.Count} GoalVolume(s); LevelAuthoring expects {auth.expectedGoalCount}.");
}
// P2-22: at least one MapAreaVolume is present.
// The map area is required (not inferred) so that gameplay bounds stay decoupled from
// visual geometry. See "Lessons Learned" — inferring playable bounds from terrain
// mesh extent was deliberately rejected.
if (ctx.MapAreaVolumes.Count == 0)
{
report.Error("P2-22", "No MapAreaVolume found. Every map must have at least one " +
"MapAreaVolume defining the playable bounds (where the builder " +
"can move and the camera can pan).");
}
// P2-16: author non-empty (soft warning)
if (string.IsNullOrWhiteSpace(auth.author))
{
report.Warning("P2-16", "Author field is empty.");
}
// P2-17: mapDescription non-empty (soft warning)
if (string.IsNullOrWhiteSpace(auth.mapDescription))
{
report.Warning("P2-17", "Map description is empty.");
}
// -- Per-volume checks --
// Build the set of declared player zones (owners present in the scene).
var declaredOwners = new HashSet<PlayerSlot>();
foreach (var z in ctx.PlayerZoneVolumes) declaredOwners.Add(z.owner);
ctx.DeclaredOwners = declaredOwners;
// Build the expected set of owners based on playerCount (Player1..PlayerN).
var expectedOwners = new HashSet<PlayerSlot>();
if (ValidPlayerCounts.Contains(auth.playerCount))
{
for (int i = 1; i <= auth.playerCount; i++) expectedOwners.Add((PlayerSlot)(byte)i);
}
ctx.ExpectedOwners = expectedOwners;
// P2-1: every expected PlayerSlot has at least one PlayerZoneVolume
foreach (var slot in expectedOwners)
{
if (!declaredOwners.Contains(slot))
{
report.Error("P2-1", $"No PlayerZoneVolume found for {slot}.");
}
}
// P2-6: no volume declares a PlayerSlot exceeding playerCount
foreach (var z in ctx.PlayerZoneVolumes)
{
if (z.owner != PlayerSlot.None && (byte)z.owner > auth.playerCount)
{
report.Error("P2-6", $"PlayerZoneVolume '{z.name}' owner is {z.owner} but playerCount is {auth.playerCount}.");
}
}
foreach (var s in ctx.SpawnerVolumes)
{
if (s.owner != PlayerSlot.None && (byte)s.owner > auth.playerCount)
{
report.Error("P2-6", $"SpawnerVolume '{s.name}' owner is {s.owner} but playerCount is {auth.playerCount}.");
}
}
foreach (var l in ctx.LeakExitVolumes)
{
if (l.sourceZone != PlayerSlot.None && (byte)l.sourceZone > auth.playerCount)
{
report.Error("P2-6", $"LeakExitVolume '{l.name}' sourceZone is {l.sourceZone} but playerCount is {auth.playerCount}.");
}
if (l.target != PlayerSlot.None && (byte)l.target > auth.playerCount)
{
report.Error("P2-6", $"LeakExitVolume '{l.name}' target is {l.target} but playerCount is {auth.playerCount}.");
}
}
// P2-3: every spawner.owner matches an existing player zone
foreach (var s in ctx.SpawnerVolumes)
{
if (!declaredOwners.Contains(s.owner))
{
report.Error("P2-3", $"SpawnerVolume '{s.name}' owner is {s.owner}, which has no PlayerZoneVolume.");
}
}
// P2-4: every leak exit sourceZone matches an existing player zone
// P2-5: every leak exit target matches an existing player zone
foreach (var l in ctx.LeakExitVolumes)
{
if (!declaredOwners.Contains(l.sourceZone))
{
report.Error("P2-4", $"LeakExitVolume '{l.name}' sourceZone is {l.sourceZone}, which has no PlayerZoneVolume.");
}
if (!declaredOwners.Contains(l.target))
{
report.Error("P2-5", $"LeakExitVolume '{l.name}' target is {l.target}, which has no PlayerZoneVolume.");
}
}
// P2-7: spawner IDs unique within their zone
// P2-8: spawner IDs contiguous from 0 (soft warning)
var spawnersByOwner = ctx.SpawnerVolumes
.GroupBy(s => s.owner)
.ToDictionary(g => g.Key, g => g.ToList());
foreach (var kv in spawnersByOwner)
{
var ids = kv.Value.Select(s => s.spawnerIdInZone).ToList();
var idSet = new HashSet<int>();
foreach (var id in ids)
{
if (!idSet.Add(id))
{
report.Error("P2-7", $"Spawner zone {kv.Key} has duplicate spawnerIdInZone={id}.");
}
}
ids.Sort();
for (int i = 0; i < ids.Count; i++)
{
if (ids[i] != i)
{
report.Warning("P2-8", $"Spawner zone {kv.Key} has non-contiguous IDs (sorted: [{string.Join(",", ids)}]). Expected 0..{ids.Count - 1}.");
break;
}
}
}
// P2-11: leak weights for each source zone sum to non-zero
var leaksBySource = ctx.LeakExitVolumes
.GroupBy(l => l.sourceZone)
.ToDictionary(g => g.Key, g => g.ToList());
foreach (var kv in leaksBySource)
{
float total = 0f;
foreach (var l in kv.Value) total += l.weight;
if (total <= 0f)
{
report.Error("P2-11", $"Leak exits for source zone {kv.Key} have non-positive total weight ({total}).");
}
}
// P2-18: every volume's BoxCollider.bounds vertically contains Y=0
foreach (var v in ctx.AllVolumes)
{
var col = v.GetComponent<BoxCollider>();
if (col == null)
{
report.Error("P2-18", $"Volume '{v.name}' has no BoxCollider.");
continue;
}
var b = col.bounds;
if (b.min.y > 0f || b.max.y < 0f)
{
report.Error("P2-18", $"Volume '{v.name}' bounds do not vertically contain Y=0 (Y range: {b.min.y:F2}..{b.max.y:F2}).");
}
}
// P2-19: every volume's transform rotation is zero
foreach (var v in ctx.AllVolumes)
{
Vector3 e = v.transform.eulerAngles;
// Normalize to (-180..180] for comparison.
float dx = NormalizeAngle(e.x);
float dy = NormalizeAngle(e.y);
float dz = NormalizeAngle(e.z);
const float tol = 0.01f;
if (Mathf.Abs(dx) > tol || Mathf.Abs(dy) > tol || Mathf.Abs(dz) > tol)
{
report.Error("P2-19", $"Volume '{v.name}' has non-zero rotation ({e}). Volumes must be axis-aligned.");
}
}
// P2-21: every volume's transform.localScale equals (1, 1, 1) (soft warning)
foreach (var v in ctx.AllVolumes)
{
Vector3 s = v.transform.localScale;
const float tol = 0.0001f;
if (Mathf.Abs(s.x - 1f) > tol || Mathf.Abs(s.y - 1f) > tol || Mathf.Abs(s.z - 1f) > tol)
{
report.Warning("P2-21", $"Volume '{v.name}' has non-unit transform.localScale ({s}). Use BoxCollider.Size for sizing instead of Transform.Scale to avoid tile-snapping issues.");
}
}
// P2-20 is checked in Phase 3 (it requires tile-rasterized aggregate bounds).
}
private static float NormalizeAngle(float a)
{
a = a % 360f;
if (a > 180f) a -= 360f;
else if (a < -180f) a += 360f;
return a;
}
// -------------------------------------------------------------------
// PHASE 3 — Tile Rasterization
// -------------------------------------------------------------------
private static void Phase3_Rasterize(BakeContext ctx, BakeReport report)
{
ctx.PerVolumeTiles = new Dictionary<VolumeBase, HashSet<Vector2Int>>();
ctx.PerOwnerZoneTiles = new Dictionary<PlayerSlot, HashSet<Vector2Int>>();
ctx.PerGoalTiles = new List<HashSet<Vector2Int>>();
ctx.MapAreaTiles = new HashSet<Vector2Int>();
bool initializedAggregate = false;
int aggMinX = 0, aggMinY = 0, aggMaxX = 0, aggMaxY = 0;
foreach (var v in ctx.AllVolumes)
{
var col = v.GetComponent<BoxCollider>();
if (col == null) continue; // Already errored in P2-18
var tiles = new HashSet<Vector2Int>();
VolumeBase.RasterizeBoundsToTiles(col.bounds, t => tiles.Add(t));
if (tiles.Count == 0)
{
// Defensive backstop — most causes are caught by P2-18 (Y=0 containment),
// but a degenerate-size collider (e.g. Size = 0) produces an empty tile set
// that the spec also rejects.
report.Error("P3", $"Volume '{v.name}' rasterized to zero tiles. " +
"Check that the BoxCollider has non-zero Size and its bounds vertically intersect Y=0.");
continue;
}
ctx.PerVolumeTiles[v] = tiles;
// Update aggregate map bounds.
foreach (var t in tiles)
{
if (!initializedAggregate)
{
aggMinX = aggMaxX = t.x;
aggMinY = aggMaxY = t.y;
initializedAggregate = true;
}
else
{
if (t.x < aggMinX) aggMinX = t.x;
if (t.x > aggMaxX) aggMaxX = t.x;
if (t.y < aggMinY) aggMinY = t.y;
if (t.y > aggMaxY) aggMaxY = t.y;
}
}
// Aggregate per-owner zone tile sets.
if (v is PlayerZoneVolume zone)
{
if (!ctx.PerOwnerZoneTiles.TryGetValue(zone.owner, out var ownerSet))
{
ownerSet = new HashSet<Vector2Int>();
ctx.PerOwnerZoneTiles[zone.owner] = ownerSet;
}
foreach (var t in tiles) ownerSet.Add(t);
}
// Goals: one tile set per goal (NOT unioned).
if (v is GoalVolume)
{
ctx.PerGoalTiles.Add(tiles);
}
// Map area: union all MapAreaVolume tiles into a single set. The map area is the
// union of all MapAreaVolumes, so a tile is "in the map" if ANY MapAreaVolume
// covers it.
if (v is MapAreaVolume)
{
foreach (var t in tiles) ctx.MapAreaTiles.Add(t);
}
}
if (!initializedAggregate)
{
report.Error("P3", "No volumes rasterized to any tiles. The map is empty.");
return;
}
ctx.MapMinTile = new Vector2Int(aggMinX, aggMinY);
ctx.MapMaxTile = new Vector2Int(aggMaxX, aggMaxY);
// P2-20: map's tile bounding rect's min corner is (0, 0). Soft warning.
// (Checked here because it requires the rasterized aggregate bounds.)
if (ctx.MapMinTile.x != 0 || ctx.MapMinTile.y != 0)
{
report.Warning("P2-20", $"Map's southwest corner is at tile {ctx.MapMinTile}, not (0, 0). " +
"Convention is for tile (0,0) to be the southwest corner of the map.");
}
}
// -------------------------------------------------------------------
// PHASE 4 — Grid Composition
// -------------------------------------------------------------------
private static void Phase4_ComposeGrids(BakeContext ctx, BakeReport report)
{
ctx.GridOriginTile = ctx.MapMinTile;
ctx.GridSize = new Vector2Int(
ctx.MapMaxTile.x - ctx.MapMinTile.x + 1,
ctx.MapMaxTile.y - ctx.MapMinTile.y + 1);
int width = ctx.GridSize.x;
int height = ctx.GridSize.y;
// 2D arrays during composition; flattened to 1D in Phase 6.
ctx.PlacementGrid2D = new PlacementState[width, height]; // defaults to Outside (0)
ctx.WalkabilityGrid2D = new bool[width, height]; // defaults to false
ctx.MapAreaGrid2D = new bool[width, height]; // defaults to false
// Map-area composition: a tile is "in map" iff any MapAreaVolume covers it. We have
// the union pre-computed in ctx.MapAreaTiles from Phase 3.
foreach (var t in ctx.MapAreaTiles)
{
int gx = t.x - ctx.GridOriginTile.x;
int gy = t.y - ctx.GridOriginTile.y;
ctx.MapAreaGrid2D[gx, gy] = true;
}
// Volume-iterating algorithm: O(total tile-coverage) rather than O(grid * volumes).
foreach (var v in ctx.AllVolumes)
{
if (!ctx.PerVolumeTiles.TryGetValue(v, out var tiles)) continue;
// MapAreaVolume contributes ONLY to the map-area grid (handled above) — not to
// walkability or placement. Buffer tiles (in-map but not gameplay) must remain
// non-walkable so enemies cannot path through them.
if (v is MapAreaVolume) continue;
bool isInvalid = IsInvalidValidity(v);
bool isPlayerZone = v is PlayerZoneVolume;
foreach (var tile in tiles)
{
int gx = tile.x - ctx.GridOriginTile.x;
int gy = tile.y - ctx.GridOriginTile.y;
// bounds always within [0..size) by construction (aggregate min/max inclusive).
// All volume tiles are walkable initially.
ctx.WalkabilityGrid2D[gx, gy] = true;
// Placement composition: "Invalid wins". Once a tile is Restricted, it stays Restricted.
if (isInvalid)
{
ctx.PlacementGrid2D[gx, gy] = PlacementState.Restricted;
}
else if (isPlayerZone && ctx.PlacementGrid2D[gx, gy] != PlacementState.Restricted)
{
ctx.PlacementGrid2D[gx, gy] = PlacementState.Buildable;
}
}
}
}
private static bool IsInvalidValidity(VolumeBase v)
{
// PlayerZoneVolume.placementValidity defaults to Allowed; others default to Invalid.
// We check the field on each subclass.
switch (v)
{
case PlayerZoneVolume pz: return pz.placementValidity == PlacementValidity.Invalid;
case SpawnerVolume sv: return sv.placementValidity == PlacementValidity.Invalid;
case LeakExitVolume lv: return lv.placementValidity == PlacementValidity.Invalid;
case GoalVolume gv: return gv.placementValidity == PlacementValidity.Invalid;
default: return false;
}
}
// -------------------------------------------------------------------
// PHASE 5 — Spatial Validation
// -------------------------------------------------------------------
private static void Phase5_SpatialValidate(BakeContext ctx, BakeReport report)
{
// P5-1: no PlayerZoneVolumes with different owners overlap.
// For each tile in any zone, track which owners cover it.
var tileToOwners = new Dictionary<Vector2Int, HashSet<PlayerSlot>>();
foreach (var z in ctx.PlayerZoneVolumes)
{
if (!ctx.PerVolumeTiles.TryGetValue(z, out var tiles)) continue;
foreach (var t in tiles)
{
if (!tileToOwners.TryGetValue(t, out var owners))
{
owners = new HashSet<PlayerSlot>();
tileToOwners[t] = owners;
}
owners.Add(z.owner);
}
}
foreach (var kv in tileToOwners)
{
if (kv.Value.Count > 1)
{
var ownersList = string.Join(", ", kv.Value);
report.Error("P5-1", $"Tile {kv.Key} is covered by multiple PlayerZoneVolumes with different owners: {ownersList}.");
}
}
// Build per-goal tile lookup. P5-2 and P5-3 use this.
var goalTileToIdx = new Dictionary<Vector2Int, int>();
for (int g = 0; g < ctx.PerGoalTiles.Count; g++)
{
foreach (var t in ctx.PerGoalTiles[g])
{
if (goalTileToIdx.TryGetValue(t, out int otherIdx))
{
// P5-3
if (otherIdx != g)
{
report.Error("P5-3", $"Tile {t} is covered by multiple GoalVolumes (goal #{otherIdx} and goal #{g}).");
}
}
else
{
goalTileToIdx[t] = g;
}
}
}
// P5-2: no PlayerZoneVolume overlaps a GoalVolume
foreach (var t in tileToOwners.Keys)
{
if (goalTileToIdx.ContainsKey(t))
{
report.Error("P5-2", $"Tile {t} is covered by both a PlayerZoneVolume and a GoalVolume.");
}
}
// Build a unified set of all walkable tiles (for BFS connectivity).
var walkableSet = new HashSet<Vector2Int>();
for (int x = 0; x < ctx.GridSize.x; x++)
{
for (int y = 0; y < ctx.GridSize.y; y++)
{
if (ctx.WalkabilityGrid2D[x, y])
{
walkableSet.Add(new Vector2Int(x + ctx.GridOriginTile.x, y + ctx.GridOriginTile.y));
}
}
}
// P5-10: walkability grid is non-empty
if (walkableSet.Count == 0)
{
report.Error("P5-10", "Walkability grid is empty — no tiles are walkable.");
return;
}
// Build the set of "exit" tiles: any leak exit tile, OR any goal tile.
var exitTiles = new HashSet<Vector2Int>();
foreach (var l in ctx.LeakExitVolumes)
{
if (!ctx.PerVolumeTiles.TryGetValue(l, out var tiles)) continue;
foreach (var t in tiles) exitTiles.Add(t);
}
foreach (var t in goalTileToIdx.Keys) exitTiles.Add(t);
// P5-4: every spawner's tiles reach an exit via walkability BFS.
// Use a single shared queue to avoid allocations across spawners.
var bfsQueue = new Queue<Vector2Int>();
var bfsVisited = new HashSet<Vector2Int>();
foreach (var s in ctx.SpawnerVolumes)
{
if (!ctx.PerVolumeTiles.TryGetValue(s, out var spawnerTiles) || spawnerTiles.Count == 0) continue;
bfsQueue.Clear();
bfsVisited.Clear();
foreach (var t in spawnerTiles)
{
bfsQueue.Enqueue(t);
bfsVisited.Add(t);
}
bool reachedExit = false;
while (bfsQueue.Count > 0)
{
var t = bfsQueue.Dequeue();
if (exitTiles.Contains(t)) { reachedExit = true; break; }
// 8-connected with corner-cut prevention — must match the
// runtime PathfindingService / TowerPlacementManager rules.
foreach (var n in GridCoordinates.GetNeighbors8(t))
{
if (bfsVisited.Contains(n)) continue;
if (!walkableSet.Contains(n)) continue;
if (GridCoordinates.IsDiagonal(t, n))
{
GridCoordinates.GetCornerShoulders(t, n,
out var shoulderA, out var shoulderB);
if (!walkableSet.Contains(shoulderA) || !walkableSet.Contains(shoulderB))
continue;
}
bfsVisited.Add(n);
bfsQueue.Enqueue(n);
}
}
if (!reachedExit)
{
report.Error("P5-4", $"SpawnerVolume '{s.name}' (owner {s.owner}, id {s.spawnerIdInZone}) cannot reach any leak exit or goal via walkability.");
}
}
// P5-5: spawner's tiles inside its declared owner's zone (soft warning).
foreach (var s in ctx.SpawnerVolumes)
{
if (!ctx.PerVolumeTiles.TryGetValue(s, out var spawnerTiles)) continue;
if (!ctx.PerOwnerZoneTiles.TryGetValue(s.owner, out var ownerZoneTiles))
{
// owner not declared — already errored in P2-3
continue;
}
foreach (var t in spawnerTiles)
{
if (!ownerZoneTiles.Contains(t))
{
report.Warning("P5-5", $"SpawnerVolume '{s.name}' (owner {s.owner}) has tile {t} outside owner's PlayerZoneVolume coverage.");
break; // only report once per spawner
}
}
}
// P5-6: every leak exit's tiles 4-adjacent to its target zone's tiles.
// P5-7: every leak exit's tiles 4-adjacent to its source zone's tiles.
foreach (var l in ctx.LeakExitVolumes)
{
if (!ctx.PerVolumeTiles.TryGetValue(l, out var leakTiles)) continue;
bool hasSourceAdj = false;
bool hasTargetAdj = false;
ctx.PerOwnerZoneTiles.TryGetValue(l.sourceZone, out var sourceTiles);
ctx.PerOwnerZoneTiles.TryGetValue(l.target, out var targetTiles);
if (sourceTiles != null && targetTiles != null)
{
foreach (var t in leakTiles)
{
foreach (var n in GridCoordinates.GetNeighbors(t))
{
if (!hasSourceAdj && sourceTiles.Contains(n)) hasSourceAdj = true;
if (!hasTargetAdj && targetTiles.Contains(n)) hasTargetAdj = true;
if (hasSourceAdj && hasTargetAdj) break;
}
if (hasSourceAdj && hasTargetAdj) break;
}
}
if (sourceTiles != null && !hasSourceAdj)
{
report.Error("P5-7", $"LeakExitVolume '{l.name}' is not 4-adjacent to its sourceZone {l.sourceZone}.");
}
if (targetTiles != null && !hasTargetAdj)
{
report.Error("P5-6", $"LeakExitVolume '{l.name}' is not 4-adjacent to its target {l.target}.");
}
}
// P5-9: at least one player zone is goal-adjacent.
// P5-8: every player zone has outgoing leak OR is goal-adjacent.
var goalAdjacentZones = new HashSet<PlayerSlot>();
var allGoalTiles = new HashSet<Vector2Int>(goalTileToIdx.Keys);
foreach (var kv in ctx.PerOwnerZoneTiles)
{
bool isGoalAdjacent = false;
foreach (var t in kv.Value)
{
foreach (var n in GridCoordinates.GetNeighbors(t))
{
if (allGoalTiles.Contains(n)) { isGoalAdjacent = true; break; }
}
if (isGoalAdjacent) break;
}
if (isGoalAdjacent) goalAdjacentZones.Add(kv.Key);
}
ctx.GoalAdjacentZones = goalAdjacentZones;
if (goalAdjacentZones.Count == 0)
{
report.Error("P5-9", "No player zone is goal-adjacent. At least one zone must be 4-adjacent to a GoalVolume.");
}
// P5-8: zones with neither outgoing leak nor goal adjacency.
var zonesWithLeaks = new HashSet<PlayerSlot>();
foreach (var l in ctx.LeakExitVolumes) zonesWithLeaks.Add(l.sourceZone);
foreach (var owner in ctx.DeclaredOwners)
{
bool hasLeak = zonesWithLeaks.Contains(owner);
bool isGoalAdj = goalAdjacentZones.Contains(owner);
if (!hasLeak && !isGoalAdj)
{
report.Error("P5-8", $"Player zone {owner} has no outgoing leak exit AND is not goal-adjacent. " +
"Every zone must either lead somewhere or be a final defender.");
}
}
// P5-11: no isolated walkable regions (soft warning).
// Connected-component count over the walkable set; warn if more than one component.
int components = CountWalkableComponents(walkableSet);
if (components > 1)
{
report.Warning("P5-11", $"Walkability grid has {components} disconnected regions. " +
"Some areas of the map are unreachable from others.");
}
// P5-12: every gameplay volume's tiles must be a subset of the map area.
// Coverage rule from the design spec — gameplay can't poke outside the playable map.
// Skipped if no MapAreaVolume exists (P2-22 already errored; this would just spam).
if (ctx.MapAreaTiles.Count > 0)
{
ValidateGameplayContainedInMapArea(ctx, report);
}
}
// P5-12: each gameplay volume (PlayerZone, Spawner, LeakExit, Goal) must have all its
// tiles inside ctx.MapAreaTiles. Reports one error per offending volume with the count of
// out-of-map tiles to avoid drowning the report in per-tile errors.
private static void ValidateGameplayContainedInMapArea(BakeContext ctx, BakeReport report)
{
foreach (var v in ctx.AllVolumes)
{
if (v is MapAreaVolume) continue; // The map area itself doesn't need to contain itself.
if (!ctx.PerVolumeTiles.TryGetValue(v, out var tiles)) continue;
int outsideCount = 0;
Vector2Int firstOutside = Vector2Int.zero;
foreach (var t in tiles)
{
if (!ctx.MapAreaTiles.Contains(t))
{
if (outsideCount == 0) firstOutside = t;
outsideCount++;
}
}
if (outsideCount > 0)
{
report.Error("P5-12",
$"{v.GetType().Name} '{v.name}' has {outsideCount} tile(s) outside the map area " +
$"(first offender: {firstOutside}). Every gameplay volume must be fully contained " +
"within the union of MapAreaVolumes.");
}
}
}
private static int CountWalkableComponents(HashSet<Vector2Int> walkable)
{
var visited = new HashSet<Vector2Int>();
int components = 0;
var queue = new Queue<Vector2Int>();
foreach (var seed in walkable)
{
if (visited.Contains(seed)) continue;
components++;
queue.Clear();
queue.Enqueue(seed);
visited.Add(seed);
while (queue.Count > 0)
{
var t = queue.Dequeue();
// 8-connected with corner-cut prevention — keeps component
// counting consistent with pathfinding semantics.
foreach (var n in GridCoordinates.GetNeighbors8(t))
{
if (visited.Contains(n)) continue;
if (!walkable.Contains(n)) continue;
if (GridCoordinates.IsDiagonal(t, n))
{
GridCoordinates.GetCornerShoulders(t, n,
out var shoulderA, out var shoulderB);
if (!walkable.Contains(shoulderA) || !walkable.Contains(shoulderB))
continue;
}
visited.Add(n);
queue.Enqueue(n);
}
}
}
return components;
}
// -------------------------------------------------------------------
// PHASE 6 — Output Assembly
// -------------------------------------------------------------------
private static void Phase6_AssembleOutput(BakeContext ctx, BakeReport report)
{
// Build the in-memory LevelData. We populate a fresh instance and then field-by-field
// copy onto targetAsset in Phase 7 (preserves the asset's GUID).
var data = ScriptableObject.CreateInstance<LevelData>();
data.MapName = ctx.Authoring.mapName;
data.PlayerCount = ctx.Authoring.playerCount;
data.MapDescription = ctx.Authoring.mapDescription;
data.Author = ctx.Authoring.author;
data.GridOriginTile = ctx.GridOriginTile;
data.GridSize = ctx.GridSize;
// Flatten 2D grids to 1D row-major arrays.
int width = ctx.GridSize.x;
int height = ctx.GridSize.y;
int total = width * height;
data.PlacementGrid = new PlacementState[total];
data.WalkabilityGrid = new bool[total];
data.OwnerGrid = new PlayerSlot[total]; // defaults to PlayerSlot.None (=0)
data.MapAreaGrid = new bool[total]; // defaults to false
for (int x = 0; x < width; x++)
{
for (int y = 0; y < height; y++)
{
int idx = y * width + x;
data.PlacementGrid[idx] = ctx.PlacementGrid2D[x, y];
data.WalkabilityGrid[idx] = ctx.WalkabilityGrid2D[x, y];
data.MapAreaGrid[idx] = ctx.MapAreaGrid2D[x, y];
}
}
// Populate OwnerGrid from per-owner zone tile sets.
foreach (var kv in ctx.PerOwnerZoneTiles)
{
foreach (var tile in kv.Value)
{
int gx = tile.x - ctx.GridOriginTile.x;
int gy = tile.y - ctx.GridOriginTile.y;
if (gx < 0 || gx >= width || gy < 0 || gy >= height) continue;
data.OwnerGrid[gy * width + gx] = kv.Key;
}
}
// Assemble PlayerZoneData[] sorted by Owner (PlayerSlot enum value).
var declaredOrdered = ctx.DeclaredOwners.OrderBy(o => (byte)o).ToList();
data.PlayerZones = new PlayerZoneData[declaredOrdered.Count];
for (int i = 0; i < declaredOrdered.Count; i++)
{
PlayerSlot owner = declaredOrdered[i];
var zoneData = new PlayerZoneData { Owner = owner };
// Spawners for this owner, sorted by SpawnerIdInZone.
var ownerSpawners = ctx.SpawnerVolumes
.Where(s => s.owner == owner)
.OrderBy(s => s.spawnerIdInZone)
.ToList();
zoneData.Spawners = new SpawnerData[ownerSpawners.Count];
for (int j = 0; j < ownerSpawners.Count; j++)
{
var sv = ownerSpawners[j];
var col = sv.GetComponent<BoxCollider>();
Vector2Int pos = col != null
? GridCoordinates.WorldToGrid(new Vector2(col.bounds.center.x, col.bounds.center.z))
: Vector2Int.zero;
Vector2Int[] tileArea = ctx.PerVolumeTiles.TryGetValue(sv, out var spawnerTiles)
? spawnerTiles.OrderBy(t => t.y).ThenBy(t => t.x).ToArray()
: new Vector2Int[0];
zoneData.Spawners[j] = new SpawnerData
{
SpawnerIdInZone = sv.spawnerIdInZone,
TilePosition = pos,
TileArea = tileArea,
Facing = sv.spawnFacing,
};
}
// Leak exits FROM this owner's zone, sorted by Target enum value.
var ownerLeaks = ctx.LeakExitVolumes
.Where(l => l.sourceZone == owner)
.OrderBy(l => (byte)l.target)
.ToList();
// Normalize weights within this source zone.
float totalWeight = 0f;
foreach (var l in ownerLeaks) totalWeight += l.weight;
// Already validated > 0 in P2-11 if there are any leaks.
zoneData.LeakExits = new LeakExitData[ownerLeaks.Count];
for (int j = 0; j < ownerLeaks.Count; j++)
{
var lv = ownerLeaks[j];
Vector2Int[] tileArea = ctx.PerVolumeTiles.TryGetValue(lv, out var leakTiles)
? leakTiles.OrderBy(t => t.y).ThenBy(t => t.x).ToArray()
: new Vector2Int[0];
zoneData.LeakExits[j] = new LeakExitData
{
Target = lv.target,
TileArea = tileArea,
NormalizedWeight = totalWeight > 0f ? lv.weight / totalWeight : 0f,
};
}
data.PlayerZones[i] = zoneData;
}
// Assemble GoalData[] sorted by min tile coordinate.
var goalsList = new List<GoalData>(ctx.PerGoalTiles.Count);
foreach (var goalTiles in ctx.PerGoalTiles)
{
Vector2Int[] tileArea = goalTiles.OrderBy(t => t.y).ThenBy(t => t.x).ToArray();
goalsList.Add(new GoalData { TileArea = tileArea });
}
// Sort by min tile coordinate (lex on min Y, then min X).
goalsList.Sort((a, b) =>
{
Vector2Int aMin = MinTile(a.TileArea);
Vector2Int bMin = MinTile(b.TileArea);
int c = aMin.y.CompareTo(bMin.y);
if (c != 0) return c;
return aMin.x.CompareTo(bMin.x);
});
data.Goals = goalsList.ToArray();
// Compute authoring hash from canonical input string.
data.AuthoringHash = ComputeAuthoringHash(ctx);
// Populate ScenePath from the active scene.
data.ScenePath = EditorSceneManager.GetActiveScene().path;
// Bake metadata.
data.LastBakeTimestamp = DateTime.UtcNow.ToString("o", CultureInfo.InvariantCulture);
data.LastBakeOutcome = report.WarningCount > 0 ? BakeOutcome.SuccessWithWarnings : BakeOutcome.Success;
data.LastBakeWarningCount = report.WarningCount;
// Render thumbnail (best-effort; failure logs warning, doesn't abort).
string assetPath = AssetDatabase.GetAssetPath(ctx.Authoring.targetAsset);
string thumbnailPath = ComputeThumbnailPath(assetPath);
try
{
if (RenderThumbnail(ctx, thumbnailPath))
{
ctx.ThumbnailPath = thumbnailPath;
}
else
{
report.Warning("P6", "Thumbnail render did not produce a file. Continuing without thumbnail.");
}
}
catch (Exception e)
{
report.Warning("P6", $"Thumbnail render failed: {e.Message}. Continuing without thumbnail.");
}
ctx.AssembledData = data;
}
private static Vector2Int MinTile(Vector2Int[] tiles)
{
if (tiles == null || tiles.Length == 0) return Vector2Int.zero;
int minX = tiles[0].x, minY = tiles[0].y;
for (int i = 1; i < tiles.Length; i++)
{
if (tiles[i].x < minX) minX = tiles[i].x;
if (tiles[i].y < minY) minY = tiles[i].y;
}
return new Vector2Int(minX, minY);
}
// -- Hash computation -------------------------------------------------
/// <summary>
/// Computes the authoring hash for a LevelAuthoring's scene state,
/// suitable for comparing against <see cref="LevelData.AuthoringHash"/>
/// to detect drift from baked data.
/// </summary>
/// <remarks>
/// Delegates to the same hashing routine the bake itself uses, so equal
/// hashes mean the scene would bake to equivalent LevelData. Hashes
/// only Layer 1 authoring inputs (volumes + metadata); visual scene
/// content (terrain, lighting, decorations) is NOT included.
///
/// Performs its own volume discovery and canonical sort. Does NOT run
/// pre-validation or rasterization, so it returns a hash even for scenes
/// that would fail the bake (e.g. missing zones, overlapping volumes).
/// That's intentional: the play-mode hook needs a hash even when the
/// scene is broken, so it can report "drift" as the actionable problem
/// rather than silently differing from the last successful bake.
/// </remarks>
public static string ComputeAuthoringHash(LevelAuthoring authoring)
{
if (authoring == null)
return string.Empty;
// Build a minimal BakeContext with just the fields the hash routine
// reads: Authoring and AllVolumes. We mirror Phase 1's discovery
// logic exactly (scoped scan + canonical sort) so the standalone
// hash is identical to what Phase 1 would produce on the same scene.
var ctx = new BakeContext { Authoring = authoring };
var rootTransform = authoring.transform;
var playerZones = rootTransform.GetComponentsInChildren<PlayerZoneVolume>(includeInactive: false);
var spawners = rootTransform.GetComponentsInChildren<SpawnerVolume>(includeInactive: false);
var leakExits = rootTransform.GetComponentsInChildren<LeakExitVolume>(includeInactive: false);
var goals = rootTransform.GetComponentsInChildren<GoalVolume>(includeInactive: false);
var mapAreas = rootTransform.GetComponentsInChildren<MapAreaVolume>(includeInactive: false);
ctx.AllVolumes = new System.Collections.Generic.List<VolumeBase>(
playerZones.Length + spawners.Length + leakExits.Length + goals.Length + mapAreas.Length);
ctx.AllVolumes.AddRange(playerZones);
ctx.AllVolumes.AddRange(spawners);
ctx.AllVolumes.AddRange(leakExits);
ctx.AllVolumes.AddRange(goals);
ctx.AllVolumes.AddRange(mapAreas);
// Match Phase 1's canonical sort exactly. The hash routine re-orders
// by canonical path internally, but sorting AllVolumes here keeps
// the data flow identical to Phase 1, so any future change to the
// discovery/sort step is automatically picked up.
ctx.AllVolumes.Sort((a, b) => string.CompareOrdinal(
CanonicalPath(a, rootTransform),
CanonicalPath(b, rootTransform)));
return ComputeAuthoringHash(ctx);
}
private static string ComputeAuthoringHash(BakeContext ctx)
{
var sb = new StringBuilder();
CultureInfo inv = CultureInfo.InvariantCulture;
// LevelAuthoring metadata block.
sb.Append("LA|");
sb.Append("mapName=").Append(ctx.Authoring.mapName ?? "").Append('|');
sb.Append("playerCount=").Append(ctx.Authoring.playerCount.ToString(inv)).Append('|');
sb.Append("expectedGoalCount=").Append(ctx.Authoring.expectedGoalCount.ToString(inv)).Append('|');
sb.Append("mapDescription=").Append(ctx.Authoring.mapDescription ?? "").Append('|');
sb.Append("author=").Append(ctx.Authoring.author ?? "").Append('\n');
// Volume blocks, ordered by canonical path.
var rootTransform = ctx.Authoring.transform;
var ordered = ctx.AllVolumes
.Select(v => (volume: v, path: CanonicalPath(v, rootTransform)))
.OrderBy(x => x.path, StringComparer.Ordinal)
.ToList();
foreach (var (v, path) in ordered)
{
sb.Append("V|");
sb.Append("type=").Append(v.GetType().Name).Append('|');
sb.Append("path=").Append(path).Append('|');
var col = v.GetComponent<BoxCollider>();
if (col != null)
{
Bounds b = col.bounds;
sb.Append("center=")
.Append(b.center.x.ToString("G17", inv)).Append(',')
.Append(b.center.y.ToString("G17", inv)).Append(',')
.Append(b.center.z.ToString("G17", inv)).Append('|');
sb.Append("size=")
.Append(b.size.x.ToString("G17", inv)).Append(',')
.Append(b.size.y.ToString("G17", inv)).Append(',')
.Append(b.size.z.ToString("G17", inv)).Append('|');
}
// Per-subtype fields. Enum values by NAME, not numeric value.
switch (v)
{
case PlayerZoneVolume pz:
sb.Append("owner=").Append(pz.owner.ToString()).Append('|');
sb.Append("validity=").Append(pz.placementValidity.ToString()).Append('|');
break;
case SpawnerVolume sv:
sb.Append("owner=").Append(sv.owner.ToString()).Append('|');
sb.Append("id=").Append(sv.spawnerIdInZone.ToString(inv)).Append('|');
sb.Append("facing=").Append(sv.spawnFacing.ToString()).Append('|');
sb.Append("validity=").Append(sv.placementValidity.ToString()).Append('|');
break;
case LeakExitVolume lv:
sb.Append("source=").Append(lv.sourceZone.ToString()).Append('|');
sb.Append("target=").Append(lv.target.ToString()).Append('|');
sb.Append("weight=").Append(lv.weight.ToString("G17", inv)).Append('|');
sb.Append("validity=").Append(lv.placementValidity.ToString()).Append('|');
break;
case GoalVolume gv:
sb.Append("validity=").Append(gv.placementValidity.ToString()).Append('|');
break;
}
sb.Append('\n');
}
// SHA256 of UTF-8 bytes.
byte[] bytes = Encoding.UTF8.GetBytes(sb.ToString());
using (var sha = SHA256.Create())
{
byte[] hash = sha.ComputeHash(bytes);
var hex = new StringBuilder(hash.Length * 2);
for (int i = 0; i < hash.Length; i++) hex.Append(hash[i].ToString("x2", inv));
return hex.ToString();
}
}
// -- Thumbnail rendering ---------------------------------------------
private static string ComputeThumbnailPath(string assetPath)
{
// Asset path looks like "Assets/_Project/Scenes/Levels/MyMap.asset". Strip extension,
// append "_Thumbnail.png".
string dir = Path.GetDirectoryName(assetPath) ?? "Assets";
string nameNoExt = Path.GetFileNameWithoutExtension(assetPath);
return Path.Combine(dir, nameNoExt + "_Thumbnail.png").Replace("\\", "/");
}
private static bool RenderThumbnail(BakeContext ctx, string thumbnailAssetPath)
{
// Compute world-space bounds of the map's tile region.
float halfTile = GridCoordinates.TILE_SIZE * 0.5f;
float minX = ctx.MapMinTile.x - halfTile;
float maxX = ctx.MapMaxTile.x + halfTile;
float minZ = ctx.MapMinTile.y - halfTile;
float maxZ = ctx.MapMaxTile.y + halfTile;
float worldW = maxX - minX;
float worldH = maxZ - minZ;
// Aspect-matched non-square render with longer side at 1024px.
int rtWidth, rtHeight;
if (worldW >= worldH)
{
rtWidth = 1024;
rtHeight = Mathf.Max(1, Mathf.RoundToInt(1024f * (worldH / worldW)));
}
else
{
rtHeight = 1024;
rtWidth = Mathf.Max(1, Mathf.RoundToInt(1024f * (worldW / worldH)));
}
// Hide _LevelAuthoring subtree during the render so volume gizmos don't show up
// (gizmos won't render in offscreen captures anyway, but child GameObjects with
// visible meshes — labeling helpers, debug cubes etc. — would).
var authGO = ctx.Authoring.gameObject;
bool wasActive = authGO.activeSelf;
authGO.SetActive(false);
GameObject camGO = null;
RenderTexture rt = null;
RenderTexture prevActive = RenderTexture.active;
Texture2D snapshot = null;
try
{
rt = new RenderTexture(rtWidth, rtHeight, 24, RenderTextureFormat.ARGB32);
rt.antiAliasing = 4;
camGO = new GameObject("__BakeThumbnailCamera");
camGO.hideFlags = HideFlags.HideAndDontSave;
var cam = camGO.AddComponent<Camera>();
cam.clearFlags = CameraClearFlags.SolidColor;
cam.backgroundColor = new Color(0.15f, 0.15f, 0.18f, 1f);
cam.orthographic = true;
// orthographicSize is HALF the vertical world extent. We sized the render texture
// so that aspect = worldW/worldH, which makes the camera's horizontal world extent
// exactly worldW when the vertical extent is worldH. So setting size = worldH/2
// makes the map fit exactly in both branches.
cam.orthographicSize = worldH * 0.5f;
// Center camera above map.
float camX = (minX + maxX) * 0.5f;
float camZ = (minZ + maxZ) * 0.5f;
cam.transform.position = new Vector3(camX, 50f, camZ);
cam.transform.rotation = Quaternion.Euler(90f, 0f, 0f); // straight down
cam.nearClipPlane = 0.1f;
cam.farClipPlane = 100f;
cam.targetTexture = rt; // aspect is derived from the texture automatically
cam.Render();
RenderTexture.active = rt;
snapshot = new Texture2D(rtWidth, rtHeight, TextureFormat.ARGB32, false);
snapshot.ReadPixels(new Rect(0, 0, rtWidth, rtHeight), 0, 0);
snapshot.Apply();
byte[] png = snapshot.EncodeToPNG();
File.WriteAllBytes(thumbnailAssetPath, png);
}
finally
{
authGO.SetActive(wasActive);
RenderTexture.active = prevActive;
if (camGO != null) UnityEngine.Object.DestroyImmediate(camGO);
if (rt != null) { rt.Release(); UnityEngine.Object.DestroyImmediate(rt); }
if (snapshot != null) UnityEngine.Object.DestroyImmediate(snapshot);
}
// Import as Sprite.
AssetDatabase.ImportAsset(thumbnailAssetPath, ImportAssetOptions.ForceSynchronousImport);
var importer = AssetImporter.GetAtPath(thumbnailAssetPath) as TextureImporter;
if (importer != null)
{
importer.textureType = TextureImporterType.Sprite;
importer.spriteImportMode = SpriteImportMode.Single;
importer.SaveAndReimport();
}
return true;
}
// -------------------------------------------------------------------
// PHASE 7 — Commit
// -------------------------------------------------------------------
private static void Phase7_Commit(BakeContext ctx, BakeReport report)
{
var target = ctx.Authoring.targetAsset;
var src = ctx.AssembledData;
// Field-by-field copy onto the target asset (preserves GUID).
target.MapName = src.MapName;
target.PlayerCount = src.PlayerCount;
target.MapDescription = src.MapDescription;
target.Author = src.Author;
target.ScenePath = src.ScenePath;
target.AuthoringHash = src.AuthoringHash;
target.LastBakeTimestamp = src.LastBakeTimestamp;
target.LastBakeOutcome = src.LastBakeOutcome;
target.LastBakeWarningCount = src.LastBakeWarningCount;
target.GridOriginTile = src.GridOriginTile;
target.GridSize = src.GridSize;
target.PlacementGrid = src.PlacementGrid;
target.WalkabilityGrid = src.WalkabilityGrid;
target.OwnerGrid = src.OwnerGrid;
target.MapAreaGrid = src.MapAreaGrid;
target.PlayerZones = src.PlayerZones;
target.Goals = src.Goals;
// Wire up the thumbnail sprite reference if a thumbnail was rendered.
if (!string.IsNullOrEmpty(ctx.ThumbnailPath))
{
target.MapThumbnail = AssetDatabase.LoadAssetAtPath<Sprite>(ctx.ThumbnailPath);
}
EditorUtility.SetDirty(target);
AssetDatabase.SaveAssets();
AssetDatabase.Refresh();
// Discard the in-memory ScriptableObject we built.
UnityEngine.Object.DestroyImmediate(src);
}
}
// -------------------------------------------------------------------
// Internal context passed between phases. Lives only for the duration of one bake run.
// -------------------------------------------------------------------
internal class BakeContext
{
public LevelAuthoring Authoring;
// Phase 1 outputs
public List<VolumeBase> AllVolumes;
public List<PlayerZoneVolume> PlayerZoneVolumes;
public List<SpawnerVolume> SpawnerVolumes;
public List<LeakExitVolume> LeakExitVolumes;
public List<GoalVolume> GoalVolumes;
public List<MapAreaVolume> MapAreaVolumes;
// Phase 2 outputs
public HashSet<PlayerSlot> ExpectedOwners;
public HashSet<PlayerSlot> DeclaredOwners;
// Phase 3 outputs
public Dictionary<VolumeBase, HashSet<Vector2Int>> PerVolumeTiles;
public Dictionary<PlayerSlot, HashSet<Vector2Int>> PerOwnerZoneTiles;
public List<HashSet<Vector2Int>> PerGoalTiles;
public HashSet<Vector2Int> MapAreaTiles;
public Vector2Int MapMinTile;
public Vector2Int MapMaxTile;
// Phase 4 outputs
public Vector2Int GridOriginTile;
public Vector2Int GridSize;
public PlacementState[,] PlacementGrid2D;
public bool[,] WalkabilityGrid2D;
public bool[,] MapAreaGrid2D;
// Phase 5 outputs
public HashSet<PlayerSlot> GoalAdjacentZones;
// Phase 6 outputs
public LevelData AssembledData;
public string ThumbnailPath;
}
}