UnityTowerDefense/Assets/_Project/Scripts/Gameplay/TowerPlacementManager.cs
Matt F a63cce53e2 Adding tons of new functionality
Decals, ghost textures, placement functionality, builder stub ins, a new camera system,  and more.
2026-05-04 00:01:30 -07:00

547 lines
No EOL
24 KiB
C#
Raw 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/TowerPlacementManager.cs
using System.Collections.Generic;
using Unity.Netcode;
using UnityEngine;
using TD.Core;
using TD.Levels;
using TD.Towers;
namespace TD.Gameplay
{
/// <summary>
/// Server-authoritative manager for tower placement requests. Receives placement
/// requests from clients via RPC, validates them in order, and either spawns the
/// tower or rejects the request with a reason code.
/// </summary>
/// <remarks>
/// <para><b>Queue-based processing.</b> Incoming RPCs enqueue a
/// <see cref="PlacementRequest"/> rather than validating inline. Each server
/// Update drains up to <see cref="RequestsPerFrame"/> requests. At 60 fps this
/// gives ~180 validations/second, comfortably above the worst-case 90/second
/// (9 players × 10 placements/second). Queuing keeps the server frame budget
/// predictable regardless of burst traffic.</para>
///
/// <para><b>Server-only logic.</b> All validation and mutation runs on the server.
/// Clients learn about accepted placements when the <see cref="TowerInstance"/>
/// NetworkObject spawns (NGO replicates it automatically). Clients learn about
/// rejections via <see cref="PlacementRejectedRpc"/>.</para>
///
/// <para><b>Validation order:</b>
/// <list type="number">
/// <item>Ownership — every footprint tile must be owned by the requesting player.</item>
/// <item>Placement state — every footprint tile must be <c>Buildable</c> and unoccupied.</item>
/// <item>Gold — the placing player must have enough gold.</item>
/// <item>Path — a BFS confirms every spawner in the placing player's zone still reaches
/// an exit after the footprint is stamped as non-walkable.</item>
/// </list></para>
///
/// <para><b>Path-check BFS.</b> The server temporarily stamps the footprint,
/// runs BFS per spawner, then un-stamps if the check fails. This is O(tiles in zone)
/// per spawner per request — acceptable for low-frequency gameplay actions and the
/// queue-rate-limited processing model.</para>
///
/// <para><b>Builder range check.</b> Deliberately omitted in Path B. The builder
/// system does not exist yet. When Path D is implemented, add a range check between
/// steps 2 and 3 above, gated on the requesting player's Builder position.</para>
/// </remarks>
public class TowerPlacementManager : NetworkBehaviour
{
// ----- Singleton --------------------------------------------------
/// <summary>
/// The active TowerPlacementManager. Null before the scene loads or after it unloads.
/// Always null-check before use. Only meaningful on the server; clients route all
/// placement through RPC.
/// </summary>
public static TowerPlacementManager Instance { get; private set; }
// ----- Inspector --------------------------------------------------
[Tooltip("Maximum number of placement requests processed per server Update tick. " +
"At 60 fps, 3 requests/frame = 180 validations/second, well above the " +
"worst-case 90/second (9 players × 10 placements/second).")]
[SerializeField] private int requestsPerFrame = 3;
[Tooltip("Tower definitions available in this match, indexed by TowerTypeId. " +
"Populate this with every TowerDefinition asset the current race roster " +
"contains. Index 0 is reserved; valid IDs start at 1. " +
"(Temporary: will be driven by RaceDefinition once Path E is complete.)")]
[SerializeField] private TowerDefinition[] towerDefinitions = new TowerDefinition[0];
// ----- Internal request queue -------------------------------------
private struct PlacementRequest
{
public ulong SenderClientId;
public Vector2Int Anchor; // SW corner of the footprint, in world-tile coords.
public int TowerTypeId; // Index into towerDefinitions[].
}
private readonly Queue<PlacementRequest> pendingRequests = new Queue<PlacementRequest>();
// Reusable scratch collections for BFS — allocated once and cleared per
// check to avoid per-frame GC pressure. Only used on the server.
private readonly Queue<Vector2Int> bfsQueue = new Queue<Vector2Int>();
private readonly HashSet<Vector2Int> bfsVisited = new HashSet<Vector2Int>();
// ----- Lifecycle --------------------------------------------------
public override void OnNetworkSpawn()
{
if (IsServer)
{
if (Instance != null && Instance != this)
{
Debug.LogError("[TowerPlacementManager] Multiple instances detected. " +
"Only one TowerPlacementManager should exist per scene.");
}
Instance = this;
Debug.Log("[TowerPlacementManager] Server ready.");
}
}
public override void OnNetworkDespawn()
{
if (Instance == this) Instance = null;
}
// ----- Server Update — queue drain --------------------------------
private void Update()
{
if (!IsServer) return;
int processed = 0;
while (pendingRequests.Count > 0 && processed < requestsPerFrame)
{
ProcessRequest(pendingRequests.Dequeue());
processed++;
}
}
// ----- RPC: client → server (placement request) -------------------
/// <summary>
/// Client entry point. Call this on the local client to request placing a tower.
/// The server will validate and either spawn the tower (visible to all clients)
/// or call back with <see cref="PlacementRejectedRpc"/>.
/// </summary>
/// <param name="anchorX">X component of the footprint anchor tile (world-tile coords).</param>
/// <param name="anchorY">Y component of the footprint anchor tile (world-tile coords).</param>
/// <param name="towerTypeId">Index into the server's towerDefinitions array.</param>
[Rpc(SendTo.Server)]
public void RequestPlaceTowerRpc(int anchorX, int anchorY, int towerTypeId,
RpcParams rpcParams = default)
{
pendingRequests.Enqueue(new PlacementRequest
{
SenderClientId = rpcParams.Receive.SenderClientId,
Anchor = new Vector2Int(anchorX, anchorY),
TowerTypeId = towerTypeId,
});
}
// ----- RPC: server → client (rejection notification) --------------
/// <summary>
/// Sent by the server to the placing client when a placement request is rejected.
/// The client uses the reason to display a feedback message to the player.
/// </summary>
[Rpc(SendTo.SpecifiedInParams)]
private void PlacementRejectedRpc(PlacementRejectionReason reason,
RpcParams rpcParams = default)
{
// This executes on the target client.
Debug.Log($"[TowerPlacementManager] Placement rejected: {reason}");
// TowerPlacementController listens for this via the static event below.
OnPlacementRejected?.Invoke(reason);
}
/// <summary>
/// Fired on the local client when the server rejects this client's placement request.
/// <see cref="TowerPlacementController"/> subscribes to this to display rejection
/// feedback messages.
/// </summary>
public static event System.Action<PlacementRejectionReason> OnPlacementRejected;
// ----- Core validation and placement logic ------------------------
private void ProcessRequest(PlacementRequest req)
{
// Resolve the TowerDefinition first — needed by every subsequent check.
if (!TryGetDefinition(req.TowerTypeId, out TowerDefinition def))
{
Reject(req, PlacementRejectionReason.InvalidTowerType);
return;
}
var loader = LevelLoader.Instance;
if (loader == null || !loader.IsLoaded)
{
Reject(req, PlacementRejectionReason.ServerError);
return;
}
// Collect the footprint tiles once; used by all subsequent checks.
var footprint = new List<Vector2Int>(def.FootprintSize.x * def.FootprintSize.y);
foreach (var tile in GridCoordinates.GetFootprintTiles(req.Anchor, def.FootprintSize))
footprint.Add(tile);
// ------------------------------------------------------------------
// Check 1: Ownership
// Every footprint tile must be owned by the placing player's zone.
// ------------------------------------------------------------------
PlayerSlot placingSlot = ClientIdToPlayerSlot(req.SenderClientId);
if (placingSlot == PlayerSlot.None)
{
Reject(req, PlacementRejectionReason.ServerError);
return;
}
foreach (var tile in footprint)
{
if (loader.GetOwner(tile) != placingSlot)
{
Reject(req, PlacementRejectionReason.WrongOwner);
return;
}
}
// ------------------------------------------------------------------
// Check 2: Placement state + occupancy
// Every footprint tile must be Buildable and not already occupied.
// ------------------------------------------------------------------
foreach (var tile in footprint)
{
if (loader.GetPlacement(tile) != PlacementState.Buildable)
{
Reject(req, PlacementRejectionReason.TileNotBuildable);
return;
}
if (loader.IsOccupied(tile))
{
Reject(req, PlacementRejectionReason.TileOccupied);
return;
}
}
// ------------------------------------------------------------------
// Check 3: Build range
// Tower must be within the placing player's builder's build range.
// Cheap to check; runs before gold and path so we don't burn cycles
// on out-of-range placements.
// ------------------------------------------------------------------
var builder = Builder.GetForClient(req.SenderClientId);
if (builder == null)
{
// No builder spawned for this client — server-side error, not a player-facing one.
Reject(req, PlacementRejectionReason.ServerError);
return;
}
if (!builder.IsTileWithinBuildRange(req.Anchor, def.FootprintSize))
{
Reject(req, PlacementRejectionReason.OutOfRange);
return;
}
// ------------------------------------------------------------------
// Check 4: Gold
// Validate before the path check — cheaper, and no point running BFS
// if the player can't afford the tower.
// ------------------------------------------------------------------
var goldManager = PlayerGoldManager.GetForClient(req.SenderClientId);
if (goldManager == null || goldManager.CurrentGold < def.GoldCost)
{
Reject(req, PlacementRejectionReason.InsufficientGold);
return;
}
// ------------------------------------------------------------------
// Check 5: Path validity
// Temporarily stamp the footprint, run BFS per spawner in the placing
// player's zone, then un-stamp if any spawner loses its exit route.
// ------------------------------------------------------------------
StampFootprint(loader, footprint, walkable: false, occupied: true);
bool pathValid = CheckPathValidity(loader, placingSlot);
if (!pathValid)
{
// Un-stamp — the placement is rejected, grid stays as it was.
StampFootprint(loader, footprint, walkable: true, occupied: false);
Reject(req, PlacementRejectionReason.BlocksPath);
return;
}
// ------------------------------------------------------------------
// All checks passed — commit the placement.
// The footprint stamp is already applied (walkable=false, occupied=true).
// Deduct gold and spawn the tower NetworkObject.
// ------------------------------------------------------------------
goldManager.DeductGold(def.GoldCost);
SpawnTower(def, req.Anchor, placingSlot);
Debug.Log($"[TowerPlacementManager] Placed '{def.DisplayName}' for " +
$"client {req.SenderClientId} ({placingSlot}) at anchor {req.Anchor}.");
}
// ----- Path-validity BFS ------------------------------------------
/// <summary>
/// Returns true if every spawner in <paramref name="placingSlot"/>'s zone can still
/// reach an exit tile (any leak exit tile OR any goal tile) via walkable tiles,
/// given the current state of <c>LevelLoader</c>'s runtime walkability grid.
/// </summary>
/// <remarks>
/// Mirrors bake-time P5-4. Runs a BFS per spawner against the runtime walkability
/// grid. Reuses <see cref="bfsQueue"/> and <see cref="bfsVisited"/> scratch
/// collections (cleared between BFS runs) to avoid GC allocation per call.
/// </remarks>
private bool CheckPathValidity(LevelLoader loader, PlayerSlot slot)
{
var levelData = loader.LevelData;
// Find the PlayerZoneData for this slot.
PlayerZoneData zoneData = null;
foreach (var zone in levelData.PlayerZones)
{
if (zone.Owner == slot) { zoneData = zone; break; }
}
if (zoneData == null || zoneData.Spawners == null || zoneData.Spawners.Length == 0)
{
// No spawners means nothing to block — treat as valid.
return true;
}
// Build the exit tile set: union of all leak exit tiles and all goal tiles.
// This is built fresh per call because it doesn't change within a match
// (tiles never move), but the allocation cost is small and correctness
// is more important than micro-optimization here.
var exitTiles = BuildExitTileSet(levelData, slot);
if (exitTiles.Count == 0)
{
// Zone has no exits at all — this would have been caught at bake time (P5-8).
// Treat as valid so a bake-side error doesn't cause all placements to fail.
Debug.LogWarning($"[TowerPlacementManager] Zone {slot} has no exit tiles. " +
$"This should have been caught at bake time (P5-8).");
return true;
}
// BFS per spawner: each spawner's tile area is the BFS seed set.
foreach (var spawner in zoneData.Spawners)
{
if (!SpawnerCanReachExit(loader, spawner, exitTiles))
return false;
}
return true;
}
/// <summary>
/// Builds the set of tiles that count as "exits" for the given player zone:
/// all tiles of every LeakExit FROM this zone, plus all goal tiles.
/// </summary>
private HashSet<Vector2Int> BuildExitTileSet(LevelData levelData, PlayerSlot slot)
{
var exits = new HashSet<Vector2Int>();
// Leak exit tiles for this zone.
foreach (var zone in levelData.PlayerZones)
{
if (zone.Owner != slot) continue;
if (zone.LeakExits == null) continue;
foreach (var leak in zone.LeakExits)
foreach (var tile in leak.TileArea)
exits.Add(tile);
}
// Goal tiles (all goals count as exits for the final defender zone).
if (levelData.Goals != null)
foreach (var goal in levelData.Goals)
foreach (var tile in goal.TileArea)
exits.Add(tile);
return exits;
}
/// <summary>
/// BFS from <paramref name="spawner"/>'s tile area. Returns true if any exit tile
/// is reachable via walkable tiles. Uses the shared scratch queue and visited set.
/// </summary>
private bool SpawnerCanReachExit(LevelLoader loader, SpawnerData spawner,
HashSet<Vector2Int> exitTiles)
{
bfsQueue.Clear();
bfsVisited.Clear();
// Seed the BFS with the spawner's full tile area (not just its center tile),
// matching bake-time P5-4 exactly.
foreach (var tile in spawner.TileArea)
{
if (bfsVisited.Add(tile))
bfsQueue.Enqueue(tile);
}
while (bfsQueue.Count > 0)
{
var current = bfsQueue.Dequeue();
if (exitTiles.Contains(current))
return true;
foreach (var neighbor in GridCoordinates.GetNeighbors(current))
{
if (bfsVisited.Contains(neighbor)) continue;
if (!loader.IsWalkable(neighbor)) continue;
bfsVisited.Add(neighbor);
bfsQueue.Enqueue(neighbor);
}
}
return false;
}
// ----- Helpers ----------------------------------------------------
/// <summary>
/// Stamps or un-stamps all tiles in <paramref name="footprint"/> on both the
/// walkability and occupancy grids simultaneously. Always update both together.
/// </summary>
private static void StampFootprint(LevelLoader loader, List<Vector2Int> footprint,
bool walkable, bool occupied)
{
foreach (var tile in footprint)
{
loader.SetWalkable(tile, walkable);
loader.SetOccupied(tile, occupied);
}
}
/// <summary>
/// Spawns the tower NetworkObject at the footprint center and records the
/// placing player's slot on the <see cref="TowerInstance"/> component.
/// </summary>
private void SpawnTower(TowerDefinition def, Vector2Int anchor, PlayerSlot owner)
{
if (def.TowerPrefab == null)
{
Debug.LogError($"[TowerPlacementManager] TowerDefinition '{def.DisplayName}' " +
$"has no TowerPrefab assigned. Cannot spawn.");
return;
}
Vector3 spawnPos = GridCoordinates.GetFootprintCenterWorld(anchor, def.FootprintSize);
// Towers sit on the buildable plane (Y=0); raise slightly so the mesh base
// sits flush rather than half-clipped. A cube of scale 1 needs +0.5 on Y.
spawnPos.y = 0.5f;
var go = Instantiate(def.TowerPrefab, spawnPos, Quaternion.identity);
var instance = go.GetComponent<TowerInstance>();
if (instance == null)
{
Debug.LogError($"[TowerPlacementManager] TowerPrefab '{def.TowerPrefab.name}' " +
$"is missing a TowerInstance component.");
Destroy(go);
return;
}
// Set data before network spawn so TowerInstance.OnNetworkSpawn sees it.
instance.InitializeServer(def, anchor, owner);
var netObj = go.GetComponent<NetworkObject>();
if (netObj == null)
{
Debug.LogError($"[TowerPlacementManager] TowerPrefab '{def.TowerPrefab.name}' " +
$"is missing a NetworkObject component.");
Destroy(go);
return;
}
netObj.Spawn(destroyWithScene: true);
}
// ----- Utility ----------------------------------------------------
private static bool TryGetDefinition(int typeId, out TowerDefinition def)
{
def = null;
// typeId 0 is reserved; valid IDs start at 1.
if (typeId <= 0) return false;
// Instance check for the static helper path — callers that have a
// direct reference use the instance array directly.
if (Instance == null) return false;
if (typeId >= Instance.towerDefinitions.Length) return false;
def = Instance.towerDefinitions[typeId];
return def != null;
}
/// <summary>
/// Maps a client ID to the PlayerSlot assigned to that client.
/// </summary>
/// <remarks>
/// STUB: Currently uses a trivial mapping where client 0 = Player1, client 1 = Player2,
/// etc. This will be replaced when MatchState / PlayerMatchState is implemented and
/// carries the authoritative client-to-slot assignment.
/// </remarks>
private static PlayerSlot ClientIdToPlayerSlot(ulong clientId)
{
// NGO client IDs start at 0 (host). PlayerSlot values start at 1.
// Cast is safe for up to 9 players; beyond that returns None.
byte slotByte = (byte)(clientId + 1);
if (slotByte < 1 || slotByte > 9) return PlayerSlot.None;
return (PlayerSlot)slotByte;
}
private void Reject(PlacementRequest req, PlacementRejectionReason reason)
{
Debug.Log($"[TowerPlacementManager] Rejected request from client " +
$"{req.SenderClientId} at anchor {req.Anchor}: {reason}");
// Send the rejection RPC back to only the requesting client.
PlacementRejectedRpc(reason,
new RpcParams
{
Send = new RpcSendParams
{
Target = RpcTarget.Single(req.SenderClientId, RpcTargetUse.Temp)
}
});
}
}
/// <summary>
/// Reason codes sent to the client when the server rejects a placement request.
/// Used by <see cref="TowerPlacementController"/> to display the appropriate
/// feedback message to the player.
/// </summary>
public enum PlacementRejectionReason
{
/// <summary>One or more footprint tiles belong to a different player's zone.</summary>
WrongOwner,
/// <summary>One or more footprint tiles are not in a Buildable state
/// (they are Restricted or Outside the map).</summary>
TileNotBuildable,
/// <summary>One or more footprint tiles are already occupied by an existing tower.</summary>
TileOccupied,
/// <summary>The placing player does not have enough gold.</summary>
InsufficientGold,
/// <summary>The placing player's builder is too far from the requested location.</summary>
OutOfRange,
/// <summary>Placing this tower would block all valid paths from at least one
/// spawner to its exit. The maze must remain passable.</summary>
BlocksPath,
/// <summary>The requested tower type ID is not in the server's definition list.</summary>
InvalidTowerType,
/// <summary>An unexpected server-side error occurred (e.g., LevelLoader not loaded,
/// client not mapped to a PlayerSlot). Check server logs.</summary>
ServerError,
}
}