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
{
///
/// Editor-only bake pipeline that runs the seven-phase algorithm against a
/// in the active scene and writes the result into the
/// ScriptableObject pointed to by authoring.targetAsset.
///
///
/// 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 's transient
/// flag, not on the asset itself.
///
public static class LevelBakePipeline
{
// -------------------------------------------------------------------
// Public entry point
// -------------------------------------------------------------------
///
/// Runs the bake. Returns true on success (with or without warnings); false on hard error.
/// On failure, .targetAsset is left untouched on disk.
///
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(includeInactive: false).ToList();
ctx.SpawnerVolumes = rootTransform.GetComponentsInChildren(includeInactive: false).ToList();
ctx.LeakExitVolumes = rootTransform.GetComponentsInChildren(includeInactive: false).ToList();
ctx.GoalVolumes = rootTransform.GetComponentsInChildren(includeInactive: false).ToList();
ctx.MapAreaVolumes = rootTransform.GetComponentsInChildren(includeInactive: false).ToList();
ctx.AllVolumes = new List();
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(FindObjectsInactive.Exclude);
var includedSet = new HashSet(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 "";
var t = component.transform;
var segments = new List();
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 ValidPlayerCounts = new HashSet { 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();
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();
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();
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();
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>();
ctx.PerOwnerZoneTiles = new Dictionary>();
ctx.PerGoalTiles = new List>();
ctx.MapAreaTiles = new HashSet();
bool initializedAggregate = false;
int aggMinX = 0, aggMinY = 0, aggMaxX = 0, aggMaxY = 0;
foreach (var v in ctx.AllVolumes)
{
var col = v.GetComponent();
if (col == null) continue; // Already errored in P2-18
var tiles = new HashSet();
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();
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>();
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();
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();
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();
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();
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();
var bfsVisited = new HashSet();
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();
var allGoalTiles = new HashSet(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();
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 walkable)
{
var visited = new HashSet();
int components = 0;
var queue = new Queue();
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();
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();
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(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 -------------------------------------------------
///
/// Computes the authoring hash for a LevelAuthoring's scene state,
/// suitable for comparing against
/// to detect drift from baked data.
///
///
/// 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.
///
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(includeInactive: false);
var spawners = rootTransform.GetComponentsInChildren(includeInactive: false);
var leakExits = rootTransform.GetComponentsInChildren(includeInactive: false);
var goals = rootTransform.GetComponentsInChildren(includeInactive: false);
var mapAreas = rootTransform.GetComponentsInChildren(includeInactive: false);
ctx.AllVolumes = new System.Collections.Generic.List(
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();
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();
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(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 AllVolumes;
public List PlayerZoneVolumes;
public List SpawnerVolumes;
public List LeakExitVolumes;
public List GoalVolumes;
public List MapAreaVolumes;
// Phase 2 outputs
public HashSet ExpectedOwners;
public HashSet DeclaredOwners;
// Phase 3 outputs
public Dictionary> PerVolumeTiles;
public Dictionary> PerOwnerZoneTiles;
public List> PerGoalTiles;
public HashSet 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 GoalAdjacentZones;
// Phase 6 outputs
public LevelData AssembledData;
public string ThumbnailPath;
}
}