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; " + $"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.AllVolumes = new List(); ctx.AllVolumes.AddRange(ctx.PlayerZoneVolumes); ctx.AllVolumes.AddRange(ctx.SpawnerVolumes); ctx.AllVolumes.AddRange(ctx.LeakExitVolumes); ctx.AllVolumes.AddRange(ctx.GoalVolumes); // 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))); } // 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-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>(); 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); } } 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 // 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; 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; } foreach (var n in GridCoordinates.GetNeighbors(t)) { if (bfsVisited.Contains(n)) continue; if (!walkableSet.Contains(n)) 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."); } } 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(); foreach (var n in GridCoordinates.GetNeighbors(t)) { if (visited.Contains(n)) continue; if (!walkable.Contains(n)) 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) 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]; } } // 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); ctx.AllVolumes = new System.Collections.Generic.List( playerZones.Length + spawners.Length + leakExits.Length + goals.Length); ctx.AllVolumes.AddRange(playerZones); ctx.AllVolumes.AddRange(spawners); ctx.AllVolumes.AddRange(leakExits); ctx.AllVolumes.AddRange(goals); // 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.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; // Phase 2 outputs public HashSet ExpectedOwners; public HashSet DeclaredOwners; // Phase 3 outputs public Dictionary> PerVolumeTiles; public Dictionary> PerOwnerZoneTiles; public List> PerGoalTiles; public Vector2Int MapMinTile; public Vector2Int MapMaxTile; // Phase 4 outputs public Vector2Int GridOriginTile; public Vector2Int GridSize; public PlacementState[,] PlacementGrid2D; public bool[,] WalkabilityGrid2D; // Phase 5 outputs public HashSet GoalAdjacentZones; // Phase 6 outputs public LevelData AssembledData; public string ThumbnailPath; } }