From b44eeaeeffcb2d78d291d2fc81565aa1ef15201f Mon Sep 17 00:00:00 2001 From: Matt F Date: Fri, 1 May 2026 10:50:03 -0700 Subject: [PATCH] Major changes to editor tools, and adding new layer for buildable towers --- .gitignore | 1 + Assets/_Project/Levels/LevelAuthoring.cs | 57 +- Assets/_Project/Scenes/Levels/Main.unity | 75 +- Assets/_Project/Scenes/Levels/TestLevel.asset | 204 ++- .../Scenes/Levels/TestLevel_Thumbnail.png | 3 + .../Levels/TestLevel_Thumbnail.png.meta | 117 ++ .../Scripts/Editor/Levels/BakeReport.cs | 86 ++ .../Scripts/Editor/Levels/BakeReport.cs.meta | 2 + .../Editor/Levels/LevelAuthoringEditor.cs | 181 +++ .../Levels/LevelAuthoringEditor.cs.meta | 2 + .../Levels/LevelAuthoringEditorState.cs | 26 + .../Levels/LevelAuthoringEditorState.cs.meta | 2 + .../Levels/LevelAuthoringPlayModeHook.cs | 195 +++ .../Levels/LevelAuthoringPlayModeHook.cs.meta | 2 + .../Editor/Levels/LevelBakePipeline.cs | 1300 +++++++++++++++++ .../Editor/Levels/LevelBakePipeline.cs.meta | 2 + .../Scripts/Editor/Levels/VolumeEditTool.cs | 214 +++ .../Editor/Levels/VolumeEditTool.cs.meta | 2 + .../_Project/Scripts/Gameplay/LevelLoader.cs | 454 ++++++ .../Scripts/Gameplay/LevelLoader.cs.meta | 2 + ProjectSettings/TagManager.asset | 29 +- 21 files changed, 2867 insertions(+), 89 deletions(-) create mode 100644 Assets/_Project/Scenes/Levels/TestLevel_Thumbnail.png create mode 100644 Assets/_Project/Scenes/Levels/TestLevel_Thumbnail.png.meta create mode 100644 Assets/_Project/Scripts/Editor/Levels/BakeReport.cs create mode 100644 Assets/_Project/Scripts/Editor/Levels/BakeReport.cs.meta create mode 100644 Assets/_Project/Scripts/Editor/Levels/LevelAuthoringEditor.cs create mode 100644 Assets/_Project/Scripts/Editor/Levels/LevelAuthoringEditor.cs.meta create mode 100644 Assets/_Project/Scripts/Editor/Levels/LevelAuthoringEditorState.cs create mode 100644 Assets/_Project/Scripts/Editor/Levels/LevelAuthoringEditorState.cs.meta create mode 100644 Assets/_Project/Scripts/Editor/Levels/LevelAuthoringPlayModeHook.cs create mode 100644 Assets/_Project/Scripts/Editor/Levels/LevelAuthoringPlayModeHook.cs.meta create mode 100644 Assets/_Project/Scripts/Editor/Levels/LevelBakePipeline.cs create mode 100644 Assets/_Project/Scripts/Editor/Levels/LevelBakePipeline.cs.meta create mode 100644 Assets/_Project/Scripts/Editor/Levels/VolumeEditTool.cs create mode 100644 Assets/_Project/Scripts/Editor/Levels/VolumeEditTool.cs.meta create mode 100644 Assets/_Project/Scripts/Gameplay/LevelLoader.cs create mode 100644 Assets/_Project/Scripts/Gameplay/LevelLoader.cs.meta 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: []