diff --git a/.gitignore b/.gitignore
index 3c335ae..bd72224 100644
--- a/.gitignore
+++ b/.gitignore
@@ -104,3 +104,4 @@ InitTestScene*.unity*
# Auto-generated cache in Assets folder
/[Aa]ssets/[Ss]ceneDependencyCache*
+/Assets/_Recovery
diff --git a/Assets/_Project/Levels/LevelAuthoring.cs b/Assets/_Project/Levels/LevelAuthoring.cs
index 60991c4..390fef0 100644
--- a/Assets/_Project/Levels/LevelAuthoring.cs
+++ b/Assets/_Project/Levels/LevelAuthoring.cs
@@ -70,39 +70,12 @@ namespace TD.Levels
public bool alwaysShowGoals = false;
// -------------------------------------------------------------------
- // Bake API (stubs this session — full implementation comes next session).
+ // Bake API
// -------------------------------------------------------------------
-
- ///
- /// Runs the seven-phase bake algorithm and writes the result into .
- ///
- /// True if the bake succeeded (with or without warnings); false if validation
- /// failed or any other hard error occurred. On failure, the existing targetAsset on disk
- /// is left untouched.
- ///
- /// STUB — full implementation is the next session's work. Currently logs a not-implemented
- /// message and returns false.
- ///
- public bool BakeLevelData()
- {
- Debug.LogWarning("[LevelAuthoring] BakeLevelData is not yet implemented. " +
- "The seven-phase bake algorithm will be added in the next session.");
- return false;
- }
-
- ///
- /// Re-renders just the lobby thumbnail without doing a full bake. Useful when only visual
- /// scene content (terrain, decorations) has changed.
- ///
- ///
- /// STUB — full implementation is part of the bake script work. Currently logs a
- /// not-implemented message.
- ///
- public void RefreshThumbnail()
- {
- Debug.LogWarning("[LevelAuthoring] RefreshThumbnail is not yet implemented. " +
- "Thumbnail rendering will be added with the bake script.");
- }
+ // The bake operation is implemented in TD.Levels.Editor.LevelBakePipeline (in the Editor
+ // assembly). It cannot be exposed as a method on this runtime class because the runtime
+ // assembly cannot reference types in the Editor assembly. The custom inspector's "Bake
+ // LevelData" button invokes the pipeline directly.
// -------------------------------------------------------------------
// Map-level gizmos: origin marker, map bounding rect, combined player zone outlines.
@@ -130,14 +103,14 @@ namespace TD.Levels
Vector3 origin = new Vector3(0f, OriginMarkerY, 0f);
Gizmos.DrawSphere(origin, 0.1f);
Gizmos.DrawLine(origin + new Vector3(-OriginMarkerSize, 0f, 0f),
- origin + new Vector3( OriginMarkerSize, 0f, 0f));
+ origin + new Vector3(OriginMarkerSize, 0f, 0f));
Gizmos.DrawLine(origin + new Vector3(0f, 0f, -OriginMarkerSize),
- origin + new Vector3(0f, 0f, OriginMarkerSize));
+ origin + new Vector3(0f, 0f, OriginMarkerSize));
Gizmos.color = prev;
#if UNITY_EDITOR
- Handles.Label(origin + new Vector3(0.15f, 0.05f, 0.15f), "Tile (0,0)");
+ Handles.Label(origin + new Vector3(0.15f, 0.05f, 0.15f), "World Origin (0,0)");
#endif
}
@@ -195,7 +168,21 @@ namespace TD.Levels
Gizmos.DrawLine(ne, nw);
Gizmos.DrawLine(nw, sw);
+ // Map SW corner marker — a small filled square so it reads distinctly from the
+ // world-origin sphere+cross when both are visible. Sized slightly above the bounds
+ // line so it doesn't z-fight.
+ Vector3 swMarkerCenter = new Vector3(sw.x, MapBoundsY + 0.005f, sw.z);
+ Gizmos.color = new Color(1f, 0.85f, 0.2f, 0.9f); // amber, distinct from origin's white
+ Gizmos.DrawCube(swMarkerCenter, new Vector3(0.25f, 0.01f, 0.25f));
+
Gizmos.color = prev;
+
+#if UNITY_EDITOR
+ // Place label slightly inside the map bounds (NE of the corner) so it doesn't get
+ // covered by the world-origin label when the map is aligned.
+ Handles.Label(swMarkerCenter + new Vector3(0.2f, 0.05f, 0.2f),
+ $"Map SW: tile ({minTile.x}, {minTile.y})");
+#endif
}
private void DrawCombinedPlayerZoneOutlines()
diff --git a/Assets/_Project/Scenes/Levels/Main.unity b/Assets/_Project/Scenes/Levels/Main.unity
index 9733e97..7f41077 100644
--- a/Assets/_Project/Scenes/Levels/Main.unity
+++ b/Assets/_Project/Scenes/Levels/Main.unity
@@ -146,7 +146,7 @@ Transform:
m_GameObject: {fileID: 154690529}
serializedVersion: 2
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
- m_LocalPosition: {x: -12, y: 0, z: 13}
+ m_LocalPosition: {x: -15.58, y: 0, z: 12.98}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
@@ -185,8 +185,58 @@ BoxCollider:
m_ProvidesContacts: 0
m_Enabled: 1
serializedVersion: 3
- m_Size: {x: 28, y: 1, z: 7}
+ m_Size: {x: 35, y: 1, z: 7}
m_Center: {x: 0, y: 0, z: 0}
+--- !u!1 &167151707
+GameObject:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ serializedVersion: 6
+ m_Component:
+ - component: {fileID: 167151709}
+ - component: {fileID: 167151708}
+ m_Layer: 0
+ m_Name: LevelLoader
+ m_TagString: Untagged
+ m_Icon: {fileID: 0}
+ m_NavMeshLayer: 0
+ m_StaticEditorFlags: 0
+ m_IsActive: 1
+--- !u!114 &167151708
+MonoBehaviour:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 167151707}
+ m_Enabled: 1
+ m_EditorHideFlags: 0
+ m_Script: {fileID: 11500000, guid: a303b690faebb0e4e930d1714afa424e, type: 3}
+ m_Name:
+ m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.LevelLoader
+ level: {fileID: 11400000, guid: 9cc56fbc3ae460a4b862f8510fdf5f09, type: 2}
+ buildablePlaneLayerName: BuildablePlane
+ drawGridBounds: 1
+ drawWalkability: 1
+ drawBuildablePlane: 1
+ drawOwnerBorders: 1
+--- !u!4 &167151709
+Transform:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 167151707}
+ serializedVersion: 2
+ m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
+ m_LocalPosition: {x: 24.6091, y: -0, z: -0.55913}
+ m_LocalScale: {x: 1, y: 1, z: 1}
+ m_ConstrainProportionsScale: 0
+ m_Children: []
+ m_Father: {fileID: 0}
+ m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!1 &239104687
GameObject:
m_ObjectHideFlags: 0
@@ -286,7 +336,7 @@ Transform:
m_GameObject: {fileID: 304575571}
serializedVersion: 2
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
- m_LocalPosition: {x: -29, y: 0, z: 13}
+ m_LocalPosition: {x: -30.03, y: 0, z: 13}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
@@ -623,11 +673,11 @@ MonoBehaviour:
m_Name:
m_EditorClassIdentifier: Assembly-CSharp::TD.Levels.LevelAuthoring
targetAsset: {fileID: 11400000, guid: 9cc56fbc3ae460a4b862f8510fdf5f09, type: 2}
- mapName:
- playerCount: 1
+ mapName: test_map
+ playerCount: 2
expectedGoalCount: 1
- mapDescription:
- author:
+ mapDescription: Test Map Description
+ author: Matt
alwaysShowPlayerZones: 1
alwaysShowSpawners: 1
alwaysShowLeakExits: 1
@@ -641,7 +691,7 @@ Transform:
m_GameObject: {fileID: 441239879}
serializedVersion: 2
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
- m_LocalPosition: {x: -35.58358, y: 0, z: -6.35952}
+ m_LocalPosition: {x: 32.91642, y: 0, z: -9.56}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children:
@@ -799,7 +849,7 @@ Transform:
m_GameObject: {fileID: 1078485323}
serializedVersion: 2
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
- m_LocalPosition: {x: 8, y: 0, z: 22}
+ m_LocalPosition: {x: 7.06, y: 0, z: 21.08}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
@@ -869,7 +919,7 @@ Transform:
m_GameObject: {fileID: 1360337262}
serializedVersion: 2
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
- m_LocalPosition: {x: 33.5, y: 0, z: 13.5}
+ m_LocalPosition: {x: 33.16, y: 0, z: 12.58}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
@@ -1016,7 +1066,7 @@ Transform:
m_GameObject: {fileID: 1464027360}
serializedVersion: 2
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
- m_LocalPosition: {x: -35, y: 0, z: 15}
+ m_LocalPosition: {x: 33.17, y: 0, z: 13.07}
m_LocalScale: {x: 7, y: 1, z: 3}
m_ConstrainProportionsScale: 0
m_Children: []
@@ -1155,7 +1205,7 @@ Transform:
m_GameObject: {fileID: 1975687919}
serializedVersion: 2
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
- m_LocalPosition: {x: 18, y: 0, z: 13}
+ m_LocalPosition: {x: 18, y: 0, z: 12.56}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
@@ -1207,3 +1257,4 @@ SceneRoots:
- {fileID: 239104690}
- {fileID: 441239881}
- {fileID: 1464027364}
+ - {fileID: 167151709}
diff --git a/Assets/_Project/Scenes/Levels/TestLevel.asset b/Assets/_Project/Scenes/Levels/TestLevel.asset
index b21d1f1..ee59cd6 100644
--- a/Assets/_Project/Scenes/Levels/TestLevel.asset
+++ b/Assets/_Project/Scenes/Levels/TestLevel.asset
@@ -12,20 +12,192 @@ MonoBehaviour:
m_Script: {fileID: 11500000, guid: 6d4e9c37b9205f3408a8225823f7a4da, type: 3}
m_Name: TestLevel
m_EditorClassIdentifier: Assembly-CSharp::TD.Levels.LevelData
- MapName:
- PlayerCount: 0
- MapDescription:
- Author:
- MapThumbnail: {fileID: 0}
- ScenePath:
- AuthoringHash:
- LastBakeTimestamp:
- LastBakeOutcome: 0
- LastBakeWarningCount: 0
+ MapName: test_map
+ PlayerCount: 2
+ MapDescription: Test Map Description
+ Author: Matt
+ MapThumbnail: {fileID: 21300000, guid: d2e652d3e1c53454d80d3c1ec7888998, type: 3}
+ ScenePath: Assets/_Project/Scenes/Levels/Main.unity
+ AuthoringHash: 521a1ef38caafd70be6e364f81e999f5da6c425332fe32933766854b8cfad413
+ LastBakeTimestamp: 2026-04-30T19:05:42.7013062Z
+ LastBakeOutcome: 1
+ LastBakeWarningCount: 1
GridOriginTile: {x: 0, y: 0}
- GridSize: {x: 0, y: 0}
- PlacementGrid:
- WalkabilityGrid:
- OwnerGrid:
- PlayerZones: []
- Goals: []
+ GridSize: {x: 68, y: 17}
+ PlacementGrid: 02020202020202010101010101010101010101010101010101010101010101010101010202010101010101010101010101010101010101010101010101010101010202020202020202020201010101010101010101010101010101010101010101010101010101020201010101010101010101010101010101010101010101010101010101020202020202020202020101010101010101010101010101010101010101010101010101010102020101010101010101010101010101010101010101010101010101010102020202020202020202010101010101010101010101010101010101010101010101010101010202010101010101010101010101010101010101010101010101010101010202020202020202020201010101010101010101010101010101010101010101010101010101020201010101010101010101010101010101010101010101010101010101020202020202020202020101010101010101010101010101010101010101010101010101010102020101010101010101010101010101010101010101010101010101010102020202020202020202010101010101010101010101010101010101010101010101010101010202010101010101010101010101010101010101010101010101010101010202020000000000000000000000000000000000000000000000000000000000000000000000000002020202020202000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000202020202020200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020202020202020000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002020202020202000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000202020202020200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020202020202020000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002020202020202000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000202020202020200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020202020202020000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002020202020202000000000000000000000000000000000000000000000000
+ WalkabilityGrid: 01010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010000000000000000000000000000000000000000000000000000000000000000000000000001010101010101000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000101010101010100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010101010101010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001010101010101000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000101010101010100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010101010101010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001010101010101000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000101010101010100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010101010101010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001010101010101000000000000000000000000000000000000000000000000
+ OwnerGrid: 01010101010101010101010101010101010101010101010101010101010101010101010000020202020202020202020202020202020202020202020202020202020000000101010101010101010101010101010101010101010101010101010101010101010101000002020202020202020202020202020202020202020202020202020202000000010101010101010101010101010101010101010101010101010101010101010101010100000202020202020202020202020202020202020202020202020202020200000001010101010101010101010101010101010101010101010101010101010101010101010000020202020202020202020202020202020202020202020202020202020000000101010101010101010101010101010101010101010101010101010101010101010101000002020202020202020202020202020202020202020202020202020202000000010101010101010101010101010101010101010101010101010101010101010101010100000202020202020202020202020202020202020202020202020202020200000001010101010101010101010101010101010101010101010101010101010101010101010000020202020202020202020202020202020202020202020202020202020000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
+ PlayerZones:
+ - Owner: 1
+ Spawners:
+ - SpawnerIdInZone: 0
+ TilePosition: {x: 3, y: 3}
+ TileArea:
+ - {x: 0, y: 0}
+ - {x: 1, y: 0}
+ - {x: 2, y: 0}
+ - {x: 3, y: 0}
+ - {x: 4, y: 0}
+ - {x: 5, y: 0}
+ - {x: 6, y: 0}
+ - {x: 0, y: 1}
+ - {x: 1, y: 1}
+ - {x: 2, y: 1}
+ - {x: 3, y: 1}
+ - {x: 4, y: 1}
+ - {x: 5, y: 1}
+ - {x: 6, y: 1}
+ - {x: 0, y: 2}
+ - {x: 1, y: 2}
+ - {x: 2, y: 2}
+ - {x: 3, y: 2}
+ - {x: 4, y: 2}
+ - {x: 5, y: 2}
+ - {x: 6, y: 2}
+ - {x: 0, y: 3}
+ - {x: 1, y: 3}
+ - {x: 2, y: 3}
+ - {x: 3, y: 3}
+ - {x: 4, y: 3}
+ - {x: 5, y: 3}
+ - {x: 6, y: 3}
+ - {x: 0, y: 4}
+ - {x: 1, y: 4}
+ - {x: 2, y: 4}
+ - {x: 3, y: 4}
+ - {x: 4, y: 4}
+ - {x: 5, y: 4}
+ - {x: 6, y: 4}
+ - {x: 0, y: 5}
+ - {x: 1, y: 5}
+ - {x: 2, y: 5}
+ - {x: 3, y: 5}
+ - {x: 4, y: 5}
+ - {x: 5, y: 5}
+ - {x: 6, y: 5}
+ - {x: 0, y: 6}
+ - {x: 1, y: 6}
+ - {x: 2, y: 6}
+ - {x: 3, y: 6}
+ - {x: 4, y: 6}
+ - {x: 5, y: 6}
+ - {x: 6, y: 6}
+ Facing: 2
+ LeakExits:
+ - Target: 2
+ TileArea:
+ - {x: 35, y: 0}
+ - {x: 36, y: 0}
+ - {x: 35, y: 1}
+ - {x: 36, y: 1}
+ - {x: 35, y: 2}
+ - {x: 36, y: 2}
+ - {x: 35, y: 3}
+ - {x: 36, y: 3}
+ - {x: 35, y: 4}
+ - {x: 36, y: 4}
+ - {x: 35, y: 5}
+ - {x: 36, y: 5}
+ - {x: 35, y: 6}
+ - {x: 36, y: 6}
+ NormalizedWeight: 1
+ - Owner: 2
+ Spawners:
+ - SpawnerIdInZone: 0
+ TilePosition: {x: 40, y: 12}
+ TileArea:
+ - {x: 37, y: 7}
+ - {x: 38, y: 7}
+ - {x: 39, y: 7}
+ - {x: 40, y: 7}
+ - {x: 41, y: 7}
+ - {x: 42, y: 7}
+ - {x: 43, y: 7}
+ - {x: 37, y: 8}
+ - {x: 38, y: 8}
+ - {x: 39, y: 8}
+ - {x: 40, y: 8}
+ - {x: 41, y: 8}
+ - {x: 42, y: 8}
+ - {x: 43, y: 8}
+ - {x: 37, y: 9}
+ - {x: 38, y: 9}
+ - {x: 39, y: 9}
+ - {x: 40, y: 9}
+ - {x: 41, y: 9}
+ - {x: 42, y: 9}
+ - {x: 43, y: 9}
+ - {x: 37, y: 10}
+ - {x: 38, y: 10}
+ - {x: 39, y: 10}
+ - {x: 40, y: 10}
+ - {x: 41, y: 10}
+ - {x: 42, y: 10}
+ - {x: 43, y: 10}
+ - {x: 37, y: 11}
+ - {x: 38, y: 11}
+ - {x: 39, y: 11}
+ - {x: 40, y: 11}
+ - {x: 41, y: 11}
+ - {x: 42, y: 11}
+ - {x: 43, y: 11}
+ - {x: 37, y: 12}
+ - {x: 38, y: 12}
+ - {x: 39, y: 12}
+ - {x: 40, y: 12}
+ - {x: 41, y: 12}
+ - {x: 42, y: 12}
+ - {x: 43, y: 12}
+ - {x: 37, y: 13}
+ - {x: 38, y: 13}
+ - {x: 39, y: 13}
+ - {x: 40, y: 13}
+ - {x: 41, y: 13}
+ - {x: 42, y: 13}
+ - {x: 43, y: 13}
+ - {x: 37, y: 14}
+ - {x: 38, y: 14}
+ - {x: 39, y: 14}
+ - {x: 40, y: 14}
+ - {x: 41, y: 14}
+ - {x: 42, y: 14}
+ - {x: 43, y: 14}
+ - {x: 37, y: 15}
+ - {x: 38, y: 15}
+ - {x: 39, y: 15}
+ - {x: 40, y: 15}
+ - {x: 41, y: 15}
+ - {x: 42, y: 15}
+ - {x: 43, y: 15}
+ - {x: 37, y: 16}
+ - {x: 38, y: 16}
+ - {x: 39, y: 16}
+ - {x: 40, y: 16}
+ - {x: 41, y: 16}
+ - {x: 42, y: 16}
+ - {x: 43, y: 16}
+ Facing: 1
+ LeakExits: []
+ Goals:
+ - TileArea:
+ - {x: 65, y: 0}
+ - {x: 66, y: 0}
+ - {x: 67, y: 0}
+ - {x: 65, y: 1}
+ - {x: 66, y: 1}
+ - {x: 67, y: 1}
+ - {x: 65, y: 2}
+ - {x: 66, y: 2}
+ - {x: 67, y: 2}
+ - {x: 65, y: 3}
+ - {x: 66, y: 3}
+ - {x: 67, y: 3}
+ - {x: 65, y: 4}
+ - {x: 66, y: 4}
+ - {x: 67, y: 4}
+ - {x: 65, y: 5}
+ - {x: 66, y: 5}
+ - {x: 67, y: 5}
+ - {x: 65, y: 6}
+ - {x: 66, y: 6}
+ - {x: 67, y: 6}
diff --git a/Assets/_Project/Scenes/Levels/TestLevel_Thumbnail.png b/Assets/_Project/Scenes/Levels/TestLevel_Thumbnail.png
new file mode 100644
index 0000000..d469050
--- /dev/null
+++ b/Assets/_Project/Scenes/Levels/TestLevel_Thumbnail.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:9b13dafb78f7bc013cba5dd80719fd2e86d11f2601ad7103adc85bfd7aed067d
+size 5198
diff --git a/Assets/_Project/Scenes/Levels/TestLevel_Thumbnail.png.meta b/Assets/_Project/Scenes/Levels/TestLevel_Thumbnail.png.meta
new file mode 100644
index 0000000..65e59c4
--- /dev/null
+++ b/Assets/_Project/Scenes/Levels/TestLevel_Thumbnail.png.meta
@@ -0,0 +1,117 @@
+fileFormatVersion: 2
+guid: d2e652d3e1c53454d80d3c1ec7888998
+TextureImporter:
+ internalIDToNameTable: []
+ externalObjects: {}
+ serializedVersion: 13
+ mipmaps:
+ mipMapMode: 0
+ enableMipMap: 0
+ sRGBTexture: 1
+ linearTexture: 0
+ fadeOut: 0
+ borderMipMap: 0
+ mipMapsPreserveCoverage: 0
+ alphaTestReferenceValue: 0.5
+ mipMapFadeDistanceStart: 1
+ mipMapFadeDistanceEnd: 3
+ bumpmap:
+ convertToNormalMap: 0
+ externalNormalMap: 0
+ heightScale: 0.25
+ normalMapFilter: 0
+ flipGreenChannel: 0
+ isReadable: 0
+ streamingMipmaps: 0
+ streamingMipmapsPriority: 0
+ vTOnly: 0
+ ignoreMipmapLimit: 0
+ grayScaleToAlpha: 0
+ generateCubemap: 6
+ cubemapConvolution: 0
+ seamlessCubemap: 0
+ textureFormat: 1
+ maxTextureSize: 2048
+ textureSettings:
+ serializedVersion: 2
+ filterMode: 1
+ aniso: 1
+ mipBias: 0
+ wrapU: 1
+ wrapV: 1
+ wrapW: 1
+ nPOTScale: 0
+ lightmap: 0
+ compressionQuality: 50
+ spriteMode: 1
+ spriteExtrude: 1
+ spriteMeshType: 1
+ alignment: 0
+ spritePivot: {x: 0.5, y: 0.5}
+ spritePixelsToUnits: 100
+ spriteBorder: {x: 0, y: 0, z: 0, w: 0}
+ spriteGenerateFallbackPhysicsShape: 1
+ alphaUsage: 1
+ alphaIsTransparency: 1
+ spriteTessellationDetail: -1
+ textureType: 8
+ textureShape: 1
+ singleChannelComponent: 0
+ flipbookRows: 1
+ flipbookColumns: 1
+ maxTextureSizeSet: 0
+ compressionQualitySet: 0
+ textureFormatSet: 0
+ ignorePngGamma: 0
+ applyGammaDecoding: 0
+ swizzle: 50462976
+ cookieLightType: 0
+ platformSettings:
+ - serializedVersion: 4
+ buildTarget: DefaultTexturePlatform
+ maxTextureSize: 2048
+ resizeAlgorithm: 0
+ textureFormat: -1
+ textureCompression: 1
+ compressionQuality: 50
+ crunchedCompression: 0
+ allowsAlphaSplitting: 0
+ overridden: 0
+ ignorePlatformSupport: 0
+ androidETC2FallbackOverride: 0
+ forceMaximumCompressionQuality_BC6H_BC7: 0
+ - serializedVersion: 4
+ buildTarget: Standalone
+ maxTextureSize: 2048
+ resizeAlgorithm: 0
+ textureFormat: -1
+ textureCompression: 1
+ compressionQuality: 50
+ crunchedCompression: 0
+ allowsAlphaSplitting: 0
+ overridden: 0
+ ignorePlatformSupport: 0
+ androidETC2FallbackOverride: 0
+ forceMaximumCompressionQuality_BC6H_BC7: 0
+ spriteSheet:
+ serializedVersion: 2
+ sprites: []
+ outline: []
+ customData:
+ physicsShape: []
+ bones: []
+ spriteID: 5e97eb03825dee720800000000000000
+ internalID: 0
+ vertices: []
+ indices:
+ edges: []
+ weights: []
+ secondaryTextures: []
+ spriteCustomMetadata:
+ entries: []
+ nameFileIdTable: {}
+ mipmapLimitGroupName:
+ pSDRemoveMatte: 0
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/_Project/Scripts/Editor/Levels/BakeReport.cs b/Assets/_Project/Scripts/Editor/Levels/BakeReport.cs
new file mode 100644
index 0000000..bf724e0
--- /dev/null
+++ b/Assets/_Project/Scripts/Editor/Levels/BakeReport.cs
@@ -0,0 +1,86 @@
+using System.Collections.Generic;
+using System.Text;
+
+namespace TD.Levels.Editor
+{
+ ///
+ /// Accumulates errors and warnings produced during a bake run. Phases append to the report
+ /// and can check at phase boundaries to decide whether to abort.
+ ///
+ ///
+ /// The bake never bails on the first error within a phase — all errors in a phase are
+ /// collected before the phase reports back. A phase boundary is the place where the pipeline
+ /// checks and decides whether to continue or abort.
+ ///
+ public class BakeReport
+ {
+ private readonly List _errors = new List();
+ private readonly List _warnings = new List();
+
+ /// Errors collected so far. Hard-error phases abort the bake when this is non-empty.
+ public IReadOnlyList Errors => _errors;
+
+ /// Warnings collected so far. Warnings never abort the bake.
+ public IReadOnlyList Warnings => _warnings;
+
+ /// True if any hard errors have been recorded.
+ public bool HasErrors => _errors.Count > 0;
+
+ /// Total error count.
+ public int ErrorCount => _errors.Count;
+
+ /// Total warning count.
+ public int WarningCount => _warnings.Count;
+
+ /// Records a hard error. The check ID (e.g. "P2-1") is prefixed for traceability.
+ public void Error(string checkId, string message)
+ {
+ _errors.Add($"[{checkId}] {message}");
+ }
+
+ /// Records a soft warning. The check ID (e.g. "P2-8") is prefixed for traceability.
+ public void Warning(string checkId, string message)
+ {
+ _warnings.Add($"[{checkId}] {message}");
+ }
+
+ /// Records an error not associated with a numbered check (e.g. "phase 1 found null authoring").
+ public void Error(string message)
+ {
+ _errors.Add(message);
+ }
+
+ /// Records a warning not associated with a numbered check (e.g. "thumbnail render failed").
+ public void Warning(string message)
+ {
+ _warnings.Add(message);
+ }
+
+ /// Renders the full report as a human-readable string for console logging.
+ public string Format()
+ {
+ var sb = new StringBuilder();
+ if (_errors.Count > 0)
+ {
+ sb.AppendLine($"Errors ({_errors.Count}):");
+ for (int i = 0; i < _errors.Count; i++)
+ {
+ sb.AppendLine($" • {_errors[i]}");
+ }
+ }
+ if (_warnings.Count > 0)
+ {
+ sb.AppendLine($"Warnings ({_warnings.Count}):");
+ for (int i = 0; i < _warnings.Count; i++)
+ {
+ sb.AppendLine($" • {_warnings[i]}");
+ }
+ }
+ if (_errors.Count == 0 && _warnings.Count == 0)
+ {
+ sb.AppendLine("(no errors or warnings)");
+ }
+ return sb.ToString();
+ }
+ }
+}
diff --git a/Assets/_Project/Scripts/Editor/Levels/BakeReport.cs.meta b/Assets/_Project/Scripts/Editor/Levels/BakeReport.cs.meta
new file mode 100644
index 0000000..52d8f18
--- /dev/null
+++ b/Assets/_Project/Scripts/Editor/Levels/BakeReport.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: c8feac7ae8882984ab8fc3bf96e73709
\ No newline at end of file
diff --git a/Assets/_Project/Scripts/Editor/Levels/LevelAuthoringEditor.cs b/Assets/_Project/Scripts/Editor/Levels/LevelAuthoringEditor.cs
new file mode 100644
index 0000000..5379660
--- /dev/null
+++ b/Assets/_Project/Scripts/Editor/Levels/LevelAuthoringEditor.cs
@@ -0,0 +1,181 @@
+// Assets/_Project/Scripts/Editor/Levels/LevelAuthoringEditor.cs
+using UnityEditor;
+using UnityEngine;
+using TD.Core;
+
+namespace TD.Levels.Editor
+{
+ ///
+ /// Custom inspector for . Provides:
+ /// - Standard property fields (via DrawDefaultInspector)
+ /// - "Bake LevelData" button
+ /// - "Sync Status" indicator: shows whether the scene matches its baked data
+ /// - "Last Bake Status" panel (outcome, timestamp, warnings, hash, grid)
+ ///
+ ///
+ /// The Refresh Thumbnail button is intentionally not present in this version.
+ /// Refreshing a thumbnail without re-baking would require duplicating the
+ /// bake's volume-discovery and rasterization phases just to compute the map
+ /// bounds the thumbnail camera frames; the maintenance hazard outweighs the
+ /// convenience for now. Revisit when authoring real maps reveals it as a
+ /// real friction point.
+ ///
+ [CustomEditor(typeof(LevelAuthoring))]
+ public class LevelAuthoringEditor : UnityEditor.Editor
+ {
+ public override void OnInspectorGUI()
+ {
+ // Standard fields — preserve all the [Tooltip], [Header], etc. authored on the type.
+ DrawDefaultInspector();
+
+ EditorGUILayout.Space(8);
+ DrawBakeSection();
+
+ EditorGUILayout.Space(8);
+ DrawSyncStatusSection();
+
+ EditorGUILayout.Space(8);
+ DrawLastBakeStatusSection();
+ }
+
+ // ---------- Bake button ----------
+
+ private void DrawBakeSection()
+ {
+ var auth = (LevelAuthoring)target;
+
+ EditorGUILayout.LabelField("Bake", EditorStyles.boldLabel);
+
+ // Disable the button when targetAsset is unset — the bake's first hard error would be
+ // P2-13 anyway, so save the user the click.
+ using (new EditorGUI.DisabledScope(auth.targetAsset == null))
+ {
+ if (GUILayout.Button("Bake LevelData", GUILayout.Height(28)))
+ {
+ // The pipeline logs its own success/failure messages and report.
+ LevelBakePipeline.Bake(auth);
+ // Force a repaint so Sync Status and Last Bake Status update immediately.
+ Repaint();
+ }
+ }
+
+ if (auth.targetAsset == null)
+ {
+ EditorGUILayout.HelpBox(
+ "Assign a LevelData ScriptableObject to Target Asset before baking.",
+ MessageType.Info);
+ }
+ }
+
+ // ---------- Sync status indicator (new this session) ----------
+
+ private void DrawSyncStatusSection()
+ {
+ var auth = (LevelAuthoring)target;
+
+ EditorGUILayout.LabelField("Sync Status", EditorStyles.boldLabel);
+
+ // Without a target asset there's nothing to compare against.
+ if (auth.targetAsset == null)
+ {
+ EditorGUILayout.HelpBox("No target asset assigned.", MessageType.None);
+ return;
+ }
+
+ // No baked hash means the asset has never been baked; the comparison
+ // would be meaningless.
+ string bakedHash = auth.targetAsset.AuthoringHash;
+ if (string.IsNullOrEmpty(bakedHash))
+ {
+ EditorGUILayout.HelpBox(
+ "Never baked. Click Bake LevelData to generate baked data.",
+ MessageType.None);
+ return;
+ }
+
+ // Recompute the scene's authoring hash on every OnInspectorGUI call.
+ // Unity throttles inspector repaints on idle frames, and the hash
+ // is sub-millisecond on the largest planned map (~30 volumes for
+ // the 9-player map). If profiling later shows this is a problem,
+ // we can cache the hash and invalidate on Undo.undoRedoPerformed +
+ // EditorSceneManager.sceneDirtied — but the simple version is
+ // probably fine for the project's scale.
+ string currentHash = LevelBakePipeline.ComputeAuthoringHash(auth);
+
+ if (currentHash == bakedHash)
+ {
+ EditorGUILayout.HelpBox("✓ Scene matches baked data.", MessageType.Info);
+ }
+ else
+ {
+ EditorGUILayout.HelpBox(
+ "⚠ Scene differs from baked data — bake recommended.",
+ MessageType.Warning);
+
+ // Showing the first few chars of each hash makes it obvious
+ // when "out of sync" is real vs. a glitch. Useful for debugging
+ // the hash function itself if it ever misbehaves.
+ EditorGUILayout.LabelField(" Scene hash:", ShortHash(currentHash));
+ EditorGUILayout.LabelField(" Baked hash:", ShortHash(bakedHash));
+ }
+ }
+
+ // ---------- Last bake status (existing, mildly tidied) ----------
+
+ private void DrawLastBakeStatusSection()
+ {
+ var auth = (LevelAuthoring)target;
+
+ EditorGUILayout.LabelField("Last Bake Status", EditorStyles.boldLabel);
+
+ // Transient failure flag overrides the asset's recorded outcome — failed bakes don't
+ // modify the asset, so the asset still shows the prior successful bake. Surface the
+ // failure separately so the user knows the current scene's last attempt failed.
+ if (LevelAuthoringEditorState.HasUncommittedFailure)
+ {
+ EditorGUILayout.HelpBox(
+ "Last bake attempt FAILED. The target asset still reflects the previous successful bake. " +
+ "See the console for the error report.",
+ MessageType.Error);
+ }
+
+ if (auth.targetAsset == null)
+ {
+ EditorGUILayout.LabelField("(no target asset)");
+ return;
+ }
+
+ var data = auth.targetAsset;
+ if (string.IsNullOrEmpty(data.LastBakeTimestamp))
+ {
+ EditorGUILayout.LabelField("Never baked.");
+ return;
+ }
+
+ EditorGUILayout.LabelField("Outcome:", data.LastBakeOutcome.ToString());
+ EditorGUILayout.LabelField("Timestamp (UTC):", data.LastBakeTimestamp);
+ EditorGUILayout.LabelField("Warnings:", data.LastBakeWarningCount.ToString());
+
+ if (data.GridSize.x > 0 && data.GridSize.y > 0)
+ {
+ EditorGUILayout.LabelField("Grid size:", $"{data.GridSize.x} × {data.GridSize.y}");
+ EditorGUILayout.LabelField("Grid origin:", data.GridOriginTile.ToString());
+ }
+
+ if (!string.IsNullOrEmpty(data.AuthoringHash))
+ {
+ EditorGUILayout.LabelField("Hash:", ShortHash(data.AuthoringHash));
+ }
+ }
+
+ // ---------- Helpers ----------
+
+ // First 8 chars of the SHA256 hex with an ellipsis. Plenty for human
+ // comparison; the full hash is far too long for the inspector.
+ private static string ShortHash(string hash)
+ {
+ if (string.IsNullOrEmpty(hash)) return "";
+ return (hash.Length > 8 ? hash.Substring(0, 8) : hash) + "…";
+ }
+ }
+}
diff --git a/Assets/_Project/Scripts/Editor/Levels/LevelAuthoringEditor.cs.meta b/Assets/_Project/Scripts/Editor/Levels/LevelAuthoringEditor.cs.meta
new file mode 100644
index 0000000..811d0ae
--- /dev/null
+++ b/Assets/_Project/Scripts/Editor/Levels/LevelAuthoringEditor.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 46bb419f816638646b8ca42c55f72e6e
\ No newline at end of file
diff --git a/Assets/_Project/Scripts/Editor/Levels/LevelAuthoringEditorState.cs b/Assets/_Project/Scripts/Editor/Levels/LevelAuthoringEditorState.cs
new file mode 100644
index 0000000..60573f0
--- /dev/null
+++ b/Assets/_Project/Scripts/Editor/Levels/LevelAuthoringEditorState.cs
@@ -0,0 +1,26 @@
+namespace TD.Levels.Editor
+{
+ ///
+ /// Editor-only transient state for the level bake workflow. Lives only in editor memory —
+ /// not serialized to any asset, not shipped in builds.
+ ///
+ ///
+ /// The bake's failure state is intentionally not persisted to —
+ /// failed bakes don't modify the asset on disk, so the previous successful bake (if any)
+ /// remains intact. This static flag exists so the custom inspector can show a "FAILED"
+ /// status next to the last bake info, and so the play-mode dirty-detection hook (next
+ /// session) knows whether the in-memory state has uncommitted failures.
+ ///
+ /// Resets to false on success. Resets to false when Unity reloads (this is editor-only
+ /// memory, not a serialized field), which is acceptable — the user will simply see the
+ /// last successful bake's status until they bake again.
+ ///
+ public static class LevelAuthoringEditorState
+ {
+ ///
+ /// True if the most recent bake attempt failed (hard error in any phase). Cleared on
+ /// successful bake or on Unity reload.
+ ///
+ public static bool HasUncommittedFailure;
+ }
+}
diff --git a/Assets/_Project/Scripts/Editor/Levels/LevelAuthoringEditorState.cs.meta b/Assets/_Project/Scripts/Editor/Levels/LevelAuthoringEditorState.cs.meta
new file mode 100644
index 0000000..cd0d995
--- /dev/null
+++ b/Assets/_Project/Scripts/Editor/Levels/LevelAuthoringEditorState.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: aeee2805cc297ca41bab91e40dcd7dee
\ No newline at end of file
diff --git a/Assets/_Project/Scripts/Editor/Levels/LevelAuthoringPlayModeHook.cs b/Assets/_Project/Scripts/Editor/Levels/LevelAuthoringPlayModeHook.cs
new file mode 100644
index 0000000..f6363e8
--- /dev/null
+++ b/Assets/_Project/Scripts/Editor/Levels/LevelAuthoringPlayModeHook.cs
@@ -0,0 +1,195 @@
+// Assets/_Project/Scripts/Editor/Levels/LevelAuthoringPlayModeHook.cs
+using UnityEditor;
+using UnityEngine;
+using TD.Levels;
+
+namespace TD.Levels.Editor
+{
+ ///
+ /// Static editor-only hook that intercepts Play mode entry and validates the
+ /// scene's LevelAuthoring against its baked LevelData. Catches the common
+ /// failure mode of "edited the scene, forgot to bake, played stale data".
+ ///
+ /// Decision tree (executes in PlayModeStateChange.ExitingEditMode, before
+ /// Play actually begins, so we can abort cleanly):
+ /// 1. No LevelAuthoring in scene -> proceed silently
+ /// 2. Multiple LevelAuthoring -> error dialog, abort
+ /// 3. targetAsset null -> 2-button "Continue Anyway / Cancel"
+ /// 4. AuthoringHash empty -> 2-button "Bake & Play / Cancel"
+ /// 5. Hashes match -> proceed silently
+ /// 6. Hashes differ -> 3-button "Bake & Play / Play Anyway / Cancel"
+ ///
+ /// Aborts via EditorApplication.isPlaying = false during ExitingEditMode.
+ /// During this window EditorApplication.isPlayingOrWillChangePlaymode is
+ /// true but isPlaying is false; setting isPlaying = false at this point
+ /// cancels the Play attempt cleanly.
+ ///
+ [InitializeOnLoad]
+ internal static class LevelAuthoringPlayModeHook
+ {
+ // Static constructor runs on every editor reload (script recompile or
+ // domain reload), wiring the event handler. It's idempotent because
+ // we always remove first (no-op if not subscribed) then add.
+ static LevelAuthoringPlayModeHook()
+ {
+ EditorApplication.playModeStateChanged -= OnPlayModeStateChanged;
+ EditorApplication.playModeStateChanged += OnPlayModeStateChanged;
+ }
+
+ private static void OnPlayModeStateChanged(PlayModeStateChange state)
+ {
+ // We only intercept the moment we're about to enter Play mode.
+ // Other phases (EnteredPlayMode, ExitingPlayMode, EnteredEditMode)
+ // are not our concern.
+ if (state != PlayModeStateChange.ExitingEditMode)
+ return;
+
+ // Find LevelAuthoring instances in the scene. We only consider
+ // active ones; an inactive LevelAuthoring is treated as
+ // "not present" because its volumes won't be discovered by the
+ // bake's scoped scan either.
+ //
+ // FindObjectsByType is the Unity 6+ replacement for the deprecated
+ // FindObjectsOfType. We use the no-sort-mode overload: in Unity 6.4+
+ // the FindObjectsSortMode parameter has been deprecated (Unity is
+ // moving from InstanceID toward EntityId, and stable sort order
+ // can't be guaranteed in the new world). Passing it now produces
+ // a CS0618 warning. We don't need stable order here -- a single
+ // expected result, or zero, or "more than one" handled as an error.
+ var authorings = Object.FindObjectsByType(
+ FindObjectsInactive.Exclude);
+
+ // Case 1: No LevelAuthoring in scene -> proceed silently.
+ // This is the normal case for menu scenes, bootstrap scenes, etc.
+ if (authorings.Length == 0)
+ return;
+
+ // Case 2: Multiple LevelAuthoring -> error and abort.
+ // The bake assumes one per scene (it walks _LevelAuthoring's
+ // subtree), so playing with two would be ambiguous.
+ if (authorings.Length > 1)
+ {
+ EditorUtility.DisplayDialog(
+ "Multiple LevelAuthoring components",
+ $"Found {authorings.Length} LevelAuthoring components in this scene. " +
+ "Only one is allowed per scene. Remove the extras before entering Play mode.",
+ "OK");
+ AbortPlay();
+ return;
+ }
+
+ var authoring = authorings[0];
+
+ // Case 3: targetAsset is null. We can't dirty-check without one,
+ // because there's no LevelData to compare the current scene's
+ // hash against. Ask the user if they want to play anyway.
+ if (authoring.targetAsset == null)
+ {
+ bool proceed = EditorUtility.DisplayDialog(
+ "No LevelData target assigned",
+ $"The LevelAuthoring on '{authoring.gameObject.name}' has no targetAsset assigned, " +
+ "so its scene state cannot be checked against baked data.\n\n" +
+ "Continue into Play mode anyway?",
+ "Continue Anyway",
+ "Cancel");
+
+ if (!proceed)
+ AbortPlay();
+ return;
+ }
+
+ // Case 4: Asset has never been baked (hash empty/null).
+ // Offer Bake & Play, since there's no useful "play with stale data"
+ // option when there's no baked data at all.
+ string bakedHash = authoring.targetAsset.AuthoringHash;
+ if (string.IsNullOrEmpty(bakedHash))
+ {
+ bool bakeAndPlay = EditorUtility.DisplayDialog(
+ "Level has never been baked",
+ $"'{authoring.targetAsset.name}' has no baked data yet. " +
+ "Bake now and then enter Play mode?",
+ "Bake && Play",
+ "Cancel");
+
+ if (!bakeAndPlay)
+ {
+ AbortPlay();
+ return;
+ }
+
+ // Lean A: if Bake & Play is requested and the bake fails,
+ // abort Play. The bake errors will already be in the console.
+ bool bakeOk = LevelBakePipeline.Bake(authoring);
+ if (!bakeOk)
+ {
+ Debug.LogError("[PlayModeHook] Bake failed; aborting Play. See above for errors.");
+ AbortPlay();
+ }
+ return;
+ }
+
+ // Compute the hash of the current scene state and compare to baked.
+ // ComputeAuthoringHash(LevelAuthoring) is a pure function over the
+ // same canonical input string the bake uses (it delegates to the
+ // same private hash routine), so equal strings means equivalent
+ // bakes.
+ string currentHash = LevelBakePipeline.ComputeAuthoringHash(authoring);
+
+ // Case 5: Hashes match -> baked data is in sync with the scene.
+ // Proceed silently; this is the happy path.
+ if (currentHash == bakedHash)
+ return;
+
+ // Case 6: Hashes differ -> 3-button dialog with the explicit
+ // "Play Anyway" escape hatch. DisplayDialogComplex returns:
+ // 0 = primary button (Bake & Play)
+ // 1 = "alt" button (Cancel)
+ // 2 = secondary (Play Anyway)
+ // The button-to-return-code mapping is fixed by Unity's API, so
+ // we have to map our intent onto it carefully.
+ int choice = EditorUtility.DisplayDialogComplex(
+ "Scene differs from baked data",
+ $"The scene has been edited since '{authoring.targetAsset.name}' was last baked. " +
+ "Playing now will use stale baked data.\n\n" +
+ "Bake the level before playing, play with stale data anyway, or cancel?",
+ "Bake && Play", // ok / button 0
+ "Cancel", // alt / button 1
+ "Play Anyway"); // secondary / button 2
+
+ switch (choice)
+ {
+ case 0: // Bake & Play
+ bool ok = LevelBakePipeline.Bake(authoring);
+ if (!ok)
+ {
+ Debug.LogError("[PlayModeHook] Bake failed; aborting Play. See above for errors.");
+ AbortPlay();
+ }
+ return;
+
+ case 2: // Play Anyway
+ // Lean C: log a console warning so debugging weirdness
+ // later has a breadcrumb. Don't persist anything; the
+ // user explicitly chose this.
+ Debug.LogWarning(
+ $"[PlayModeHook] Entering Play mode with stale baked data for " +
+ $"'{authoring.targetAsset.name}'. Last bake: {authoring.targetAsset.LastBakeTimestamp}.");
+ return;
+
+ case 1: // Cancel
+ default:
+ AbortPlay();
+ return;
+ }
+ }
+
+ ///
+ /// Cancels the Play mode transition. Must be called during
+ /// ExitingEditMode for the cancel to take effect cleanly.
+ ///
+ private static void AbortPlay()
+ {
+ EditorApplication.isPlaying = false;
+ }
+ }
+}
\ No newline at end of file
diff --git a/Assets/_Project/Scripts/Editor/Levels/LevelAuthoringPlayModeHook.cs.meta b/Assets/_Project/Scripts/Editor/Levels/LevelAuthoringPlayModeHook.cs.meta
new file mode 100644
index 0000000..47951cc
--- /dev/null
+++ b/Assets/_Project/Scripts/Editor/Levels/LevelAuthoringPlayModeHook.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 65d1bbee09d1bd646a6bc07583836b6e
\ No newline at end of file
diff --git a/Assets/_Project/Scripts/Editor/Levels/LevelBakePipeline.cs b/Assets/_Project/Scripts/Editor/Levels/LevelBakePipeline.cs
new file mode 100644
index 0000000..6b9dd6e
--- /dev/null
+++ b/Assets/_Project/Scripts/Editor/Levels/LevelBakePipeline.cs
@@ -0,0 +1,1300 @@
+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;
+ }
+}
diff --git a/Assets/_Project/Scripts/Editor/Levels/LevelBakePipeline.cs.meta b/Assets/_Project/Scripts/Editor/Levels/LevelBakePipeline.cs.meta
new file mode 100644
index 0000000..bbf0d2e
--- /dev/null
+++ b/Assets/_Project/Scripts/Editor/Levels/LevelBakePipeline.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 5933991b7c238d048bb1c9cd2a23dbd9
\ No newline at end of file
diff --git a/Assets/_Project/Scripts/Editor/Levels/VolumeEditTool.cs b/Assets/_Project/Scripts/Editor/Levels/VolumeEditTool.cs
new file mode 100644
index 0000000..c3a439b
--- /dev/null
+++ b/Assets/_Project/Scripts/Editor/Levels/VolumeEditTool.cs
@@ -0,0 +1,214 @@
+using UnityEditor;
+using UnityEditor.EditorTools;
+using UnityEditor.ShortcutManagement;
+using UnityEngine;
+using TD.Core;
+
+namespace TD.Levels.Editor
+{
+ ///
+ /// Custom scene-view tool for resizing volumes by dragging individual
+ /// edges, instead of Unity's default symmetric BoxCollider sizing. Each drag is snapped to
+ /// whole tiles (1.0 world units), so volumes always stay tile-aligned.
+ ///
+ ///
+ /// Activate from the scene-view toolbar when a VolumeBase is selected. Four handles appear at
+ /// the midpoints of the volume's four horizontal edges (N/S/E/W). Dragging a handle moves only
+ /// that edge — the opposite edge stays put.
+ ///
+ /// The tool refuses to operate on rotated volumes (P2-19 hard-error in bake). Rotation must
+ /// be zeroed before the tool will edit. This surfaces the bake constraint at authoring time.
+ ///
+ /// Edits modify and together;
+ /// the GameObject's Transform is left alone. This keeps the asymmetry contained to the
+ /// collider component, where it belongs.
+ ///
+ [EditorTool("Resize Volume Edge", typeof(VolumeBase))]
+ public class VolumeEditTool : EditorTool
+ {
+ // Visual constants.
+ private const float HandleY = 0.10f; // world Y at which to draw handles
+ private const float HandleSize = 0.30f; // world-space radius of the handle disc
+ private const float MinSize = 1.0f; // smallest allowed size on either tile axis (1 tile)
+ private const float SnapIncrement = 1.0f; // tile size — the snap unit
+
+ // Handle colors (X-axis red, Z-axis blue, matching Unity's standard convention).
+ private static readonly Color XAxisColor = new Color(0.95f, 0.35f, 0.35f, 1f);
+ private static readonly Color ZAxisColor = new Color(0.35f, 0.55f, 0.95f, 1f);
+
+ public override void OnToolGUI(EditorWindow window)
+ {
+ // Multi-selection: don't draw anything when more than one volume is selected. Bulk
+ // edge-edits are ambiguous (which volumes' edges align?) and out of scope for now.
+ if (targets == null) return;
+ int count = 0;
+ foreach (var _ in targets) { count++; if (count > 1) break; }
+ if (count != 1) return;
+
+ var volume = target as VolumeBase;
+ if (volume == null) return;
+
+ var col = volume.GetComponent();
+ if (col == null) return;
+
+ // Refuse to operate on rotated volumes. Show an in-scene warning instead.
+ Vector3 e = volume.transform.eulerAngles;
+ if (!IsZeroRotation(e))
+ {
+ DrawRotationWarning(volume, e);
+ return;
+ }
+
+ DrawEdgeHandles(volume, col);
+ }
+
+ private static bool IsZeroRotation(Vector3 eulerAngles)
+ {
+ const float tol = 0.01f;
+ // Normalize each axis to (-180..180] before comparing.
+ float dx = NormalizeAngle(eulerAngles.x);
+ float dy = NormalizeAngle(eulerAngles.y);
+ float dz = NormalizeAngle(eulerAngles.z);
+ return Mathf.Abs(dx) <= tol && Mathf.Abs(dy) <= tol && Mathf.Abs(dz) <= tol;
+ }
+
+ private static float NormalizeAngle(float a)
+ {
+ a = a % 360f;
+ if (a > 180f) a -= 360f;
+ else if (a < -180f) a += 360f;
+ return a;
+ }
+
+ private void DrawRotationWarning(VolumeBase volume, Vector3 euler)
+ {
+ var col = volume.GetComponent();
+ Vector3 labelPos = col != null
+ ? new Vector3(col.bounds.center.x, HandleY + 0.5f, col.bounds.center.z)
+ : volume.transform.position + Vector3.up * 0.5f;
+
+ // Use the editor's Handles label with a colored background-ish hint via a sphere.
+ Handles.color = Color.red;
+ Handles.SphereHandleCap(0, labelPos, Quaternion.identity, 0.2f, EventType.Repaint);
+ Handles.Label(labelPos + new Vector3(0.3f, 0f, 0.3f),
+ $"VolumeEditTool: zero out rotation to edit edges (current: {euler}).");
+ }
+
+ private void DrawEdgeHandles(VolumeBase volume, BoxCollider col)
+ {
+ // Compute the four edge-midpoint world positions. We treat the volume's local axes as
+ // world axes (zero rotation already enforced above). The transform's position can still
+ // translate the volume, so we use TransformPoint to get world positions.
+ //
+ // BoxCollider.center and BoxCollider.size are LOCAL to the GameObject's transform.
+ Vector3 localCenter = col.center;
+ Vector3 localSize = col.size;
+ float halfX = localSize.x * 0.5f;
+ float halfZ = localSize.z * 0.5f;
+
+ Vector3 eastLocal = localCenter + new Vector3(halfX, 0f, 0f);
+ Vector3 westLocal = localCenter + new Vector3(-halfX, 0f, 0f);
+ Vector3 northLocal = localCenter + new Vector3(0f, 0f, halfZ);
+ Vector3 southLocal = localCenter + new Vector3(0f, 0f, -halfZ);
+
+ Transform t = volume.transform;
+ Vector3 eastWorld = WithY(t.TransformPoint(eastLocal), HandleY);
+ Vector3 westWorld = WithY(t.TransformPoint(westLocal), HandleY);
+ Vector3 northWorld = WithY(t.TransformPoint(northLocal), HandleY);
+ Vector3 southWorld = WithY(t.TransformPoint(southLocal), HandleY);
+
+ // Render and process each handle. Handles.Slider returns the new world position; we
+ // compute the snapped tile-delta and apply it to the collider's size/center.
+ DrawAxisHandle(col, eastWorld, Vector3.right, axisIsX: true, edgeIsPositive: true, XAxisColor);
+ DrawAxisHandle(col, westWorld, Vector3.right, axisIsX: true, edgeIsPositive: false, XAxisColor);
+ DrawAxisHandle(col, northWorld, Vector3.forward, axisIsX: false, edgeIsPositive: true, ZAxisColor);
+ DrawAxisHandle(col, southWorld, Vector3.forward, axisIsX: false, edgeIsPositive: false, ZAxisColor);
+ }
+
+ // Draw a single edge handle and apply the resulting size/center change to the collider.
+ // - `direction`: the world axis the handle slides along (Vector3.right for E/W, Vector3.forward for N/S).
+ // - `axisIsX`: true if this handle modifies size.x/center.x; false if size.z/center.z.
+ // - `edgeIsPositive`: true if this is the +axis edge (East or North); false for -axis (West or South).
+ private void DrawAxisHandle(BoxCollider col, Vector3 worldPos, Vector3 direction,
+ bool axisIsX, bool edgeIsPositive, Color color)
+ {
+ Handles.color = color;
+
+ EditorGUI.BeginChangeCheck();
+ // FreeMoveHandle would allow drag in any direction; we constrain to the axis using Slider.
+ Vector3 newWorldPos = Handles.Slider(worldPos, direction, HandleSize, Handles.SphereHandleCap, 0f);
+ if (!EditorGUI.EndChangeCheck()) return;
+
+ // How far did the handle move along its axis, in world units?
+ float worldDelta = axisIsX
+ ? (newWorldPos.x - worldPos.x)
+ : (newWorldPos.z - worldPos.z);
+
+ // Snap to whole tiles. Discard sub-tile motion until the user drags a full tile.
+ int tileDelta = Mathf.RoundToInt(worldDelta / SnapIncrement);
+ if (tileDelta == 0) return;
+
+ float worldDeltaSnapped = tileDelta * SnapIncrement;
+
+ // For the negative edge (W or S), dragging "outward" means dragging toward more
+ // negative axis values; the size should still grow. Normalize so a positive `outwardDelta`
+ // always means "grow."
+ float outwardDelta = edgeIsPositive ? worldDeltaSnapped : -worldDeltaSnapped;
+
+ Vector3 size = col.size;
+ Vector3 center = col.center;
+
+ float currentSize = axisIsX ? size.x : size.z;
+ float newSize = currentSize + outwardDelta;
+
+ // Clamp at minimum tile size. If the user tries to shrink past min, discard the input
+ // (don't move the edge at all). This is more predictable than partially honoring it.
+ if (newSize < MinSize) return;
+
+ // Apply the change. The edge that's NOT being dragged should stay put. To keep the
+ // opposite edge fixed, the center must shift by half the size change in the direction
+ // of the edge being dragged.
+ //
+ // Example (east edge dragged outward by 2 tiles): size.x += 2; center.x += 1.
+ // Example (west edge dragged outward by 1 tile): size.x += 1; center.x -= 0.5.
+ float centerShift = (edgeIsPositive ? 1f : -1f) * (outwardDelta * 0.5f);
+
+ if (axisIsX)
+ {
+ size.x = newSize;
+ center.x += centerShift;
+ }
+ else
+ {
+ size.z = newSize;
+ center.z += centerShift;
+ }
+
+ Undo.RecordObject(col, "Resize Volume Edge");
+ col.size = size;
+ col.center = center;
+ EditorUtility.SetDirty(col);
+ }
+
+ private static Vector3 WithY(Vector3 v, float y)
+ {
+ v.y = y;
+ return v;
+ }
+
+ // -------------------------------------------------------------------
+ // Hotkey activation. The [Shortcut] attribute registers this with Unity's Shortcut Manager
+ // (Edit → Shortcuts), where it can be rebound. The default binding is B, mnemonic for "Box".
+ //
+ // The shortcut activates the tool whether or not a VolumeBase is currently selected — when
+ // nothing relevant is selected, the tool is "active" but draws nothing. The handles appear
+ // as soon as a VolumeBase is selected.
+ // -------------------------------------------------------------------
+
+ [Shortcut("Tools/Resize Volume Edge", KeyCode.B)]
+ private static void ActivateShortcut()
+ {
+ ToolManager.SetActiveTool();
+ }
+ }
+}
\ No newline at end of file
diff --git a/Assets/_Project/Scripts/Editor/Levels/VolumeEditTool.cs.meta b/Assets/_Project/Scripts/Editor/Levels/VolumeEditTool.cs.meta
new file mode 100644
index 0000000..50c1823
--- /dev/null
+++ b/Assets/_Project/Scripts/Editor/Levels/VolumeEditTool.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: f4527cb4870dae24280db768d6af69c8
\ No newline at end of file
diff --git a/Assets/_Project/Scripts/Gameplay/LevelLoader.cs b/Assets/_Project/Scripts/Gameplay/LevelLoader.cs
new file mode 100644
index 0000000..01c6969
--- /dev/null
+++ b/Assets/_Project/Scripts/Gameplay/LevelLoader.cs
@@ -0,0 +1,454 @@
+// Assets/_Project/Scripts/Gameplay/LevelLoader.cs
+using UnityEngine;
+using TD.Core;
+using TD.Levels;
+
+namespace TD.Gameplay
+{
+ ///
+ /// Match-time loader for a baked asset. Owns the
+ /// runtime representation of the level: the buildable-plane physics
+ /// collider that click-to-tile raycasts target, and tile-query accessors
+ /// for the rest of the gameplay code.
+ ///
+ ///
+ /// Plain MonoBehaviour, not NetworkBehaviour. The baked
+ /// LevelData is identical on every peer (same asset, same content), so
+ /// there's no value in syncing it through Netcode. Each peer loads
+ /// locally on scene-load.
+ ///
+ /// Mutable runtime state (walkability changes as towers are placed) lives
+ /// here for now. When server-authoritative tower placement is implemented,
+ /// the mutable walkability grid will move to a dedicated
+ /// MatchState NetworkBehaviour alongside lives, wave info, etc.
+ /// Today's goal is the minimum loader surface area needed for everything
+ /// that comes next.
+ ///
+ /// Singleton access: is set in Awake and
+ /// cleared in OnDestroy. Consumers null-check before use.
+ ///
+ public class LevelLoader : MonoBehaviour
+ {
+ // ----- Singleton --------------------------------------------------
+
+ ///
+ /// The currently loaded LevelLoader. Null before any scene with a
+ /// LevelLoader has Awake'd, and during scene transitions. Always
+ /// null-check before use.
+ ///
+ public static LevelLoader Instance { get; private set; }
+
+ // ----- Inspector fields -------------------------------------------
+
+ [Header("Level")]
+
+ [Tooltip("Baked LevelData asset to load. Required.")]
+ [SerializeField] private LevelData level;
+
+ [Tooltip("Name of the physics layer used by the buildable-plane collider. " +
+ "Click-to-tile raycasts should target this layer. Layer must exist " +
+ "in Project Settings → Tags and Layers.")]
+ [SerializeField] private string buildablePlaneLayerName = "BuildablePlane";
+
+ [Header("Debug Gizmos")]
+
+ [Tooltip("Draw the grid's bounding rectangle (always visible).")]
+ [SerializeField] private bool drawGridBounds = true;
+
+ [Tooltip("Draw a translucent overlay showing walkable vs. non-walkable tiles.")]
+ [SerializeField] private bool drawWalkability = true;
+
+ [Tooltip("Draw the buildable-plane collider's footprint.")]
+ [SerializeField] private bool drawBuildablePlane = true;
+
+ [Tooltip("Draw owner-grid tile borders. Only visible when LevelLoader is selected.")]
+ [SerializeField] private bool drawOwnerBorders = true;
+
+ // ----- Runtime state ----------------------------------------------
+
+ /// The loaded LevelData asset, or null if loading failed.
+ public LevelData LevelData => level;
+
+ /// True if the loader successfully initialized at match start.
+ public bool IsLoaded { get; private set; }
+
+ // The mutable walkability grid. Initialized from LevelData.WalkabilityGrid
+ // and mutated by tower placement at runtime. Stays in lockstep with the
+ // baked grid until towers are placed (none yet, since tower placement
+ // isn't implemented).
+ //
+ // This is intentionally NOT exposed through a property yet -- consumers
+ // will query through IsWalkable(Vector2Int) instead, hiding the array
+ // indexing. When tower placement needs to mutate it, we'll expose a
+ // SetWalkable method then. Easier to add than to take away.
+ private bool[] runtimeWalkability;
+
+ // The buildable-plane GameObject we instantiated as our child.
+ // Cached for inspector debugging and future destruction.
+ private GameObject buildablePlaneGO;
+ private BoxCollider buildablePlaneCollider;
+
+ // ----- Lifecycle --------------------------------------------------
+
+ private void Awake()
+ {
+ if (Instance != null && Instance != this)
+ {
+ // Two LevelLoaders in one scene is a setup error -- the bake
+ // pipeline assumes one map per scene, and the singleton doesn't
+ // make sense with multiples. Log loudly and let the second one
+ // keep going inert (Instance still points at the first).
+ Debug.LogError(
+ $"[LevelLoader] Multiple LevelLoader instances in scene. " +
+ $"Existing: '{Instance.gameObject.name}'. New: '{gameObject.name}'. " +
+ $"This loader will not initialize.");
+ return;
+ }
+ Instance = this;
+
+ if (!ValidateInputs())
+ {
+ IsLoaded = false;
+ return;
+ }
+
+ InitializeRuntimeWalkability();
+ SpawnBuildablePlane();
+
+ IsLoaded = true;
+ LogLoadSummary();
+ }
+
+ private void OnDestroy()
+ {
+ if (Instance == this)
+ {
+ Instance = null;
+ }
+ // The buildable plane GameObject is our child, so Unity will
+ // destroy it automatically when this object is destroyed.
+ }
+
+ // ----- Loading steps ----------------------------------------------
+
+ private bool ValidateInputs()
+ {
+ if (level == null)
+ {
+ Debug.LogError(
+ $"[LevelLoader] '{gameObject.name}': LevelData reference is not assigned. " +
+ $"Assign a baked LevelData asset in the inspector.");
+ return false;
+ }
+
+ // Grid arrays must be present and consistent. An unbaked LevelData
+ // has empty arrays; a corrupted one might have mismatched sizes.
+ int expectedLength = level.GridSize.x * level.GridSize.y;
+ if (level.GridSize.x <= 0 || level.GridSize.y <= 0 || expectedLength <= 0)
+ {
+ Debug.LogError(
+ $"[LevelLoader] '{level.name}' has invalid grid size {level.GridSize}. " +
+ $"Re-bake the level.");
+ return false;
+ }
+ if (level.WalkabilityGrid == null || level.WalkabilityGrid.Length != expectedLength ||
+ level.PlacementGrid == null || level.PlacementGrid.Length != expectedLength ||
+ level.OwnerGrid == null || level.OwnerGrid.Length != expectedLength)
+ {
+ Debug.LogError(
+ $"[LevelLoader] '{level.name}' has inconsistent grid arrays. " +
+ $"Expected each grid to have {expectedLength} entries " +
+ $"(GridSize {level.GridSize.x}×{level.GridSize.y}). Re-bake the level.");
+ return false;
+ }
+
+ // Physics layer must exist. NameToLayer returns -1 for unknown
+ // names; that's the failure signal.
+ int layerIndex = LayerMask.NameToLayer(buildablePlaneLayerName);
+ if (layerIndex < 0)
+ {
+ Debug.LogError(
+ $"[LevelLoader] Physics layer '{buildablePlaneLayerName}' does not exist. " +
+ $"Create it in Project Settings → Tags and Layers, then assign it to the " +
+ $"buildablePlaneLayerName field if you used a different name.");
+ return false;
+ }
+
+ return true;
+ }
+
+ private void InitializeRuntimeWalkability()
+ {
+ // Copy the baked walkability into a runtime array. We don't reuse
+ // level.WalkabilityGrid directly because (a) the runtime grid will
+ // mutate as towers are placed, and we don't want to mutate the
+ // ScriptableObject asset; (b) ScriptableObject array fields are
+ // shared across the editor and runtime, so mutating it would persist
+ // tower placements across Play sessions.
+ runtimeWalkability = new bool[level.WalkabilityGrid.Length];
+ System.Array.Copy(
+ level.WalkabilityGrid, runtimeWalkability, level.WalkabilityGrid.Length);
+ }
+
+ private void SpawnBuildablePlane()
+ {
+ // Compute the world-space center and size of the grid.
+ //
+ // The grid covers tiles from GridOriginTile (inclusive, SW corner)
+ // to GridOriginTile + GridSize - (1,1) (inclusive, NE corner).
+ // Each tile is TILE_SIZE wide and centered on its integer coords.
+ //
+ // World extent on X:
+ // left = (GridOriginTile.x - 0.5) * TILE_SIZE
+ // right = (GridOriginTile.x + GridSize.x - 0.5) * TILE_SIZE
+ // width = GridSize.x * TILE_SIZE
+ // centerX = (left + right) / 2 = (GridOriginTile.x + (GridSize.x - 1) / 2) * TILE_SIZE
+ //
+ // Same shape on Z (grid-y maps to world-z).
+ float worldCenterX =
+ (level.GridOriginTile.x + (level.GridSize.x - 1) * 0.5f) * GridCoordinates.TILE_SIZE;
+ float worldCenterZ =
+ (level.GridOriginTile.y + (level.GridSize.y - 1) * 0.5f) * GridCoordinates.TILE_SIZE;
+ float worldSizeX = level.GridSize.x * GridCoordinates.TILE_SIZE;
+ float worldSizeZ = level.GridSize.y * GridCoordinates.TILE_SIZE;
+
+ buildablePlaneGO = new GameObject("__BuildablePlane");
+ buildablePlaneGO.transform.SetParent(transform, worldPositionStays: false);
+ buildablePlaneGO.transform.localPosition = Vector3.zero;
+ buildablePlaneGO.transform.localRotation = Quaternion.identity;
+ buildablePlaneGO.transform.localScale = Vector3.one;
+ buildablePlaneGO.layer = LayerMask.NameToLayer(buildablePlaneLayerName);
+
+ buildablePlaneCollider = buildablePlaneGO.AddComponent();
+ // Position the collider center in WORLD space coords by setting
+ // BoxCollider.center after the GameObject is at origin. Since the
+ // GO transform is identity at origin, local == world.
+ buildablePlaneCollider.center = new Vector3(
+ worldCenterX, GridCoordinates.BUILDABLE_PLANE_Y, worldCenterZ);
+ // Y size: a thin sliver. We give it a non-zero height so raycasts
+ // from above and below both register, and so floating-point
+ // imprecision in raycast origin doesn't make hits fail at the
+ // exact Y=0 plane. 0.1 world units is plenty.
+ buildablePlaneCollider.size = new Vector3(worldSizeX, 0.1f, worldSizeZ);
+ // Default isTrigger=false; raycasts hit it as a solid collider.
+ // If we ever want it to ignore physics simulation while still
+ // being raycastable, isTrigger=true also works for Raycast (with
+ // QueryTriggerInteraction.Collide).
+ }
+
+ private void LogLoadSummary()
+ {
+ Vector3 c = buildablePlaneCollider.center;
+ Vector3 s = buildablePlaneCollider.size;
+ Debug.Log(
+ $"[LevelLoader] Loaded '{level.MapName}' " +
+ $"(playerCount={level.PlayerCount}, " +
+ $"grid {level.GridSize.x}×{level.GridSize.y} from origin {level.GridOriginTile}). " +
+ $"Buildable plane at world ({c.x:F2}, {c.y:F2}, {c.z:F2}) " +
+ $"size ({s.x:F2}, {s.y:F2}, {s.z:F2}) on layer '{buildablePlaneLayerName}'.");
+ }
+
+ // ----- Public tile queries ----------------------------------------
+ //
+ // All queries take WORLD-TILE coordinates (the same coordinates
+ // GridCoordinates.WorldToGrid returns). Internally we translate by
+ // GridOriginTile to index into the flat arrays. Consumers should
+ // never see grid-array indices.
+
+ /// True if is within the loaded grid's bounds.
+ public bool InBounds(Vector2Int tile)
+ {
+ if (!IsLoaded) return false;
+ int x = tile.x - level.GridOriginTile.x;
+ int y = tile.y - level.GridOriginTile.y;
+ return x >= 0 && x < level.GridSize.x && y >= 0 && y < level.GridSize.y;
+ }
+
+ ///
+ /// True if is currently walkable. Returns
+ /// false for out-of-bounds tiles. Reflects the runtime walkability
+ /// grid (which will mutate as towers are placed in future work).
+ ///
+ public bool IsWalkable(Vector2Int tile)
+ {
+ if (!TryFlatIndex(tile, out int idx)) return false;
+ return runtimeWalkability[idx];
+ }
+
+ ///
+ /// Placement state for . Returns
+ /// for out-of-bounds tiles.
+ /// Reflects the baked placement grid; this does NOT change at runtime.
+ ///
+ public PlacementState GetPlacement(Vector2Int tile)
+ {
+ if (!TryFlatIndex(tile, out int idx)) return PlacementState.Outside;
+ return level.PlacementGrid[idx];
+ }
+
+ ///
+ /// Owning player for . Returns
+ /// for out-of-bounds tiles or tiles not
+ /// inside any player zone.
+ ///
+ public PlayerSlot GetOwner(Vector2Int tile)
+ {
+ if (!TryFlatIndex(tile, out int idx)) return PlayerSlot.None;
+ return level.OwnerGrid[idx];
+ }
+
+ // Translates world-tile coordinates to a flat-array index, returning
+ // false if the tile is out of bounds. Used by all query methods.
+ private bool TryFlatIndex(Vector2Int tile, out int idx)
+ {
+ idx = 0;
+ if (!IsLoaded) return false;
+ int x = tile.x - level.GridOriginTile.x;
+ int y = tile.y - level.GridOriginTile.y;
+ if (x < 0 || x >= level.GridSize.x || y < 0 || y >= level.GridSize.y) return false;
+ idx = y * level.GridSize.x + x;
+ return true;
+ }
+
+ // ----- Gizmos -----------------------------------------------------
+ //
+ // Gizmos run in both edit mode and play mode. In edit mode we use the
+ // baked walkability/owner grids from the LevelData asset directly,
+ // because runtimeWalkability hasn't been initialized yet. In play mode
+ // we use runtimeWalkability so the visualization reflects any future
+ // tower stamps. Owner and placement grids are immutable, so we read
+ // them from the asset in both modes.
+
+ private void OnDrawGizmos()
+ {
+ if (level == null) return;
+ if (level.GridSize.x <= 0 || level.GridSize.y <= 0) return;
+
+ if (drawGridBounds) DrawGridBoundsGizmo();
+ if (drawBuildablePlane) DrawBuildablePlaneGizmo();
+ if (drawWalkability) DrawWalkabilityGizmo();
+ }
+
+ private void OnDrawGizmosSelected()
+ {
+ if (level == null) return;
+ if (level.GridSize.x <= 0 || level.GridSize.y <= 0) return;
+
+ if (drawOwnerBorders) DrawOwnerBordersGizmo();
+ }
+
+ private void DrawGridBoundsGizmo()
+ {
+ // One outlined wire box covering the entire grid extent.
+ float halfTile = GridCoordinates.TILE_SIZE * 0.5f;
+ Vector3 sw = new Vector3(
+ level.GridOriginTile.x * GridCoordinates.TILE_SIZE - halfTile,
+ GridCoordinates.BUILDABLE_PLANE_Y,
+ level.GridOriginTile.y * GridCoordinates.TILE_SIZE - halfTile);
+ Vector3 ne = new Vector3(
+ (level.GridOriginTile.x + level.GridSize.x) * GridCoordinates.TILE_SIZE - halfTile,
+ GridCoordinates.BUILDABLE_PLANE_Y,
+ (level.GridOriginTile.y + level.GridSize.y) * GridCoordinates.TILE_SIZE - halfTile);
+
+ Gizmos.color = new Color(1f, 1f, 1f, 0.9f); // bright white outline
+ Vector3 nw = new Vector3(sw.x, sw.y, ne.z);
+ Vector3 se = new Vector3(ne.x, sw.y, sw.z);
+ Gizmos.DrawLine(sw, nw);
+ Gizmos.DrawLine(nw, ne);
+ Gizmos.DrawLine(ne, se);
+ Gizmos.DrawLine(se, sw);
+ }
+
+ private void DrawBuildablePlaneGizmo()
+ {
+ // In play mode the collider exists; draw it directly. In edit mode
+ // we don't have a collider yet, but we can draw the rectangle that
+ // the loader WOULD instantiate, so designers can preview it.
+ float worldCenterX =
+ (level.GridOriginTile.x + (level.GridSize.x - 1) * 0.5f) * GridCoordinates.TILE_SIZE;
+ float worldCenterZ =
+ (level.GridOriginTile.y + (level.GridSize.y - 1) * 0.5f) * GridCoordinates.TILE_SIZE;
+ float worldSizeX = level.GridSize.x * GridCoordinates.TILE_SIZE;
+ float worldSizeZ = level.GridSize.y * GridCoordinates.TILE_SIZE;
+
+ Gizmos.color = new Color(0.3f, 0.6f, 1f, 0.10f); // translucent blue fill
+ Gizmos.DrawCube(
+ new Vector3(worldCenterX, GridCoordinates.BUILDABLE_PLANE_Y, worldCenterZ),
+ new Vector3(worldSizeX, 0.02f, worldSizeZ));
+ }
+
+ private void DrawWalkabilityGizmo()
+ {
+ // Per-tile translucent fill: green tint for walkable, red tint
+ // for non-walkable. Edit mode reads from the baked asset; play
+ // mode reads from the runtime grid.
+ bool[] walk = (Application.isPlaying && runtimeWalkability != null)
+ ? runtimeWalkability
+ : level.WalkabilityGrid;
+ if (walk == null || walk.Length != level.GridSize.x * level.GridSize.y) return;
+
+ Color walkable = new Color(0.2f, 0.9f, 0.2f, 0.10f);
+ Color blocked = new Color(0.9f, 0.2f, 0.2f, 0.20f);
+
+ float tile = GridCoordinates.TILE_SIZE;
+ // Slight Y offset so this sits above the buildable-plane gizmo
+ // rather than z-fighting with it.
+ float drawY = GridCoordinates.BUILDABLE_PLANE_Y + 0.005f;
+ Vector3 size = new Vector3(tile * 0.95f, 0.001f, tile * 0.95f);
+
+ for (int y = 0; y < level.GridSize.y; y++)
+ {
+ for (int x = 0; x < level.GridSize.x; x++)
+ {
+ int idx = y * level.GridSize.x + x;
+ Gizmos.color = walk[idx] ? walkable : blocked;
+ Vector3 c = new Vector3(
+ (level.GridOriginTile.x + x) * tile,
+ drawY,
+ (level.GridOriginTile.y + y) * tile);
+ Gizmos.DrawCube(c, size);
+ }
+ }
+ }
+
+ private void DrawOwnerBordersGizmo()
+ {
+ // One thin outlined square per tile that has an owner, colored
+ // with that owner's player color. Drawn only when LevelLoader is
+ // selected to keep the scene view from getting too noisy.
+ if (level.OwnerGrid == null ||
+ level.OwnerGrid.Length != level.GridSize.x * level.GridSize.y) return;
+
+ float tile = GridCoordinates.TILE_SIZE;
+ float halfTile = tile * 0.5f;
+ float drawY = GridCoordinates.BUILDABLE_PLANE_Y + 0.010f;
+
+ for (int y = 0; y < level.GridSize.y; y++)
+ {
+ for (int x = 0; x < level.GridSize.x; x++)
+ {
+ int idx = y * level.GridSize.x + x;
+ PlayerSlot owner = level.OwnerGrid[idx];
+ if (owner == PlayerSlot.None) continue;
+
+ Vector3 c = new Vector3(
+ (level.GridOriginTile.x + x) * tile,
+ drawY,
+ (level.GridOriginTile.y + y) * tile);
+
+ Gizmos.color = PlayerColors.Get(owner);
+ // Draw four edges as a wire square. We could DrawWireCube
+ // but it would also draw vertical edges we don't want.
+ Vector3 sw = new Vector3(c.x - halfTile, drawY, c.z - halfTile);
+ Vector3 nw = new Vector3(c.x - halfTile, drawY, c.z + halfTile);
+ Vector3 ne = new Vector3(c.x + halfTile, drawY, c.z + halfTile);
+ Vector3 se = new Vector3(c.x + halfTile, drawY, c.z - halfTile);
+ Gizmos.DrawLine(sw, nw);
+ Gizmos.DrawLine(nw, ne);
+ Gizmos.DrawLine(ne, se);
+ Gizmos.DrawLine(se, sw);
+ }
+ }
+ }
+ }
+}
diff --git a/Assets/_Project/Scripts/Gameplay/LevelLoader.cs.meta b/Assets/_Project/Scripts/Gameplay/LevelLoader.cs.meta
new file mode 100644
index 0000000..6475f2b
--- /dev/null
+++ b/Assets/_Project/Scripts/Gameplay/LevelLoader.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: a303b690faebb0e4e930d1714afa424e
\ No newline at end of file
diff --git a/ProjectSettings/TagManager.asset b/ProjectSettings/TagManager.asset
index 6413d11..1d9a303 100644
--- a/ProjectSettings/TagManager.asset
+++ b/ProjectSettings/TagManager.asset
@@ -2,7 +2,7 @@
%TAG !u! tag:unity3d.com,2011:
--- !u!78 &1
TagManager:
- serializedVersion: 2
+ serializedVersion: 3
tags: []
layers:
- Default
@@ -11,7 +11,7 @@ TagManager:
-
- Water
- UI
- -
+ - BuildablePlane
-
-
-
@@ -50,27 +50,4 @@ TagManager:
- Light Layer 5
- Light Layer 6
- Light Layer 7
- -
- -
- -
- -
- -
- -
- -
- -
- -
- -
- -
- -
- -
- -
- -
- -
- -
- -
- -
- -
- -
- -
- -
- -
+ m_MigratedRenderPipelines: []