714 lines
No EOL
33 KiB
C#
714 lines
No EOL
33 KiB
C#
// 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>();
|
||
|
||
// Scratch set for "tiles that should be treated as blocked for this BFS run only"
|
||
// — populated by queue-time path-validity checks with the candidate tower's footprint
|
||
// tiles. Avoids the stamp-then-restore pattern (which fired walkability-change events
|
||
// on tiles whose net state didn't change, causing a cascade of enemy re-paths).
|
||
private readonly HashSet<Vector2Int> virtualBlockedScratch = new HashSet<Vector2Int>();
|
||
|
||
// ----- Lifecycle --------------------------------------------------
|
||
|
||
public override void OnNetworkSpawn()
|
||
{
|
||
if (Instance != null && Instance != this)
|
||
{
|
||
Debug.LogError("[TowerPlacementManager] Multiple instances detected. " +
|
||
"Only one TowerPlacementManager should exist per scene.");
|
||
}
|
||
Instance = this;
|
||
|
||
if (IsServer)
|
||
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)
|
||
// Virtually treat the footprint as non-walkable and run BFS per spawner
|
||
// in the placing player's zone. We do NOT modify the grid here — the
|
||
// BFS just consults a "virtually blocked" tile set in addition to
|
||
// IsWalkable. Importantly we do NOT block other queued (but not yet
|
||
// constructing) jobs — 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.
|
||
//
|
||
// Why virtual instead of stamp-and-restore: every real walkability
|
||
// flip fires OnWalkabilityChanged which triggers all enemies to A*.
|
||
// Stamp-and-restore (no net change) would fire those events twice for
|
||
// no reason. The virtual approach has zero side-effects.
|
||
// ------------------------------------------------------------------
|
||
virtualBlockedScratch.Clear();
|
||
foreach (var tile in footprint) virtualBlockedScratch.Add(tile);
|
||
|
||
bool pathValid = CheckPathValidity(loader, placingSlot, virtualBlockedScratch);
|
||
|
||
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);
|
||
|
||
// Virtual check first — no grid mutation, no walkability events fire while
|
||
// we're just asking "would this break the maze?". Only if the check passes
|
||
// do we stamp the footprint for real, which fires exactly one batched event.
|
||
virtualBlockedScratch.Clear();
|
||
foreach (var tile in footprint) virtualBlockedScratch.Add(tile);
|
||
|
||
if (!CheckPathValidity(loader, placingSlot, virtualBlockedScratch))
|
||
{
|
||
// Maze would break. Caller refunds and drops the job. Grid untouched,
|
||
// no events fired.
|
||
return false;
|
||
}
|
||
|
||
// Commit: stamp the footprint non-walkable. Single batched event fires
|
||
// OnWalkabilityChanged once for the whole footprint, regardless of size.
|
||
StampWalkable(loader, footprint, walkable: false);
|
||
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,
|
||
HashSet<Vector2Int> virtualBlocked = null)
|
||
{
|
||
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. The optional
|
||
// virtualBlocked set lets queue-time checks treat the candidate footprint as
|
||
// non-walkable WITHOUT modifying the grid (avoiding spurious walkability events).
|
||
foreach (var spawner in zoneData.Spawners)
|
||
{
|
||
if (!SpawnerCanReachExit(loader, spawner, exitTiles, virtualBlocked))
|
||
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,
|
||
HashSet<Vector2Int> virtualBlocked = null)
|
||
{
|
||
bfsQueue.Clear();
|
||
bfsVisited.Clear();
|
||
|
||
// Local walkability check that honors the virtual-blocked override. Hot-path
|
||
// helper so we don't duplicate the conditional inside every neighbor test.
|
||
bool IsTileOpen(Vector2Int t)
|
||
{
|
||
if (virtualBlocked != null && virtualBlocked.Contains(t)) return false;
|
||
return loader.IsWalkable(t);
|
||
}
|
||
|
||
// 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 (!IsTileOpen(neighbor)) continue;
|
||
|
||
if (GridCoordinates.IsDiagonal(current, neighbor))
|
||
{
|
||
GridCoordinates.GetCornerShoulders(current, neighbor,
|
||
out var shoulderA, out var shoulderB);
|
||
if (!IsTileOpen(shoulderA) || !IsTileOpen(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)
|
||
{
|
||
// Batched: fires OnWalkabilityChanged at most once for the whole footprint,
|
||
// instead of once per tile. Without this, a 2×2 placement fires 4 enemy
|
||
// re-paths instead of 1; a 3×3 fires 9. The cascade was the dominant
|
||
// contributor to placement stutter on larger maps.
|
||
loader.SetWalkableBatch(footprint, 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,
|
||
}
|
||
} |