Major changes to editor tools, and adding new layer for buildable towers
This commit is contained in:
parent
a4e28bc93f
commit
b44eeaeeff
21 changed files with 2867 additions and 89 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -104,3 +104,4 @@ InitTestScene*.unity*
|
||||||
|
|
||||||
# Auto-generated cache in Assets folder
|
# Auto-generated cache in Assets folder
|
||||||
/[Aa]ssets/[Ss]ceneDependencyCache*
|
/[Aa]ssets/[Ss]ceneDependencyCache*
|
||||||
|
/Assets/_Recovery
|
||||||
|
|
|
||||||
|
|
@ -70,39 +70,12 @@ namespace TD.Levels
|
||||||
public bool alwaysShowGoals = false;
|
public bool alwaysShowGoals = false;
|
||||||
|
|
||||||
// -------------------------------------------------------------------
|
// -------------------------------------------------------------------
|
||||||
// Bake API (stubs this session — full implementation comes next session).
|
// Bake API
|
||||||
// -------------------------------------------------------------------
|
// -------------------------------------------------------------------
|
||||||
|
// The bake operation is implemented in TD.Levels.Editor.LevelBakePipeline (in the Editor
|
||||||
/// <summary>
|
// assembly). It cannot be exposed as a method on this runtime class because the runtime
|
||||||
/// Runs the seven-phase bake algorithm and writes the result into <see cref="targetAsset"/>.
|
// assembly cannot reference types in the Editor assembly. The custom inspector's "Bake
|
||||||
/// </summary>
|
// LevelData" button invokes the pipeline directly.
|
||||||
/// <returns>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.</returns>
|
|
||||||
/// <remarks>
|
|
||||||
/// STUB — full implementation is the next session's work. Currently logs a not-implemented
|
|
||||||
/// message and returns false.
|
|
||||||
/// </remarks>
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Re-renders just the lobby thumbnail without doing a full bake. Useful when only visual
|
|
||||||
/// scene content (terrain, decorations) has changed.
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// STUB — full implementation is part of the bake script work. Currently logs a
|
|
||||||
/// not-implemented message.
|
|
||||||
/// </remarks>
|
|
||||||
public void RefreshThumbnail()
|
|
||||||
{
|
|
||||||
Debug.LogWarning("[LevelAuthoring] RefreshThumbnail is not yet implemented. " +
|
|
||||||
"Thumbnail rendering will be added with the bake script.");
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------
|
// -------------------------------------------------------------------
|
||||||
// Map-level gizmos: origin marker, map bounding rect, combined player zone outlines.
|
// 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);
|
Vector3 origin = new Vector3(0f, OriginMarkerY, 0f);
|
||||||
Gizmos.DrawSphere(origin, 0.1f);
|
Gizmos.DrawSphere(origin, 0.1f);
|
||||||
Gizmos.DrawLine(origin + new Vector3(-OriginMarkerSize, 0f, 0f),
|
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),
|
Gizmos.DrawLine(origin + new Vector3(0f, 0f, -OriginMarkerSize),
|
||||||
origin + new Vector3(0f, 0f, OriginMarkerSize));
|
origin + new Vector3(0f, 0f, OriginMarkerSize));
|
||||||
|
|
||||||
Gizmos.color = prev;
|
Gizmos.color = prev;
|
||||||
|
|
||||||
#if UNITY_EDITOR
|
#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
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -195,7 +168,21 @@ namespace TD.Levels
|
||||||
Gizmos.DrawLine(ne, nw);
|
Gizmos.DrawLine(ne, nw);
|
||||||
Gizmos.DrawLine(nw, sw);
|
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;
|
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()
|
private void DrawCombinedPlayerZoneOutlines()
|
||||||
|
|
|
||||||
|
|
@ -146,7 +146,7 @@ Transform:
|
||||||
m_GameObject: {fileID: 154690529}
|
m_GameObject: {fileID: 154690529}
|
||||||
serializedVersion: 2
|
serializedVersion: 2
|
||||||
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
|
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_LocalScale: {x: 1, y: 1, z: 1}
|
||||||
m_ConstrainProportionsScale: 0
|
m_ConstrainProportionsScale: 0
|
||||||
m_Children: []
|
m_Children: []
|
||||||
|
|
@ -185,8 +185,58 @@ BoxCollider:
|
||||||
m_ProvidesContacts: 0
|
m_ProvidesContacts: 0
|
||||||
m_Enabled: 1
|
m_Enabled: 1
|
||||||
serializedVersion: 3
|
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}
|
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
|
--- !u!1 &239104687
|
||||||
GameObject:
|
GameObject:
|
||||||
m_ObjectHideFlags: 0
|
m_ObjectHideFlags: 0
|
||||||
|
|
@ -286,7 +336,7 @@ Transform:
|
||||||
m_GameObject: {fileID: 304575571}
|
m_GameObject: {fileID: 304575571}
|
||||||
serializedVersion: 2
|
serializedVersion: 2
|
||||||
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
|
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_LocalScale: {x: 1, y: 1, z: 1}
|
||||||
m_ConstrainProportionsScale: 0
|
m_ConstrainProportionsScale: 0
|
||||||
m_Children: []
|
m_Children: []
|
||||||
|
|
@ -623,11 +673,11 @@ MonoBehaviour:
|
||||||
m_Name:
|
m_Name:
|
||||||
m_EditorClassIdentifier: Assembly-CSharp::TD.Levels.LevelAuthoring
|
m_EditorClassIdentifier: Assembly-CSharp::TD.Levels.LevelAuthoring
|
||||||
targetAsset: {fileID: 11400000, guid: 9cc56fbc3ae460a4b862f8510fdf5f09, type: 2}
|
targetAsset: {fileID: 11400000, guid: 9cc56fbc3ae460a4b862f8510fdf5f09, type: 2}
|
||||||
mapName:
|
mapName: test_map
|
||||||
playerCount: 1
|
playerCount: 2
|
||||||
expectedGoalCount: 1
|
expectedGoalCount: 1
|
||||||
mapDescription:
|
mapDescription: Test Map Description
|
||||||
author:
|
author: Matt
|
||||||
alwaysShowPlayerZones: 1
|
alwaysShowPlayerZones: 1
|
||||||
alwaysShowSpawners: 1
|
alwaysShowSpawners: 1
|
||||||
alwaysShowLeakExits: 1
|
alwaysShowLeakExits: 1
|
||||||
|
|
@ -641,7 +691,7 @@ Transform:
|
||||||
m_GameObject: {fileID: 441239879}
|
m_GameObject: {fileID: 441239879}
|
||||||
serializedVersion: 2
|
serializedVersion: 2
|
||||||
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
|
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_LocalScale: {x: 1, y: 1, z: 1}
|
||||||
m_ConstrainProportionsScale: 0
|
m_ConstrainProportionsScale: 0
|
||||||
m_Children:
|
m_Children:
|
||||||
|
|
@ -799,7 +849,7 @@ Transform:
|
||||||
m_GameObject: {fileID: 1078485323}
|
m_GameObject: {fileID: 1078485323}
|
||||||
serializedVersion: 2
|
serializedVersion: 2
|
||||||
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
|
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_LocalScale: {x: 1, y: 1, z: 1}
|
||||||
m_ConstrainProportionsScale: 0
|
m_ConstrainProportionsScale: 0
|
||||||
m_Children: []
|
m_Children: []
|
||||||
|
|
@ -869,7 +919,7 @@ Transform:
|
||||||
m_GameObject: {fileID: 1360337262}
|
m_GameObject: {fileID: 1360337262}
|
||||||
serializedVersion: 2
|
serializedVersion: 2
|
||||||
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
|
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_LocalScale: {x: 1, y: 1, z: 1}
|
||||||
m_ConstrainProportionsScale: 0
|
m_ConstrainProportionsScale: 0
|
||||||
m_Children: []
|
m_Children: []
|
||||||
|
|
@ -1016,7 +1066,7 @@ Transform:
|
||||||
m_GameObject: {fileID: 1464027360}
|
m_GameObject: {fileID: 1464027360}
|
||||||
serializedVersion: 2
|
serializedVersion: 2
|
||||||
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
|
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_LocalScale: {x: 7, y: 1, z: 3}
|
||||||
m_ConstrainProportionsScale: 0
|
m_ConstrainProportionsScale: 0
|
||||||
m_Children: []
|
m_Children: []
|
||||||
|
|
@ -1155,7 +1205,7 @@ Transform:
|
||||||
m_GameObject: {fileID: 1975687919}
|
m_GameObject: {fileID: 1975687919}
|
||||||
serializedVersion: 2
|
serializedVersion: 2
|
||||||
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
|
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_LocalScale: {x: 1, y: 1, z: 1}
|
||||||
m_ConstrainProportionsScale: 0
|
m_ConstrainProportionsScale: 0
|
||||||
m_Children: []
|
m_Children: []
|
||||||
|
|
@ -1207,3 +1257,4 @@ SceneRoots:
|
||||||
- {fileID: 239104690}
|
- {fileID: 239104690}
|
||||||
- {fileID: 441239881}
|
- {fileID: 441239881}
|
||||||
- {fileID: 1464027364}
|
- {fileID: 1464027364}
|
||||||
|
- {fileID: 167151709}
|
||||||
|
|
|
||||||
|
|
@ -12,20 +12,192 @@ MonoBehaviour:
|
||||||
m_Script: {fileID: 11500000, guid: 6d4e9c37b9205f3408a8225823f7a4da, type: 3}
|
m_Script: {fileID: 11500000, guid: 6d4e9c37b9205f3408a8225823f7a4da, type: 3}
|
||||||
m_Name: TestLevel
|
m_Name: TestLevel
|
||||||
m_EditorClassIdentifier: Assembly-CSharp::TD.Levels.LevelData
|
m_EditorClassIdentifier: Assembly-CSharp::TD.Levels.LevelData
|
||||||
MapName:
|
MapName: test_map
|
||||||
PlayerCount: 0
|
PlayerCount: 2
|
||||||
MapDescription:
|
MapDescription: Test Map Description
|
||||||
Author:
|
Author: Matt
|
||||||
MapThumbnail: {fileID: 0}
|
MapThumbnail: {fileID: 21300000, guid: d2e652d3e1c53454d80d3c1ec7888998, type: 3}
|
||||||
ScenePath:
|
ScenePath: Assets/_Project/Scenes/Levels/Main.unity
|
||||||
AuthoringHash:
|
AuthoringHash: 521a1ef38caafd70be6e364f81e999f5da6c425332fe32933766854b8cfad413
|
||||||
LastBakeTimestamp:
|
LastBakeTimestamp: 2026-04-30T19:05:42.7013062Z
|
||||||
LastBakeOutcome: 0
|
LastBakeOutcome: 1
|
||||||
LastBakeWarningCount: 0
|
LastBakeWarningCount: 1
|
||||||
GridOriginTile: {x: 0, y: 0}
|
GridOriginTile: {x: 0, y: 0}
|
||||||
GridSize: {x: 0, y: 0}
|
GridSize: {x: 68, y: 17}
|
||||||
PlacementGrid:
|
PlacementGrid: 02020202020202010101010101010101010101010101010101010101010101010101010202010101010101010101010101010101010101010101010101010101010202020202020202020201010101010101010101010101010101010101010101010101010101020201010101010101010101010101010101010101010101010101010101020202020202020202020101010101010101010101010101010101010101010101010101010102020101010101010101010101010101010101010101010101010101010102020202020202020202010101010101010101010101010101010101010101010101010101010202010101010101010101010101010101010101010101010101010101010202020202020202020201010101010101010101010101010101010101010101010101010101020201010101010101010101010101010101010101010101010101010101020202020202020202020101010101010101010101010101010101010101010101010101010102020101010101010101010101010101010101010101010101010101010102020202020202020202010101010101010101010101010101010101010101010101010101010202010101010101010101010101010101010101010101010101010101010202020000000000000000000000000000000000000000000000000000000000000000000000000002020202020202000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000202020202020200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020202020202020000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002020202020202000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000202020202020200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020202020202020000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002020202020202000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000202020202020200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020202020202020000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002020202020202000000000000000000000000000000000000000000000000
|
||||||
WalkabilityGrid:
|
WalkabilityGrid: 01010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010000000000000000000000000000000000000000000000000000000000000000000000000001010101010101000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000101010101010100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010101010101010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001010101010101000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000101010101010100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010101010101010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001010101010101000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000101010101010100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010101010101010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001010101010101000000000000000000000000000000000000000000000000
|
||||||
OwnerGrid:
|
OwnerGrid: 01010101010101010101010101010101010101010101010101010101010101010101010000020202020202020202020202020202020202020202020202020202020000000101010101010101010101010101010101010101010101010101010101010101010101000002020202020202020202020202020202020202020202020202020202000000010101010101010101010101010101010101010101010101010101010101010101010100000202020202020202020202020202020202020202020202020202020200000001010101010101010101010101010101010101010101010101010101010101010101010000020202020202020202020202020202020202020202020202020202020000000101010101010101010101010101010101010101010101010101010101010101010101000002020202020202020202020202020202020202020202020202020202000000010101010101010101010101010101010101010101010101010101010101010101010100000202020202020202020202020202020202020202020202020202020200000001010101010101010101010101010101010101010101010101010101010101010101010000020202020202020202020202020202020202020202020202020202020000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
PlayerZones: []
|
PlayerZones:
|
||||||
Goals: []
|
- 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}
|
||||||
|
|
|
||||||
3
Assets/_Project/Scenes/Levels/TestLevel_Thumbnail.png
Normal file
3
Assets/_Project/Scenes/Levels/TestLevel_Thumbnail.png
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:9b13dafb78f7bc013cba5dd80719fd2e86d11f2601ad7103adc85bfd7aed067d
|
||||||
|
size 5198
|
||||||
117
Assets/_Project/Scenes/Levels/TestLevel_Thumbnail.png.meta
Normal file
117
Assets/_Project/Scenes/Levels/TestLevel_Thumbnail.png.meta
Normal file
|
|
@ -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:
|
||||||
86
Assets/_Project/Scripts/Editor/Levels/BakeReport.cs
Normal file
86
Assets/_Project/Scripts/Editor/Levels/BakeReport.cs
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace TD.Levels.Editor
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Accumulates errors and warnings produced during a bake run. Phases append to the report
|
||||||
|
/// and can check <see cref="HasErrors"/> at phase boundaries to decide whether to abort.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// 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 <see cref="HasErrors"/> and decides whether to continue or abort.
|
||||||
|
/// </remarks>
|
||||||
|
public class BakeReport
|
||||||
|
{
|
||||||
|
private readonly List<string> _errors = new List<string>();
|
||||||
|
private readonly List<string> _warnings = new List<string>();
|
||||||
|
|
||||||
|
/// <summary>Errors collected so far. Hard-error phases abort the bake when this is non-empty.</summary>
|
||||||
|
public IReadOnlyList<string> Errors => _errors;
|
||||||
|
|
||||||
|
/// <summary>Warnings collected so far. Warnings never abort the bake.</summary>
|
||||||
|
public IReadOnlyList<string> Warnings => _warnings;
|
||||||
|
|
||||||
|
/// <summary>True if any hard errors have been recorded.</summary>
|
||||||
|
public bool HasErrors => _errors.Count > 0;
|
||||||
|
|
||||||
|
/// <summary>Total error count.</summary>
|
||||||
|
public int ErrorCount => _errors.Count;
|
||||||
|
|
||||||
|
/// <summary>Total warning count.</summary>
|
||||||
|
public int WarningCount => _warnings.Count;
|
||||||
|
|
||||||
|
/// <summary>Records a hard error. The check ID (e.g. "P2-1") is prefixed for traceability.</summary>
|
||||||
|
public void Error(string checkId, string message)
|
||||||
|
{
|
||||||
|
_errors.Add($"[{checkId}] {message}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Records a soft warning. The check ID (e.g. "P2-8") is prefixed for traceability.</summary>
|
||||||
|
public void Warning(string checkId, string message)
|
||||||
|
{
|
||||||
|
_warnings.Add($"[{checkId}] {message}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Records an error not associated with a numbered check (e.g. "phase 1 found null authoring").</summary>
|
||||||
|
public void Error(string message)
|
||||||
|
{
|
||||||
|
_errors.Add(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Records a warning not associated with a numbered check (e.g. "thumbnail render failed").</summary>
|
||||||
|
public void Warning(string message)
|
||||||
|
{
|
||||||
|
_warnings.Add(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Renders the full report as a human-readable string for console logging.</summary>
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
2
Assets/_Project/Scripts/Editor/Levels/BakeReport.cs.meta
Normal file
2
Assets/_Project/Scripts/Editor/Levels/BakeReport.cs.meta
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: c8feac7ae8882984ab8fc3bf96e73709
|
||||||
181
Assets/_Project/Scripts/Editor/Levels/LevelAuthoringEditor.cs
Normal file
181
Assets/_Project/Scripts/Editor/Levels/LevelAuthoringEditor.cs
Normal file
|
|
@ -0,0 +1,181 @@
|
||||||
|
// Assets/_Project/Scripts/Editor/Levels/LevelAuthoringEditor.cs
|
||||||
|
using UnityEditor;
|
||||||
|
using UnityEngine;
|
||||||
|
using TD.Core;
|
||||||
|
|
||||||
|
namespace TD.Levels.Editor
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Custom inspector for <see cref="LevelAuthoring"/>. 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)
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// 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.
|
||||||
|
/// </remarks>
|
||||||
|
[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) + "…";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 46bb419f816638646b8ca42c55f72e6e
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
namespace TD.Levels.Editor
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Editor-only transient state for the level bake workflow. Lives only in editor memory —
|
||||||
|
/// not serialized to any asset, not shipped in builds.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The bake's failure state is intentionally not persisted to <see cref="LevelData"/> —
|
||||||
|
/// 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.
|
||||||
|
/// </remarks>
|
||||||
|
public static class LevelAuthoringEditorState
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// True if the most recent bake attempt failed (hard error in any phase). Cleared on
|
||||||
|
/// successful bake or on Unity reload.
|
||||||
|
/// </summary>
|
||||||
|
public static bool HasUncommittedFailure;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: aeee2805cc297ca41bab91e40dcd7dee
|
||||||
|
|
@ -0,0 +1,195 @@
|
||||||
|
// Assets/_Project/Scripts/Editor/Levels/LevelAuthoringPlayModeHook.cs
|
||||||
|
using UnityEditor;
|
||||||
|
using UnityEngine;
|
||||||
|
using TD.Levels;
|
||||||
|
|
||||||
|
namespace TD.Levels.Editor
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
[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<LevelAuthoring>(
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Cancels the Play mode transition. Must be called during
|
||||||
|
/// ExitingEditMode for the cancel to take effect cleanly.
|
||||||
|
/// </summary>
|
||||||
|
private static void AbortPlay()
|
||||||
|
{
|
||||||
|
EditorApplication.isPlaying = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 65d1bbee09d1bd646a6bc07583836b6e
|
||||||
1300
Assets/_Project/Scripts/Editor/Levels/LevelBakePipeline.cs
Normal file
1300
Assets/_Project/Scripts/Editor/Levels/LevelBakePipeline.cs
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,2 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 5933991b7c238d048bb1c9cd2a23dbd9
|
||||||
214
Assets/_Project/Scripts/Editor/Levels/VolumeEditTool.cs
Normal file
214
Assets/_Project/Scripts/Editor/Levels/VolumeEditTool.cs
Normal file
|
|
@ -0,0 +1,214 @@
|
||||||
|
using UnityEditor;
|
||||||
|
using UnityEditor.EditorTools;
|
||||||
|
using UnityEditor.ShortcutManagement;
|
||||||
|
using UnityEngine;
|
||||||
|
using TD.Core;
|
||||||
|
|
||||||
|
namespace TD.Levels.Editor
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Custom scene-view tool for resizing <see cref="VolumeBase"/> 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.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// 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 <see cref="BoxCollider.size"/> and <see cref="BoxCollider.center"/> together;
|
||||||
|
/// the GameObject's Transform is left alone. This keeps the asymmetry contained to the
|
||||||
|
/// collider component, where it belongs.
|
||||||
|
/// </remarks>
|
||||||
|
[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<BoxCollider>();
|
||||||
|
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<BoxCollider>();
|
||||||
|
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<VolumeEditTool>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: f4527cb4870dae24280db768d6af69c8
|
||||||
454
Assets/_Project/Scripts/Gameplay/LevelLoader.cs
Normal file
454
Assets/_Project/Scripts/Gameplay/LevelLoader.cs
Normal file
|
|
@ -0,0 +1,454 @@
|
||||||
|
// Assets/_Project/Scripts/Gameplay/LevelLoader.cs
|
||||||
|
using UnityEngine;
|
||||||
|
using TD.Core;
|
||||||
|
using TD.Levels;
|
||||||
|
|
||||||
|
namespace TD.Gameplay
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Match-time loader for a baked <see cref="LevelData"/> 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.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Plain <c>MonoBehaviour</c>, not <c>NetworkBehaviour</c>. 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
|
||||||
|
/// <c>MatchState</c> NetworkBehaviour alongside lives, wave info, etc.
|
||||||
|
/// Today's goal is the minimum loader surface area needed for everything
|
||||||
|
/// that comes next.
|
||||||
|
///
|
||||||
|
/// Singleton access: <see cref="Instance"/> is set in <c>Awake</c> and
|
||||||
|
/// cleared in <c>OnDestroy</c>. Consumers null-check before use.
|
||||||
|
/// </remarks>
|
||||||
|
public class LevelLoader : MonoBehaviour
|
||||||
|
{
|
||||||
|
// ----- Singleton --------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The currently loaded LevelLoader. Null before any scene with a
|
||||||
|
/// LevelLoader has Awake'd, and during scene transitions. Always
|
||||||
|
/// null-check before use.
|
||||||
|
/// </summary>
|
||||||
|
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 ----------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>The loaded LevelData asset, or null if loading failed.</summary>
|
||||||
|
public LevelData LevelData => level;
|
||||||
|
|
||||||
|
/// <summary>True if the loader successfully initialized at match start.</summary>
|
||||||
|
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<BoxCollider>();
|
||||||
|
// 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.
|
||||||
|
|
||||||
|
/// <summary>True if <paramref name="tile"/> is within the loaded grid's bounds.</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True if <paramref name="tile"/> 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).
|
||||||
|
/// </summary>
|
||||||
|
public bool IsWalkable(Vector2Int tile)
|
||||||
|
{
|
||||||
|
if (!TryFlatIndex(tile, out int idx)) return false;
|
||||||
|
return runtimeWalkability[idx];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Placement state for <paramref name="tile"/>. Returns
|
||||||
|
/// <see cref="PlacementState.Outside"/> for out-of-bounds tiles.
|
||||||
|
/// Reflects the baked placement grid; this does NOT change at runtime.
|
||||||
|
/// </summary>
|
||||||
|
public PlacementState GetPlacement(Vector2Int tile)
|
||||||
|
{
|
||||||
|
if (!TryFlatIndex(tile, out int idx)) return PlacementState.Outside;
|
||||||
|
return level.PlacementGrid[idx];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Owning player for <paramref name="tile"/>. Returns
|
||||||
|
/// <see cref="PlayerSlot.None"/> for out-of-bounds tiles or tiles not
|
||||||
|
/// inside any player zone.
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
2
Assets/_Project/Scripts/Gameplay/LevelLoader.cs.meta
Normal file
2
Assets/_Project/Scripts/Gameplay/LevelLoader.cs.meta
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: a303b690faebb0e4e930d1714afa424e
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
%TAG !u! tag:unity3d.com,2011:
|
%TAG !u! tag:unity3d.com,2011:
|
||||||
--- !u!78 &1
|
--- !u!78 &1
|
||||||
TagManager:
|
TagManager:
|
||||||
serializedVersion: 2
|
serializedVersion: 3
|
||||||
tags: []
|
tags: []
|
||||||
layers:
|
layers:
|
||||||
- Default
|
- Default
|
||||||
|
|
@ -11,7 +11,7 @@ TagManager:
|
||||||
-
|
-
|
||||||
- Water
|
- Water
|
||||||
- UI
|
- UI
|
||||||
-
|
- BuildablePlane
|
||||||
-
|
-
|
||||||
-
|
-
|
||||||
-
|
-
|
||||||
|
|
@ -50,27 +50,4 @@ TagManager:
|
||||||
- Light Layer 5
|
- Light Layer 5
|
||||||
- Light Layer 6
|
- Light Layer 6
|
||||||
- Light Layer 7
|
- Light Layer 7
|
||||||
-
|
m_MigratedRenderPipelines: []
|
||||||
-
|
|
||||||
-
|
|
||||||
-
|
|
||||||
-
|
|
||||||
-
|
|
||||||
-
|
|
||||||
-
|
|
||||||
-
|
|
||||||
-
|
|
||||||
-
|
|
||||||
-
|
|
||||||
-
|
|
||||||
-
|
|
||||||
-
|
|
||||||
-
|
|
||||||
-
|
|
||||||
-
|
|
||||||
-
|
|
||||||
-
|
|
||||||
-
|
|
||||||
-
|
|
||||||
-
|
|
||||||
-
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue