Adding a ton of funcitonality to the builder's movement and build queue
This commit is contained in:
parent
a63cce53e2
commit
f05734e19b
31 changed files with 3104 additions and 339 deletions
|
|
@ -10,39 +10,48 @@ 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.
|
||||
/// 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 processing.</b> Incoming RPCs enqueue a
|
||||
/// <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
|
||||
/// 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>
|
||||
/// 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 — every footprint tile must be <c>Buildable</c> and unoccupied.</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.</item>
|
||||
/// 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>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>
|
||||
/// <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
|
||||
{
|
||||
|
|
@ -123,12 +132,10 @@ namespace TD.Gameplay
|
|||
|
||||
/// <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"/>.
|
||||
/// 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>
|
||||
/// <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)
|
||||
|
|
@ -210,7 +217,10 @@ namespace TD.Gameplay
|
|||
|
||||
// ------------------------------------------------------------------
|
||||
// Check 2: Placement state + occupancy
|
||||
// Every footprint tile must be Buildable and not already occupied.
|
||||
// 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)
|
||||
{
|
||||
|
|
@ -227,10 +237,13 @@ namespace TD.Gameplay
|
|||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 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.
|
||||
// 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)
|
||||
|
|
@ -239,11 +252,6 @@ namespace TD.Gameplay
|
|||
Reject(req, PlacementRejectionReason.ServerError);
|
||||
return;
|
||||
}
|
||||
if (!builder.IsTileWithinBuildRange(req.Anchor, def.FootprintSize))
|
||||
{
|
||||
Reject(req, PlacementRejectionReason.OutOfRange);
|
||||
return;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Check 4: Gold
|
||||
|
|
@ -258,35 +266,135 @@ namespace TD.Gameplay
|
|||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 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.
|
||||
// Check 5: Queue capacity
|
||||
// The placing player's builder must have room for one more job.
|
||||
// Cheap check; runs before the path BFS.
|
||||
// ------------------------------------------------------------------
|
||||
StampFootprint(loader, footprint, walkable: false, occupied: true);
|
||||
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)
|
||||
{
|
||||
// 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.
|
||||
// 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);
|
||||
|
||||
SpawnTower(def, req.Anchor, placingSlot);
|
||||
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] Placed '{def.DisplayName}' for " +
|
||||
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>
|
||||
|
|
@ -316,14 +424,9 @@ namespace TD.Gameplay
|
|||
}
|
||||
|
||||
// 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;
|
||||
|
|
@ -406,17 +509,25 @@ namespace TD.Gameplay
|
|||
// ----- Helpers ----------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Stamps or un-stamps all tiles in <paramref name="footprint"/> on both the
|
||||
/// walkability and occupancy grids simultaneously. Always update both together.
|
||||
/// 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 StampFootprint(LevelLoader loader, List<Vector2Int> footprint,
|
||||
bool walkable, bool occupied)
|
||||
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>
|
||||
|
|
@ -468,14 +579,21 @@ namespace TD.Gameplay
|
|||
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>
|
||||
/// 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>
|
||||
/// Maps a client ID to the PlayerSlot assigned to that client.
|
||||
/// </summary>
|
||||
|
|
@ -486,8 +604,6 @@ namespace TD.Gameplay
|
|||
/// </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;
|
||||
|
|
@ -524,7 +640,8 @@ namespace TD.Gameplay
|
|||
/// (they are Restricted or Outside the map).</summary>
|
||||
TileNotBuildable,
|
||||
|
||||
/// <summary>One or more footprint tiles are already occupied by an existing tower.</summary>
|
||||
/// <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>
|
||||
|
|
@ -537,6 +654,10 @@ namespace TD.Gameplay
|
|||
/// 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,
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue