UnityTowerDefense/Assets/_Project/Scripts/Gameplay/LevelLoader.cs

591 lines
No EOL
27 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Assets/_Project/Scripts/Gameplay/LevelLoader.cs
using System.Collections.Generic;
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.
//
// Consumers query through IsWalkable(Vector2Int) and mutate through
// SetWalkable(Vector2Int, bool), which hide the flat-array indexing.
//
// NOTE: This grid will move to MatchState when that NetworkBehaviour is
// implemented, so server-authoritative mutation is co-located with other
// match-wide state. For now it lives here because no consumer needed it
// to live elsewhere.
private bool[] runtimeWalkability;
// Per-tile tower occupancy. True when a tower's footprint covers this tile.
// Distinct from runtimeWalkability because:
// (a) spawner and leak-exit tiles are walkable but can never be occupied
// by a tower — tracking them separately avoids ambiguity.
// (b) the placement ghost needs to detect tile conflicts independently
// of walkability (a tile is walkable until the tower is placed,
// but the ghost should turn red as soon as a prior placement
// reserves it).
//
// Initialized to all-false on Awake. Mutated via SetOccupied; queried
// via IsOccupied. Will move to MatchState alongside runtimeWalkability.
private bool[] runtimeOccupied;
// 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();
InitializeRuntimeOccupied();
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 InitializeRuntimeOccupied()
{
// All tiles start unoccupied. bool[] default-initializes to false,
// so Array.Clear is redundant but makes intent explicit.
runtimeOccupied = new bool[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 edge-aligned: tile N occupies world [N, N+1].
//
// World extent on X:
// left = GridOriginTile.x * TILE_SIZE
// right = (GridOriginTile.x + GridSize.x) * TILE_SIZE
// width = GridSize.x * TILE_SIZE
// centerX = (left + right) / 2 = (GridOriginTile.x + GridSize.x / 2) * TILE_SIZE
//
// Same shape on Z (grid-y maps to world-z).
float worldCenterX =
(level.GridOriginTile.x + level.GridSize.x * 0.5f) * GridCoordinates.TILE_SIZE;
float worldCenterZ =
(level.GridOriginTile.y + level.GridSize.y * 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 part of the playable map area (inside any
/// MapAreaVolume at bake time). Returns false for out-of-bounds tiles and for in-bounds
/// "void" tiles outside the map area. This is the outermost gate — gameplay queries
/// (IsWalkable, GetPlacement, GetOwner) are only meaningful where IsInMap is true.
///
/// Use this for: builder movement clamp, camera pan clamp, minimap rendering bounds.
/// </summary>
public bool IsInMap(Vector2Int tile)
{
if (!TryFlatIndex(tile, out int idx)) return false;
// Defensive: existing maps that haven't been re-baked since MapAreaGrid was added
// will have a null array. Treat that as "not in map" so callers don't false-positive.
if (level.MapAreaGrid == null || level.MapAreaGrid.Length == 0) return false;
return level.MapAreaGrid[idx];
}
/// <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];
}
/// <summary>
/// True if <paramref name="tile"/> is currently occupied by a placed tower footprint.
/// Returns false for out-of-bounds tiles. Unlike <see cref="IsWalkable"/>, this grid
/// starts all-false and only becomes true when a tower is successfully placed.
/// </summary>
/// <remarks>
/// The placement ghost uses this (alongside <see cref="GetPlacement"/> and
/// <see cref="GetOwner"/>) to decide whether to render as valid (white) or invalid (red).
/// The server uses it as part of the tile-availability check before path validation.
/// </remarks>
public bool IsOccupied(Vector2Int tile)
{
if (!TryFlatIndex(tile, out int idx)) return false;
return runtimeOccupied[idx];
}
// ----- Runtime mutators -------------------------------------------
//
// Called by TowerPlacementManager (server-side) when a tower placement
// is accepted. Both mutators must be called for every tile in the tower's
// footprint so the two grids stay in sync.
//
// These are not RPCs — LevelLoader is a plain MonoBehaviour, not a
// NetworkBehaviour. The server calls these directly after authoritative
// validation; clients learn about the change when the TowerInstance
// NetworkObject spawns and its Start/OnNetworkSpawn stamps its own
// footprint locally.
/// <summary>
/// Fired on every peer whenever <see cref="SetWalkable"/> changes a tile's
/// walkability. <see cref="TD.Gameplay.PathfindingService"/> subscribes to
/// invalidate cached paths so in-flight enemies reroute after a tower is
/// placed or removed.
/// </summary>
public event System.Action OnWalkabilityChanged;
/// <summary>
/// Sets the runtime walkability of <paramref name="tile"/>. Called by
/// <c>TowerPlacementManager</c> on the server when a tower is accepted (pass
/// <c>false</c>) and when a tower is sold/destroyed (pass <c>true</c>).
/// No-ops silently for out-of-bounds tiles.
/// </summary>
/// <remarks>
/// Fires <see cref="OnWalkabilityChanged"/> once if the tile actually changed.
/// For multi-tile stamps (a tower footprint), prefer
/// <see cref="SetWalkableBatch"/> to fire the event ONCE for the whole batch
/// instead of per-tile — every event triggers all enemy re-paths.
/// </remarks>
public void SetWalkable(Vector2Int tile, bool walkable)
{
if (!TryFlatIndex(tile, out int idx)) return;
if (runtimeWalkability[idx] == walkable) return; // no change — don't fire event
runtimeWalkability[idx] = walkable;
OnWalkabilityChanged?.Invoke();
}
/// <summary>
/// Batched variant of <see cref="SetWalkable"/>: updates every tile in
/// <paramref name="tiles"/> to <paramref name="walkable"/> and fires
/// <see cref="OnWalkabilityChanged"/> AT MOST ONCE for the entire batch
/// (only if at least one tile actually changed). Use this for tower footprint
/// stamps so a 2×2 placement triggers a single enemy re-path instead of four.
/// Out-of-bounds tiles in the list are skipped silently.
/// </summary>
public void SetWalkableBatch(IList<Vector2Int> tiles, bool walkable)
{
if (tiles == null) return;
bool anyChanged = false;
for (int i = 0; i < tiles.Count; i++)
{
if (!TryFlatIndex(tiles[i], out int idx)) continue;
if (runtimeWalkability[idx] == walkable) continue;
runtimeWalkability[idx] = walkable;
anyChanged = true;
}
if (anyChanged) OnWalkabilityChanged?.Invoke();
}
/// <summary>
/// Sets the runtime occupancy of <paramref name="tile"/>. Called alongside
/// <see cref="SetWalkable"/> — always update both grids together so they
/// stay in sync. Pass <c>true</c> when a tower is placed, <c>false</c> when
/// it is sold or destroyed.
/// No-ops silently for out-of-bounds tiles.
/// </summary>
public void SetOccupied(Vector2Int tile, bool occupied)
{
if (!TryFlatIndex(tile, out int idx)) return;
runtimeOccupied[idx] = occupied;
}
// 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 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. Tile N spans world
// [N, N+1], so the grid's SW corner is at GridOriginTile and its NE corner
// is at GridOriginTile + GridSize.
Vector3 sw = new Vector3(
level.GridOriginTile.x * GridCoordinates.TILE_SIZE,
GridCoordinates.BUILDABLE_PLANE_Y,
level.GridOriginTile.y * GridCoordinates.TILE_SIZE);
Vector3 ne = new Vector3(
(level.GridOriginTile.x + level.GridSize.x) * GridCoordinates.TILE_SIZE,
GridCoordinates.BUILDABLE_PLANE_Y,
(level.GridOriginTile.y + level.GridSize.y) * GridCoordinates.TILE_SIZE);
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. Uses
// the same formula as SpawnBuildablePlane (tiles are edge-aligned).
float worldCenterX =
(level.GridOriginTile.x + level.GridSize.x * 0.5f) * GridCoordinates.TILE_SIZE;
float worldCenterZ =
(level.GridOriginTile.y + level.GridSize.y * 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;
// Use GridToWorld so tile centers stay consistent with the convention
// (tile (N, N) center at world (N+0.5, N+0.5)).
Vector3 c = GridCoordinates.GridToWorld(
new Vector2Int(level.GridOriginTile.x + x, level.GridOriginTile.y + y));
c.y = drawY;
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 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;
// Tile (gx, gy) spans world XZ from (gx, gy) to (gx+1, gy+1) (edge-aligned).
int gx = level.GridOriginTile.x + x;
int gy = level.GridOriginTile.y + y;
float wMinX = gx * tile;
float wMaxX = (gx + 1) * tile;
float wMinZ = gy * tile;
float wMaxZ = (gy + 1) * 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(wMinX, drawY, wMinZ);
Vector3 nw = new Vector3(wMinX, drawY, wMaxZ);
Vector3 ne = new Vector3(wMaxX, drawY, wMaxZ);
Vector3 se = new Vector3(wMaxX, drawY, wMinZ);
Gizmos.DrawLine(sw, nw);
Gizmos.DrawLine(nw, ne);
Gizmos.DrawLine(ne, se);
Gizmos.DrawLine(se, sw);
}
}
}
}
}