Major changes to editor tools, and adding new layer for buildable towers

This commit is contained in:
Matt F 2026-05-01 10:50:03 -07:00
parent a4e28bc93f
commit b44eeaeeff
21 changed files with 2867 additions and 89 deletions

View 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);
}
}
}
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: a303b690faebb0e4e930d1714afa424e