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
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue