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

687 lines
No EOL
31 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 enqueues
/// the placement on the player's <see cref="Builder"/> or rejects the request
/// with a reason code.
/// </summary>
/// <remarks>
/// <para><b>Queue-based RPC 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 build-site visual NetworkObject
/// spawns (queued ghost) and later when the <see cref="TowerInstance"/> spawns at
/// construction-complete. Clients learn about rejections via
/// <see cref="PlacementRejectedRpc"/>.</para>
///
/// <para><b>Validation order:</b>
/// <list type="number">
/// <item>Tower type — must resolve to a valid TowerDefinition.</item>
/// <item>Ownership — every footprint tile must be owned by the requesting player.</item>
/// <item>Placement state + occupancy — every tile must be Buildable and not already occupied.
/// Occupancy includes both completed towers AND queued/constructing jobs in any
/// builder's queue.</item>
/// <item>Builder existence — the placing player must have a spawned builder. Build range
/// is NOT checked at queue-time — the queue is precisely the mechanism for deferring
/// "go there and build it." Range is enforced at construction-start in Builder.</item>
/// <item>Gold — the placing player must have enough gold.</item>
/// <item>Queue capacity — the placing player's builder queue must have room.</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. Note that QUEUED towers
/// do not participate in this BFS — only completed/constructing towers — because
/// queued ghosts are intent, not structures.</item>
/// </list></para>
///
/// <para><b>D2 build-queue flow.</b> On success, ProcessRequest does NOT spawn the tower.
/// Instead it deducts gold, stamps occupancy=true (walkable stays true), and appends a
/// BuildJob to the player's builder. The Builder owns walking to the site, transitioning
/// to Constructing (which re-validates the path and stamps walkable=false), running the
/// staged construction animation, and finally calling
/// <see cref="ServerSpawnCompletedTower"/> to spawn the real TowerInstance.</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 enqueue the placement (which spawns a
/// build-site visual visible to all clients) or call back with
/// <see cref="PlacementRejectedRpc"/>.
/// </summary>
[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. Note
// that the occupancy grid was stamped at queue-time for ALL pending jobs
// in any builder's queue, so a single IsOccupied check correctly rejects
// overlap with a queued ghost (in this builder's queue OR another's).
// ------------------------------------------------------------------
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: Builder must exist
// The placing player needs a spawned builder to take ownership of the
// resulting BuildJob, but build range is NOT checked here. The whole
// point of a queue is to defer "go there and build it" — if the builder
// is out of range at queue-time, it will walk to the site when the job
// reaches the head of the queue. Range is enforced at construction-start
// (in Builder.DriveHead_Queued) which is the moment range actually matters.
// ------------------------------------------------------------------
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;
}
// ------------------------------------------------------------------
// 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: Queue capacity
// The placing player's builder must have room for one more job.
// Cheap check; runs before the path BFS.
// ------------------------------------------------------------------
if (builder.Jobs.Count >= builder.MaxQueueDepth)
{
Reject(req, PlacementRejectionReason.JobLimitReached);
return;
}
// ------------------------------------------------------------------
// Check 6: Path validity (queue-time)
// Temporarily stamp the footprint non-walkable, run BFS per spawner
// in the placing player's zone, then un-stamp if any spawner loses
// its exit route. Importantly we do NOT stamp other queued (but not
// yet constructing) jobs as non-walkable — queued ghosts represent
// intent only and don't block enemies. The check is "could THIS
// tower be built right now if it were instantly complete?" — a
// coarse test that catches obvious blockers at queue-time. The
// construction-start re-check (in Builder.DriveHead_Queued) catches
// cases where the maze changed since queue-time.
// ------------------------------------------------------------------
StampWalkable(loader, footprint, walkable: false);
bool pathValid = CheckPathValidity(loader, placingSlot);
// Restore walkability — the queue stage leaves tiles walkable.
// Occupancy is stamped below as part of the commit.
StampWalkable(loader, footprint, walkable: true);
if (!pathValid)
{
Reject(req, PlacementRejectionReason.BlocksPath);
return;
}
// ------------------------------------------------------------------
// All checks passed — commit the queue entry.
// - Mark the footprint occupied (but keep it walkable; queued ghosts
// don't block enemies).
// - Deduct gold.
// - Append the BuildJob to the builder's queue. The Builder spawns
// the green-ghost build-site visual itself.
// ------------------------------------------------------------------
StampOccupied(loader, footprint, occupied: true);
goldManager.DeductGold(def.GoldCost);
if (!builder.ServerEnqueueJob(req.Anchor, req.TowerTypeId, def.GoldCost,
out ulong jobId))
{
// Should not happen — we checked capacity above. Defensive: roll back.
StampOccupied(loader, footprint, occupied: false);
goldManager.AwardGold(def.GoldCost);
Reject(req, PlacementRejectionReason.JobLimitReached);
return;
}
Debug.Log($"[TowerPlacementManager] Queued '{def.DisplayName}' (job {jobId}) for " +
$"client {req.SenderClientId} ({placingSlot}) at anchor {req.Anchor}.");
}
// ----- Server-side commit hooks called by Builder ------------------
/// <summary>
/// Server-only: called by <c>Builder</c> the moment a queued job's builder arrives
/// at the build site. Stamps the footprint non-walkable and re-runs the path BFS.
/// On success, the footprint stays non-walkable and the caller transitions the
/// job to Constructing. On failure, walkability is restored and false is returned;
/// caller must drop and refund the job.
/// </summary>
public bool ServerCommitConstructionStart(Vector2Int anchor, Vector2Int footprintSize,
PlayerSlot placingSlot)
{
if (!IsServer) return false;
var loader = LevelLoader.Instance;
if (loader == null || !loader.IsLoaded) return false;
var footprint = new List<Vector2Int>(footprintSize.x * footprintSize.y);
foreach (var tile in GridCoordinates.GetFootprintTiles(anchor, footprintSize))
footprint.Add(tile);
StampWalkable(loader, footprint, walkable: false);
bool ok = CheckPathValidity(loader, placingSlot);
if (!ok)
{
// Roll back — the maze would break. Caller refunds and drops the job.
StampWalkable(loader, footprint, walkable: true);
return false;
}
// Footprint is now occupied (still) and non-walkable. Construction proceeds.
return true;
}
/// <summary>
/// Server-only: read-only path validity check for the given player's zone.
/// Used by <c>Builder</c> when resuming a paused job — the footprint is
/// already non-walkable (has been since original construction-start), so
/// stamping/un-stamping would be a no-op at best and a bug at worst (an
/// un-stamp would un-block an actively-blocking tile). This method just
/// runs the BFS against the current grid state and returns the result.
/// </summary>
public bool ServerVerifyPathStillValid(PlayerSlot placingSlot)
{
if (!IsServer) return false;
var loader = LevelLoader.Instance;
if (loader == null || !loader.IsLoaded) return false;
return CheckPathValidity(loader, placingSlot);
}
/// <summary>
/// Server-only: called by <c>Builder</c> when a constructing job completes.
/// Spawns the real <see cref="TowerInstance"/> NetworkObject at the anchor.
/// The footprint is already occupied and non-walkable from the construction-start
/// commit; <c>TowerInstance.OnNetworkSpawn</c>'s footprint stamp is idempotent
/// and harmless.
/// </summary>
public void ServerSpawnCompletedTower(TowerDefinition def, Vector2Int anchor,
PlayerSlot owner)
{
if (!IsServer) return;
SpawnTower(def, anchor, owner);
}
// ----- 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.
var exitTiles = BuildExitTileSet(levelData, slot);
if (exitTiles.Count == 0)
{
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;
// 8-connected expansion to match enemy pathfinding. A 4-connected
// BFS here would reject placements enemies could actually navigate
// around via diagonals, OR accept placements that diagonally squeeze
// through corners. Corner-cut prevention keeps the maze rule consistent
// with PathfindingService: a diagonal step requires both shoulder
// cardinal tiles to be walkable.
foreach (var neighbor in GridCoordinates.GetNeighbors8(current))
{
if (bfsVisited.Contains(neighbor)) continue;
if (!loader.IsWalkable(neighbor)) continue;
if (GridCoordinates.IsDiagonal(current, neighbor))
{
GridCoordinates.GetCornerShoulders(current, neighbor,
out var shoulderA, out var shoulderB);
if (!loader.IsWalkable(shoulderA) || !loader.IsWalkable(shoulderB))
continue;
}
bfsVisited.Add(neighbor);
bfsQueue.Enqueue(neighbor);
}
}
return false;
}
// ----- Helpers ----------------------------------------------------
/// <summary>
/// Stamps walkability on every tile in <paramref name="footprint"/>.
/// Independent of occupancy because the queue-time and construction-time
/// transitions touch them on different schedules.
/// </summary>
private static void StampWalkable(LevelLoader loader, List<Vector2Int> footprint,
bool walkable)
{
foreach (var tile in footprint)
loader.SetWalkable(tile, walkable);
}
/// <summary>
/// Stamps occupancy on every tile in <paramref name="footprint"/>.
/// </summary>
private static void StampOccupied(LevelLoader loader, List<Vector2Int> footprint,
bool occupied)
{
foreach (var tile in footprint)
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;
if (Instance == null) return false;
if (typeId >= Instance.towerDefinitions.Length) return false;
def = Instance.towerDefinitions[typeId];
return def != null;
}
/// <summary>
/// Public lookup used by <see cref="Builder"/> to resolve a tower type ID
/// to its definition. Returns null if the ID is invalid.
/// </summary>
public static TowerDefinition GetDefinition(int typeId)
{
return TryGetDefinition(typeId, out var def) ? def : null;
}
/// <summary>
/// Enumerates all valid (definition, typeId) pairs from the current definition list.
/// TypeId 0 is reserved; valid entries start at index 1. Used by the HUD command grid
/// to populate tower buttons. Only meaningful on the instance — check Instance != null
/// before calling.
/// </summary>
public System.Collections.Generic.IEnumerable<(TowerDefinition def, int typeId)>
GetAvailableDefinitions()
{
for (int i = 1; i < towerDefinitions.Length; i++)
{
if (towerDefinitions[i] != null)
yield return (towerDefinitions[i], i);
}
}
private static PlayerSlot ClientIdToPlayerSlot(ulong clientId)
=> PlayerMatchState.SlotForClient(clientId);
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
/// or by a queued/constructing build job.</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 placing player's builder queue is at capacity. Cancel pending
/// jobs or wait for one to complete before queuing more.</summary>
JobLimitReached,
/// <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,
}
}