1428 lines
No EOL
64 KiB
C#
1428 lines
No EOL
64 KiB
C#
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. Tile N spans world [N, N+1]
|
||
// (edge-aligned), so the rect spans from MapMinTile to MapMaxTile + 1 on each axis.
|
||
float tileSize = GridCoordinates.TILE_SIZE;
|
||
float minX = ctx.MapMinTile.x * tileSize;
|
||
float maxX = (ctx.MapMaxTile.x + 1) * tileSize;
|
||
float minZ = ctx.MapMinTile.y * tileSize;
|
||
float maxZ = (ctx.MapMaxTile.y + 1) * tileSize;
|
||
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;
|
||
Camera cam = null;
|
||
RenderTexture rt = null;
|
||
RenderTexture prevActive = RenderTexture.active;
|
||
Texture2D snapshot = null;
|
||
try
|
||
{
|
||
// Allocate via a descriptor and force GPU creation up front with Create().
|
||
// A manually-newed MSAA RenderTexture that is only lazily created (during
|
||
// the camera's first Render) registers inconsistently with Unity's internal
|
||
// RenderTexture age-check pool. On teardown that leaves a dangling tracker
|
||
// entry, producing the editor-update spam:
|
||
// "Checking lifetime of RenderTextures but m_AgeCheckAdded = false".
|
||
// Explicit Create() makes registration deterministic so Release() cleanly
|
||
// unregisters it.
|
||
var desc = new RenderTextureDescriptor(rtWidth, rtHeight, RenderTextureFormat.ARGB32, 24)
|
||
{
|
||
msaaSamples = 4,
|
||
autoGenerateMips = false,
|
||
};
|
||
rt = new RenderTexture(desc);
|
||
rt.Create();
|
||
|
||
camGO = new GameObject("__BakeThumbnailCamera");
|
||
camGO.hideFlags = HideFlags.HideAndDontSave;
|
||
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);
|
||
|
||
// Teardown order matters: clear every reference to the RT before destroying
|
||
// it, otherwise the camera (or the global active slot) is left pointing at
|
||
// freed memory, which is the other way the age-check tracker gets corrupted.
|
||
if (cam != null) cam.targetTexture = null;
|
||
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;
|
||
}
|
||
} |