// Assets/_Project/Scripts/Gameplay/LevelLoader.cs using UnityEngine; using TD.Core; using TD.Levels; namespace TD.Gameplay { /// /// Match-time loader for a baked asset. Owns the /// runtime representation of the level: the buildable-plane physics /// collider that click-to-tile raycasts target, and tile-query accessors /// for the rest of the gameplay code. /// /// /// Plain MonoBehaviour, not NetworkBehaviour. The baked /// LevelData is identical on every peer (same asset, same content), so /// there's no value in syncing it through Netcode. Each peer loads /// locally on scene-load. /// /// Mutable runtime state (walkability changes as towers are placed) lives /// here for now. When server-authoritative tower placement is implemented, /// the mutable walkability grid will move to a dedicated /// MatchState NetworkBehaviour alongside lives, wave info, etc. /// Today's goal is the minimum loader surface area needed for everything /// that comes next. /// /// Singleton access: is set in Awake and /// cleared in OnDestroy. Consumers null-check before use. /// public class LevelLoader : MonoBehaviour { // ----- Singleton -------------------------------------------------- /// /// The currently loaded LevelLoader. Null before any scene with a /// LevelLoader has Awake'd, and during scene transitions. Always /// null-check before use. /// public static LevelLoader Instance { get; private set; } // ----- Inspector fields ------------------------------------------- [Header("Level")] [Tooltip("Baked LevelData asset to load. Required.")] [SerializeField] private LevelData level; [Tooltip("Name of the physics layer used by the buildable-plane collider. " + "Click-to-tile raycasts should target this layer. Layer must exist " + "in Project Settings → Tags and Layers.")] [SerializeField] private string buildablePlaneLayerName = "BuildablePlane"; [Header("Debug Gizmos")] [Tooltip("Draw the grid's bounding rectangle (always visible).")] [SerializeField] private bool drawGridBounds = true; [Tooltip("Draw a translucent overlay showing walkable vs. non-walkable tiles.")] [SerializeField] private bool drawWalkability = true; [Tooltip("Draw the buildable-plane collider's footprint.")] [SerializeField] private bool drawBuildablePlane = true; [Tooltip("Draw owner-grid tile borders. Only visible when LevelLoader is selected.")] [SerializeField] private bool drawOwnerBorders = true; // ----- Runtime state ---------------------------------------------- /// The loaded LevelData asset, or null if loading failed. public LevelData LevelData => level; /// True if the loader successfully initialized at match start. public bool IsLoaded { get; private set; } // The mutable walkability grid. Initialized from LevelData.WalkabilityGrid // and mutated by tower placement at runtime. Stays in lockstep with the // baked grid until towers are placed. // // 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 centered on its integer coords. // // World extent on X: // left = (GridOriginTile.x - 0.5) * TILE_SIZE // right = (GridOriginTile.x + GridSize.x - 0.5) * TILE_SIZE // width = GridSize.x * TILE_SIZE // centerX = (left + right) / 2 = (GridOriginTile.x + (GridSize.x - 1) / 2) * TILE_SIZE // // Same shape on Z (grid-y maps to world-z). float worldCenterX = (level.GridOriginTile.x + (level.GridSize.x - 1) * 0.5f) * GridCoordinates.TILE_SIZE; float worldCenterZ = (level.GridOriginTile.y + (level.GridSize.y - 1) * 0.5f) * GridCoordinates.TILE_SIZE; float worldSizeX = level.GridSize.x * GridCoordinates.TILE_SIZE; float worldSizeZ = level.GridSize.y * GridCoordinates.TILE_SIZE; buildablePlaneGO = new GameObject("__BuildablePlane"); buildablePlaneGO.transform.SetParent(transform, worldPositionStays: false); buildablePlaneGO.transform.localPosition = Vector3.zero; buildablePlaneGO.transform.localRotation = Quaternion.identity; buildablePlaneGO.transform.localScale = Vector3.one; buildablePlaneGO.layer = LayerMask.NameToLayer(buildablePlaneLayerName); buildablePlaneCollider = buildablePlaneGO.AddComponent(); // Position the collider center in WORLD space coords by setting // BoxCollider.center after the GameObject is at origin. Since the // GO transform is identity at origin, local == world. buildablePlaneCollider.center = new Vector3( worldCenterX, GridCoordinates.BUILDABLE_PLANE_Y, worldCenterZ); // Y size: a thin sliver. We give it a non-zero height so raycasts // from above and below both register, and so floating-point // imprecision in raycast origin doesn't make hits fail at the // exact Y=0 plane. 0.1 world units is plenty. buildablePlaneCollider.size = new Vector3(worldSizeX, 0.1f, worldSizeZ); // Default isTrigger=false; raycasts hit it as a solid collider. // If we ever want it to ignore physics simulation while still // being raycastable, isTrigger=true also works for Raycast (with // QueryTriggerInteraction.Collide). } private void LogLoadSummary() { Vector3 c = buildablePlaneCollider.center; Vector3 s = buildablePlaneCollider.size; Debug.Log( $"[LevelLoader] Loaded '{level.MapName}' " + $"(playerCount={level.PlayerCount}, " + $"grid {level.GridSize.x}×{level.GridSize.y} from origin {level.GridOriginTile}). " + $"Buildable plane at world ({c.x:F2}, {c.y:F2}, {c.z:F2}) " + $"size ({s.x:F2}, {s.y:F2}, {s.z:F2}) on layer '{buildablePlaneLayerName}'."); } // ----- Public tile queries ---------------------------------------- // // All queries take WORLD-TILE coordinates (the same coordinates // GridCoordinates.WorldToGrid returns). Internally we translate by // GridOriginTile to index into the flat arrays. Consumers should // never see grid-array indices. /// True if is within the loaded grid's bounds. public bool InBounds(Vector2Int tile) { if (!IsLoaded) return false; int x = tile.x - level.GridOriginTile.x; int y = tile.y - level.GridOriginTile.y; return x >= 0 && x < level.GridSize.x && y >= 0 && y < level.GridSize.y; } /// /// True if is 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. /// 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]; } /// /// True if is currently walkable. Returns /// false for out-of-bounds tiles. Reflects the runtime walkability /// grid (which will mutate as towers are placed in future work). /// public bool IsWalkable(Vector2Int tile) { if (!TryFlatIndex(tile, out int idx)) return false; return runtimeWalkability[idx]; } /// /// Placement state for . Returns /// for out-of-bounds tiles. /// Reflects the baked placement grid; this does NOT change at runtime. /// public PlacementState GetPlacement(Vector2Int tile) { if (!TryFlatIndex(tile, out int idx)) return PlacementState.Outside; return level.PlacementGrid[idx]; } /// /// Owning player for . Returns /// for out-of-bounds tiles or tiles not /// inside any player zone. /// public PlayerSlot GetOwner(Vector2Int tile) { if (!TryFlatIndex(tile, out int idx)) return PlayerSlot.None; return level.OwnerGrid[idx]; } /// /// True if is currently occupied by a placed tower footprint. /// Returns false for out-of-bounds tiles. Unlike , this grid /// starts all-false and only becomes true when a tower is successfully placed. /// /// /// The placement ghost uses this (alongside and /// ) 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. /// 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. /// /// Sets the runtime walkability of . Called by /// TowerPlacementManager on the server when a tower is accepted (pass /// false) and when a tower is sold/destroyed (pass true). /// No-ops silently for out-of-bounds tiles. /// public void SetWalkable(Vector2Int tile, bool walkable) { if (!TryFlatIndex(tile, out int idx)) return; runtimeWalkability[idx] = walkable; } /// /// Sets the runtime occupancy of . Called alongside /// — always update both grids together so they /// stay in sync. Pass true when a tower is placed, false when /// it is sold or destroyed. /// No-ops silently for out-of-bounds tiles. /// 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. 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); } } } } }