Adding a ton of funcitonality to the builder's movement and build queue

This commit is contained in:
Matt F 2026-05-05 22:01:40 -07:00
parent a63cce53e2
commit f05734e19b
31 changed files with 3104 additions and 339 deletions

View file

@ -3,32 +3,46 @@ using System.Collections.Generic;
using Unity.Netcode;
using UnityEngine;
using TD.Core;
using TD.Levels;
using TD.Towers;
namespace TD.Gameplay
{
/// <summary>
/// Per-player avatar that gates tower placement by proximity. Server-authoritative
/// position; clients submit move requests via Rpc and the server validates and applies.
/// Per-player avatar that gates tower placement by proximity AND drives the build
/// queue. Server-authoritative position and queue; clients submit move requests
/// via Rpc and read replicated NetworkList state to render queued/constructing
/// visuals.
/// </summary>
/// <remarks>
/// <para><b>Pure visual avatar.</b> Builders have no collider for gameplay purposes —
/// they don't block enemies, can't be attacked, and aren't selected as targets. They
/// are visible to all players but only their owner can move them.</para>
/// <para><b>Pure visual avatar.</b> Builders have no collider for gameplay purposes
/// (no enemy blocking, not targetable). They DO have a small trigger collider on
/// the "Selection" physics layer so left-click can select them — that collider
/// must not be on layers that participate in placement raycasts or builder
/// height sampling. See the prefab setup notes in the project context doc.</para>
///
/// <para><b>Terrain-aware height.</b> Each frame the server casts a ray straight down
/// from the builder against the <see cref="terrainLayerMask"/> and sets Y to
/// <c>hit.point.y + heightOffset</c>. If the ray misses, falls back to the buildable
/// plane Y. Towers are not on the terrain layer, so they don't influence height.</para>
/// <para><b>Terrain-aware height.</b> Each frame the server casts a ray straight
/// down from the builder against <see cref="terrainLayerMask"/> and sets Y to
/// <c>hit.point.y + heightOffset</c>. Falls back to <see cref="GridCoordinates.BUILDABLE_PLANE_Y"/>
/// if the ray misses. Towers must not be on the terrain layer.</para>
///
/// <para><b>Range gating.</b> <see cref="IsTileWithinBuildRange"/> is the public query
/// that <c>TowerPlacementManager</c> uses to validate placement requests. Builder range
/// is measured center-of-builder to center-of-anchor-tile in world units.</para>
/// <para><b>Range gating.</b> <see cref="IsTileWithinBuildRange"/> is the public
/// query that <c>TowerPlacementManager</c> uses to validate placement requests.
/// Range is measured center-of-builder to nearest-point-of-footprint in world units.</para>
///
/// <para><b>Static registry.</b> Like <see cref="PlayerGoldManager"/>, builders register
/// themselves in a static dictionary keyed by <c>OwnerClientId</c> on spawn, so server
/// gameplay code (notably <c>TowerPlacementManager</c>) can find a player's builder without
/// scene traversal.</para>
/// <para><b>Build queue.</b> Each Builder owns a <see cref="NetworkList{T}"/> of
/// <see cref="BuildJob"/>. The server appends jobs from
/// <c>TowerPlacementManager.ProcessRequest</c> via <see cref="ServerEnqueueJob"/>.
/// Each server tick, if the head job is Queued, the builder walks toward its
/// anchor; on arrival, the head job transitions to Constructing (path re-validated
/// here; refunded and dropped if the maze would now break). Stages advance by
/// time; on completion the visual is replaced with a real <c>TowerInstance</c>
/// and the head job is removed. Cancellation drops every job in the queue and
/// refunds 100% per the design doc.</para>
///
/// <para><b>Static registry.</b> Like <see cref="PlayerGoldManager"/>, builders
/// register themselves in a static dictionary keyed by <c>OwnerClientId</c> on
/// spawn so server gameplay code can find a player's builder without scene
/// traversal.</para>
/// </remarks>
[RequireComponent(typeof(NetworkObject))]
public class Builder : NetworkBehaviour
@ -74,6 +88,11 @@ namespace TD.Gameplay
"but smoother.")]
[SerializeField] private float arrivalThreshold = 0.05f;
[Tooltip("Degrees per second the builder rotates to face its movement direction. " +
"Lower = lazier turns; higher = snappier. The builder only rotates while " +
"moving; it keeps its last facing when idle.")]
[SerializeField] private float turnRateDegPerSec = 540f;
[Header("Height tracking")]
[Tooltip("Vertical offset above the terrain at which the builder hovers. " +
"Re-evaluated every server tick by raycasting straight down.")]
@ -93,6 +112,24 @@ namespace TD.Gameplay
"for placement to be allowed, measured in world units (== tiles).")]
[SerializeField] private float buildRange = 6f;
[Header("Build queue")]
[Tooltip("Maximum number of pending build jobs. Bounds memory and prevents a player " +
"from spamming queue entries faster than the server can process them.")]
[SerializeField] private int maxQueueDepth = 32;
[Tooltip("Build-site visual prefab. Spawned at queue-time as a green ghost; " +
"transitions to staged-construction visuals on arrival; despawned on " +
"completion (replaced by the real TowerInstance) or cancellation.")]
[SerializeField] private GameObject buildSiteVisualPrefab;
[Header("Visuals")]
[Tooltip("Mesh renderers that should be tinted with the owner's player color. " +
"Drag in only the builder body's renderers — exclude the SelectionRing, " +
"BuildRangeIndicator, or any other visual that has its own color rules. " +
"If left empty, the builder will not be tinted (other meshes' colors " +
"from the prefab are preserved).")]
[SerializeField] private MeshRenderer[] tintedRenderers;
// ----- Networked state --------------------------------------------
// Server-authoritative target position. The server moves the builder toward this
@ -104,6 +141,28 @@ namespace TD.Gameplay
readPerm: NetworkVariableReadPermission.Everyone,
writePerm: NetworkVariableWritePermission.Server);
// The build queue. Replicated as a NetworkList so all clients can render queued
// ghosts and progress without per-job RPCs. Server is the only writer.
private NetworkList<BuildJob> jobs;
/// <summary>
/// Read-only access to the current job queue. Clients use this to render
/// build-site visuals at queued anchors. Index 0 is the head (active) job.
/// </summary>
public NetworkList<BuildJob> Jobs => jobs;
// ----- Server-only state ------------------------------------------
//
// Tracks the BuildSiteVisual NetworkObject for each active job so we can
// despawn it on completion or cancellation. NetworkList itself doesn't
// hold object references; we keep the side-table here.
private readonly Dictionary<ulong, NetworkObject> jobIdToVisual
= new Dictionary<ulong, NetworkObject>();
// Monotonic job-ID counter on the server. Resets per builder, which is fine —
// IDs only need to be unique within one builder's queue.
private ulong nextJobId = 1;
// ----- Public accessors -------------------------------------------
/// <summary>The builder's current world position (its actual transform position,
@ -122,8 +181,39 @@ namespace TD.Gameplay
/// <summary>Build range in world units.</summary>
public float BuildRange => buildRange;
/// <summary>Maximum jobs allowed in the queue.</summary>
public int MaxQueueDepth => maxQueueDepth;
/// <summary>True if a tile is currently part of any queued or constructing job.</summary>
/// <remarks>
/// Used by <c>TowerPlacementManager</c> to reject placement on tiles already
/// covered by another pending job in this builder's queue. Walks every job
/// and footprint — O(jobs × tiles) but bounded by <see cref="MaxQueueDepth"/>.
/// </remarks>
public bool IsTileInActiveJob(Vector2Int tile)
{
for (int i = 0; i < jobs.Count; i++)
{
var job = jobs[i];
Vector2Int footprint = ResolveFootprint(job.TowerTypeId);
foreach (var t in GridCoordinates.GetFootprintTiles(job.Anchor, footprint))
{
if (t == tile) return true;
}
}
return false;
}
// ----- Lifecycle --------------------------------------------------
private void Awake()
{
// NetworkList must be allocated before NetworkObject.Spawn() (NGO 2.x
// discovers it during the spawn handshake). Awake runs before any
// network lifecycle method, so this is the right place.
jobs = new NetworkList<BuildJob>();
}
public override void OnNetworkSpawn()
{
s_byClientId[OwnerClientId] = this;
@ -132,15 +222,46 @@ namespace TD.Gameplay
{
// Set initial target = current position so the builder doesn't drift on spawn.
// The spawner is responsible for placing this builder at a sensible position
// BEFORE Spawn() — see PlayerSpawnHelper / Player.OnNetworkSpawn.
// BEFORE Spawn() — see PlayerBuilderSpawner.
targetPosition.Value = transform.position;
Debug.Log($"[Builder] Spawned for client {OwnerClientId} at " +
$"{transform.position}.");
}
ApplyOwnerColor();
// Auto-select on spawn for the local owner. RTS-standard "your unit is
// selected by default when it appears" — without this, the player has to
// click their own builder before any right-click command works, which is
// friction. Players can still deselect (left-click empty space, Escape)
// and re-select normally. Owner-gated so remote clients don't accidentally
// get someone else's builder selected.
if (IsOwner)
{
SelectionState.Instance?.Select(this);
}
}
public override void OnNetworkDespawn()
{
if (s_byClientId.TryGetValue(OwnerClientId, out var registered) && registered == this)
s_byClientId.Remove(OwnerClientId);
// Server-only cleanup: despawn any remaining build-site visuals so they
// don't leak when a player disconnects mid-construction.
if (IsServer)
{
foreach (var kv in jobIdToVisual)
{
if (kv.Value != null && kv.Value.IsSpawned)
kv.Value.Despawn(destroy: true);
}
jobIdToVisual.Clear();
}
}
// (NetworkList is owned by NGO; no manual Dispose needed in NGO 2.x.)
// ----- Owner color tinting ----------------------------------------
// Lazily allocated; reused across renderers. Construction in a field initializer
@ -166,14 +287,16 @@ namespace TD.Gameplay
colorPropertyBlock.SetColor(ColorPropertyId, c);
colorPropertyBlock.SetColor(BaseColorPropertyId, c);
foreach (var rend in GetComponentsInChildren<MeshRenderer>())
// Tint only the renderers explicitly listed in the inspector. Avoids
// accidentally re-coloring the SelectionRing or BuildRangeIndicator,
// which need their own (non-player) colors. If the list is empty,
// skip — the builder shows whatever colors are baked into the prefab.
if (tintedRenderers == null) return;
foreach (var rend in tintedRenderers)
{
if (rend == null) continue;
rend.SetPropertyBlock(colorPropertyBlock);
}
public override void OnNetworkDespawn()
{
if (s_byClientId.TryGetValue(OwnerClientId, out var registered) && registered == this)
s_byClientId.Remove(OwnerClientId);
}
}
// ----- Per-frame movement (server only) ---------------------------
@ -182,7 +305,15 @@ namespace TD.Gameplay
{
if (!IsServer) return;
// Move toward target on the XZ plane.
// Step 1: drive movement target from the queue head, if appropriate.
ServerDriveQueue();
// Step 2: move toward the target on XZ, sample terrain Y.
ServerStepMovement();
}
private void ServerStepMovement()
{
Vector3 current = transform.position;
Vector3 target = targetPosition.Value;
@ -191,18 +322,35 @@ namespace TD.Gameplay
Vector3 targetXZ = new Vector3(target.x, 0f, target.z);
Vector3 newXZ;
bool moving;
if (Vector3.SqrMagnitude(currentXZ - targetXZ) <= arrivalThreshold * arrivalThreshold)
{
newXZ = targetXZ;
moving = false;
}
else
{
newXZ = Vector3.MoveTowards(currentXZ, targetXZ, moveSpeed * Time.deltaTime);
moving = true;
}
// Resolve Y from terrain.
float groundY = SampleTerrainY(new Vector3(newXZ.x, 0f, newXZ.z));
transform.position = new Vector3(newXZ.x, groundY + heightOffset, newXZ.z);
// Smoothly face the movement direction. We rotate on the server only;
// NetworkTransform replicates the rotation to clients alongside position.
// Skip rotation when stationary so the builder keeps its last facing.
if (moving)
{
Vector3 dir = targetXZ - currentXZ;
if (dir.sqrMagnitude > 0.0001f)
{
Quaternion desired = Quaternion.LookRotation(dir, Vector3.up);
transform.rotation = Quaternion.RotateTowards(
transform.rotation, desired, turnRateDegPerSec * Time.deltaTime);
}
}
}
/// <summary>
@ -228,8 +376,7 @@ namespace TD.Gameplay
/// <summary>
/// Server-side entry point: directly sets the move target. Called by the input
/// controller's Rpc handler after validation. Out-of-map positions are clamped
/// to the current position (no-op).
/// controller's Rpc handler after validation. Out-of-map positions are rejected.
/// </summary>
public void ServerSetMoveTarget(Vector3 worldPos)
{
@ -246,8 +393,7 @@ namespace TD.Gameplay
Vector2Int tile = GridCoordinates.WorldToGrid(worldPos);
if (!loader.IsInMap(tile))
{
// Out-of-map move requests are rejected silently. Could log if useful for
// debugging client/server mismatch, but otherwise this is normal.
// Out-of-map move requests are rejected silently.
return;
}
}
@ -268,7 +414,7 @@ namespace TD.Gameplay
ServerSetMoveTarget(worldPos);
}
// ----- Range query (used by TowerPlacementManager) ----------------
// ----- Range query (used by Builder's own queue driver) ----------
/// <summary>
/// True if the tower with the given anchor and footprint size is within build range
@ -276,9 +422,14 @@ namespace TD.Gameplay
/// nearest point of the tower footprint, in world units.
/// </summary>
/// <remarks>
/// "Nearest point of the footprint" rather than "footprint center" so that a tower
/// is reachable when ANY of its tiles is within range, even if the center is
/// slightly outside. Aligns with player intuition that "I can reach this tile."
/// <para>Used by <see cref="DriveHead_Queued"/> as the "have I arrived?" check —
/// the builder walks toward a queued job until it's in range, then begins
/// construction. NOT consulted at queue-time; players can queue any tile in their
/// zone regardless of where the builder currently is.</para>
///
/// <para>"Nearest point of the footprint" rather than "footprint center" so that
/// a tower is reachable when ANY of its tiles is within range, even if the center
/// is slightly outside. Aligns with player intuition that "I can reach this tile."</para>
/// </remarks>
public bool IsTileWithinBuildRange(Vector2Int anchor, Vector2Int footprintSize)
{
@ -299,5 +450,572 @@ namespace TD.Gameplay
return Vector3.SqrMagnitude(builderXZ - nearestPoint)
<= buildRange * buildRange;
}
// ===================================================================
// BUILD QUEUE (server-side)
// ===================================================================
/// <summary>
/// Server-only: append a new job to the queue. Caller is responsible for having
/// already validated the placement (ownership, gold, range, path) and stamped
/// the footprint as occupied (walkable=true). Returns true on success, or false
/// if the queue is full.
/// </summary>
public bool ServerEnqueueJob(Vector2Int anchor, int towerTypeId, int goldSpent,
out ulong jobId)
{
jobId = 0;
if (!IsServer) return false;
if (jobs.Count >= maxQueueDepth) return false;
jobId = nextJobId++;
var job = BuildJob.CreateQueued(jobId, anchor, towerTypeId, goldSpent);
jobs.Add(job);
// Spawn a green-ghost build-site visual at the anchor.
SpawnBuildSiteVisual(job);
return true;
}
private void SpawnBuildSiteVisual(BuildJob job)
{
if (buildSiteVisualPrefab == null)
{
Debug.LogError("[Builder] No buildSiteVisualPrefab assigned. " +
"Queued ghost will not be visible.");
return;
}
var def = TowerPlacementManager.GetDefinition(job.TowerTypeId);
if (def == null)
{
Debug.LogError($"[Builder] Could not resolve TowerDefinition " +
$"{job.TowerTypeId} when spawning build-site visual.");
return;
}
Vector3 spawnPos = GridCoordinates.GetFootprintCenterWorld(
job.Anchor, def.FootprintSize);
// Sit on the buildable plane at the same elevation real towers will use.
spawnPos.y = 0f;
var go = Instantiate(buildSiteVisualPrefab, spawnPos, Quaternion.identity);
var visual = go.GetComponent<BuildSiteVisual>();
if (visual == null)
{
Debug.LogError("[Builder] buildSiteVisualPrefab is missing a " +
"BuildSiteVisual component.");
Destroy(go);
return;
}
// Owner slot: same stub mapping as elsewhere.
byte slotByte = (byte)(OwnerClientId + 1);
PlayerSlot owner = (slotByte >= 1 && slotByte <= 9)
? (PlayerSlot)slotByte
: PlayerSlot.None;
visual.InitializeServer(def, owner, job.Anchor, job.TowerTypeId, job.GoldSpent);
var netObj = go.GetComponent<NetworkObject>();
if (netObj == null)
{
Debug.LogError("[Builder] buildSiteVisualPrefab is missing a NetworkObject.");
Destroy(go);
return;
}
// SpawnWithOwnership rather than plain Spawn so the visual's inherited
// NetworkBehaviour.OwnerClientId correctly reports the player's client ID.
// This is purely for identity (used by client-side click tests in
// BuilderInputController) — the visual has no owner-only Rpcs, so granting
// ownership to the player has no security implications. As a bonus, NGO
// automatically cleans up owned NetworkObjects when a player disconnects.
netObj.SpawnWithOwnership(OwnerClientId, destroyWithScene: true);
jobIdToVisual[job.JobId] = netObj;
}
// ----- Per-tick queue drive (server) ------------------------------
private void ServerDriveQueue()
{
if (jobs.Count == 0) return;
var head = jobs[0];
switch (head.Stage)
{
case BuildStage.Queued:
DriveHead_Queued(ref head);
break;
case BuildStage.Constructing:
DriveHead_Constructing(ref head);
break;
case BuildStage.Paused:
// Nothing to do — paused jobs wait for an explicit resume command.
// The builder is free to be moved around; targetPosition is not
// touched here so player right-click moves are honored.
break;
}
}
// While the head job is Queued: walk toward the build site, checking each
// tick whether the builder is now in range. On reaching range, re-validate
// the path and either kick off construction or fail it. The builder doesn't
// need to walk *onto* the build site — being within build-range is enough,
// matching player intuition and the Wintermaul-style "reach to build" feel.
private void DriveHead_Queued(ref BuildJob head)
{
var def = TowerPlacementManager.GetDefinition(head.TowerTypeId);
if (def == null)
{
// Lost the definition somehow — drop the job and refund.
Debug.LogWarning($"[Builder] Job {head.JobId} has unknown tower type " +
$"{head.TowerTypeId}. Dropping and refunding.");
ServerCancelHead(refund: true);
return;
}
// If we're already in range, kick off construction immediately. Otherwise
// walk toward the build-site center; we'll re-check range on subsequent
// ticks. The walk target is the footprint center even though we may stop
// before reaching it — Vector3.MoveTowards handles the early-stop naturally
// when the range check trips.
if (!IsTileWithinBuildRange(head.Anchor, def.FootprintSize))
{
Vector3 siteCenter = GridCoordinates.GetFootprintCenterWorld(
head.Anchor, def.FootprintSize);
targetPosition.Value = new Vector3(siteCenter.x, 0f, siteCenter.z);
return;
}
// In range. Stop here so the builder doesn't keep walking onto the
// build site after construction kicks off. Setting target = current
// position freezes the walk at this exact spot.
targetPosition.Value = new Vector3(
transform.position.x, 0f, transform.position.z);
// Path-revalidation: the rules differ for fresh-queued vs resume-from-paused.
//
// FRESH QUEUE (AccumulatedConstructionTime == 0): footprint is currently
// walkable (queue stage doesn't stamp it). Stamp it non-walkable, run BFS,
// un-stamp on failure.
//
// RESUME (AccumulatedConstructionTime > 0): footprint is ALREADY non-walkable
// (has been since the original Constructing transition; pause didn't restore
// walkability because the half-built tower keeps blocking enemies). Don't
// stamp anything — just verify the path still works given current grid state.
// Path failure on resume is essentially impossible (the path was valid before
// pause, and only this player can build in their zone), but the defensive
// check is here in case future mechanics change that.
var placingSlot = OwnerToSlot(OwnerClientId);
bool isResume = head.AccumulatedConstructionTime > 0f;
bool pathOk;
if (isResume)
{
pathOk = TowerPlacementManager.Instance != null
&& TowerPlacementManager.Instance.ServerVerifyPathStillValid(placingSlot);
}
else
{
pathOk = TowerPlacementManager.Instance != null
&& TowerPlacementManager.Instance.ServerCommitConstructionStart(
head.Anchor, def.FootprintSize, placingSlot);
}
if (!pathOk)
{
// Path would now break. Refund and drop the job. The walkability
// rules differ between fresh-queue and resume:
// - Fresh queue: ServerCommitConstructionStart already rolled
// walkable=true, so the tile is in the same state as a normal
// Queued job (occupied=true, walkable=true). Just clear occupancy.
// - Resume: walkable was never stamped this go-round but the tile
// IS non-walkable from the original construction. We need to
// restore walkability since the tower is being fully removed.
Debug.Log($"[Builder] Job {head.JobId} dropped at construction-start: " +
$"path would break. Refunding {head.GoldSpent} gold.");
var loader = LevelLoader.Instance;
if (loader != null && loader.IsLoaded)
{
foreach (var tile in GridCoordinates.GetFootprintTiles(head.Anchor, def.FootprintSize))
{
loader.SetOccupied(tile, false);
if (isResume) loader.SetWalkable(tile, true);
}
}
RefundJobGold(head);
DespawnVisualAndForget(head.JobId);
jobs.RemoveAt(0);
return;
}
// Path OK; transition to Constructing.
head.Stage = BuildStage.Constructing;
head.ConstructionStartServerTime = (float)NetworkManager.Singleton.ServerTime.Time;
jobs[0] = head;
// Tell the build-site visual to switch to the constructing animation.
// For a fresh transition AccumulatedConstructionTime is 0 (no previous progress);
// for a resume it carries the elapsed time from prior runs so the visual
// and the elapsed-time computation pick up where they left off.
if (jobIdToVisual.TryGetValue(head.JobId, out var visualObj) && visualObj != null)
{
var visual = visualObj.GetComponent<BuildSiteVisual>();
if (visual != null) visual.ServerBeginConstructing(head.AccumulatedConstructionTime);
}
}
// While the head job is Constructing: wait for buildTime to elapse, then
// promote the build-site to a real TowerInstance and dequeue.
private void DriveHead_Constructing(ref BuildJob head)
{
var def = TowerPlacementManager.GetDefinition(head.TowerTypeId);
if (def == null)
{
// Should be impossible — definitions are validated at queue-time.
Debug.LogError($"[Builder] Constructing job {head.JobId} has unknown " +
$"tower type {head.TowerTypeId}. Dropping.");
ServerCancelHead(refund: true);
return;
}
// Total construction progress = elapsed in current run + previously accumulated
// (across prior pause/resume cycles). Pause sets ConstructionStartServerTime to -1
// so this code path only runs when the job is genuinely Constructing — but defensive
// check anyway.
float currentRunElapsed = head.ConstructionStartServerTime > 0f
? (float)NetworkManager.Singleton.ServerTime.Time - head.ConstructionStartServerTime
: 0f;
float total = currentRunElapsed + head.AccumulatedConstructionTime;
if (total < def.BuildTime) return;
// Construction complete. Spawn the real TowerInstance, despawn the visual,
// remove the job. The TowerInstance will stamp walkability=false / occupancy=true
// in its OnNetworkSpawn — but those bits are ALREADY non-walkable / occupied
// from the construction-start commit, so the stamp is a no-op-equivalent
// (idempotent writes; see TowerInstance.cs comments).
var placingSlot = OwnerToSlot(OwnerClientId);
TowerPlacementManager.Instance?.ServerSpawnCompletedTower(
def, head.Anchor, placingSlot);
// Despawn the build-site visual.
if (jobIdToVisual.TryGetValue(head.JobId, out var visualObj))
{
if (visualObj != null && visualObj.IsSpawned)
visualObj.Despawn(destroy: true);
jobIdToVisual.Remove(head.JobId);
}
// Pop the head.
jobs.RemoveAt(0);
Debug.Log($"[Builder] Job {head.JobId} complete at anchor {head.Anchor}.");
}
// ----- Cancellation API -------------------------------------------
/// <summary>
/// Owner-only Rpc: cancel every job in this builder's queue. Each cancellation
/// refunds 100% of the gold paid (per the design doc) and frees the affected
/// tiles. Builder stops at its current position.
/// </summary>
[Rpc(SendTo.Server, InvokePermission = RpcInvokePermission.Owner)]
public void RequestCancelAllJobsRpc()
{
if (!IsServer) return;
ServerCancelAllJobs();
}
/// <summary>
/// Server-side: cancel every job in the queue. Public so other server code
/// (disconnect cleanup, future "level reset" events) can invoke it directly.
/// </summary>
public void ServerCancelAllJobs()
{
// Cancel in reverse so RemoveAt indices stay valid as we go.
while (jobs.Count > 0)
{
ServerCancelJobAt(jobs.Count - 1);
}
// Stop walking. The current position becomes the new target so the builder
// settles at the spot it cancelled at, rather than drifting toward the
// anchor of the (now-removed) head job.
targetPosition.Value = new Vector3(
transform.position.x, 0f, transform.position.z);
}
// ===================================================================
// PAUSE / SHELVE / RESUME (D2 — paused builds are shelved off the queue)
// ===================================================================
/// <summary>
/// Owner-only Rpc: the player issued a "move to here" command (right-click
/// on empty buildable plane). Server applies the new move target and:
/// <list type="bullet">
/// <item>If head is Constructing → pause + shelve the head. The visual
/// remains in the world as a shelved standalone object; the queue
/// loses this entry. Tail jobs are refunded.</item>
/// <item>If head is Queued → refund the entire queue (no progress to preserve).</item>
/// <item>Empty queue → just a move command. No queue effects.</item>
/// </list>
/// </summary>
[Rpc(SendTo.Server, InvokePermission = RpcInvokePermission.Owner)]
public void RequestMoveAndPauseRpc(Vector3 worldPos)
{
if (!IsServer) return;
// Step 1: queue effects (if any).
if (jobs.Count > 0)
{
var head = jobs[0];
if (head.Stage == BuildStage.Constructing)
{
// Pause + shelve the head, then refund tail jobs.
ServerPauseAndShelveHead();
ServerRefundAllRemainingJobs();
}
else if (head.Stage == BuildStage.Queued)
{
// Head never reached Constructing — refund everything.
ServerCancelAllJobs();
}
// Note: Paused-in-queue is no longer a state we can be in (paused
// jobs are immediately shelved and removed from jobs[]). If we
// somehow get here, treat it like Constructing for safety.
}
// Step 2: apply the move target.
ServerSetMoveTarget(worldPos);
}
/// <summary>
/// Owner-only Rpc: the player right-clicked a shelved build site to resume it.
/// Server pre-empts the current queue (shelves any active head, refunds all
/// remaining jobs), then unshelves the clicked visual into a new BuildJob at
/// index 0. Builder will walk back into range and resume construction with
/// preserved accumulated time.
/// </summary>
[Rpc(SendTo.Server, InvokePermission = RpcInvokePermission.Owner)]
public void RequestResumeShelvedRpc(NetworkObjectReference visualRef)
{
if (!IsServer) return;
// Resolve the visual NetworkObject from the reference.
if (!visualRef.TryGet(out NetworkObject visualNetObj) || visualNetObj == null)
{
Debug.LogWarning("[Builder] RequestResumeShelvedRpc: visual not found.");
return;
}
var visual = visualNetObj.GetComponent<BuildSiteVisual>();
if (visual == null || !visual.IsShelved)
{
Debug.LogWarning("[Builder] RequestResumeShelvedRpc: visual is not shelved.");
return;
}
if (visualNetObj.OwnerClientId != OwnerClientId)
{
Debug.LogWarning($"[Builder] RequestResumeShelvedRpc: visual owner " +
$"{visualNetObj.OwnerClientId} != builder owner {OwnerClientId}.");
return;
}
// Step 1: pre-empt the current queue.
// - If head is Constructing: pause+shelve it (it becomes a new shelved tower).
// - Otherwise (Queued head, or empty): refund everything.
// The clicked visual is NOT in the queue (it's shelved), so this loop
// doesn't touch it.
if (jobs.Count > 0)
{
var head = jobs[0];
if (head.Stage == BuildStage.Constructing)
{
ServerPauseAndShelveHead();
ServerRefundAllRemainingJobs();
}
else
{
ServerCancelAllJobs();
}
}
// Step 2: unshelve the clicked visual. Reconstruct a BuildJob from its
// NetworkVariables, push it as the new head of the queue, and re-register
// it in jobIdToVisual so the queue driver finds it.
ulong jobId = nextJobId++;
var job = BuildJob.CreateQueued(
jobId,
visual.Anchor,
visual.TowerTypeId,
visual.GoldSpent);
// Carry the accumulated time forward — the resume keeps prior progress.
job.AccumulatedConstructionTime = visual.AccumulatedConstructionTime;
jobs.Add(job);
jobIdToVisual[jobId] = visualNetObj;
visual.ServerMarkUnshelved();
// Note: The visual's currentStage is still Paused; the queue driver's
// DriveHead_Queued will call ServerBeginConstructing once the builder
// arrives, which transitions it to Constructing.
Debug.Log($"[Builder] Resumed shelved tower at {visual.Anchor} as job " +
$"{jobId} (accumulated {visual.AccumulatedConstructionTime:F2}s).");
}
// ----- Pause/shelve/refund helpers --------------------------------
// Pauses + shelves the head job: Constructing → Paused, then removes from
// jobs[] and jobIdToVisual. The visual stays in the world as a standalone
// shelved object. Player can later right-click it to resume.
private void ServerPauseAndShelveHead()
{
if (jobs.Count == 0) return;
var head = jobs[0];
if (head.Stage != BuildStage.Constructing) return;
// Compute current run's elapsed and add to accumulated.
float currentRunElapsed = head.ConstructionStartServerTime > 0f
? (float)NetworkManager.Singleton.ServerTime.Time - head.ConstructionStartServerTime
: 0f;
float totalAccumulated = head.AccumulatedConstructionTime + currentRunElapsed;
// Tell the visual to enter Paused state, write the accumulated time,
// and mark itself as shelved. After this, the visual carries all the
// state that used to live on the BuildJob.
if (jobIdToVisual.TryGetValue(head.JobId, out var visualObj) && visualObj != null)
{
var visual = visualObj.GetComponent<BuildSiteVisual>();
if (visual != null)
{
visual.ServerPauseAndPersistAccumulated(totalAccumulated);
visual.ServerMarkShelved();
}
// Remove from our visual tracking — the visual is now self-managing.
jobIdToVisual.Remove(head.JobId);
}
// Pop the head from the queue. The visual stays in the world.
jobs.RemoveAt(0);
Debug.Log($"[Builder] Job {head.JobId} paused and shelved at " +
$"{totalAccumulated:F2}s of construction.");
}
// Refunds and removes EVERY job in the queue. Used after pause+shelve to
// clear out tail jobs (the shelved head is no longer in jobs[] at this point).
// Same as ServerCancelAllJobs but doesn't reset targetPosition (caller does that
// by setting an explicit move target afterward).
private void ServerRefundAllRemainingJobs()
{
for (int i = jobs.Count - 1; i >= 0; i--)
{
ServerCancelJobAt(i);
}
}
// ----- Public state accessor (read by the input controller) -------
// Note: PausedHeadJobId was removed when paused jobs moved out of the queue
// entirely. The input controller now uses the BuildSiteVisual's own state
// (visual.IsShelved + ownership) to determine resumeability and sends the
// visual's NetworkObjectReference to RequestResumeShelvedRpc.
// Cancels the head job specifically. Used for the "definition lost" defensive
// cases where the TowerDefinition can't be resolved. Restores walkability
// appropriately based on the current stage of the job being cancelled.
private void ServerCancelHead(bool refund)
{
if (jobs.Count == 0) return;
var head = jobs[0];
var def = TowerPlacementManager.GetDefinition(head.TowerTypeId);
if (def != null)
{
// Constructing or Paused → tile is non-walkable, restore it.
// Queued → tile was never made non-walkable, leave alone.
bool wasBlocking = head.Stage == BuildStage.Constructing
|| head.Stage == BuildStage.Paused;
FreeFootprintTiles(head.Anchor, def.FootprintSize,
alsoMakeWalkable: wasBlocking);
}
if (refund) RefundJobGold(head);
DespawnVisualAndForget(head.JobId);
jobs.RemoveAt(0);
}
// Cancels the job at index i. Used for cancel-all and any future targeted cancel.
private void ServerCancelJobAt(int index)
{
if (index < 0 || index >= jobs.Count) return;
var job = jobs[index];
var def = TowerPlacementManager.GetDefinition(job.TowerTypeId);
if (def != null)
{
// Walkability state by stage:
// - Queued: walkable (queue stage doesn't stamp walkability) → don't touch.
// - Constructing: non-walkable (stamped at construction-start) → restore.
// - Paused: non-walkable (stamped at original construction-start, never
// reverted because the half-built tower keeps blocking) → restore.
bool wasBlocking = job.Stage == BuildStage.Constructing
|| job.Stage == BuildStage.Paused;
FreeFootprintTiles(job.Anchor, def.FootprintSize,
alsoMakeWalkable: wasBlocking);
}
RefundJobGold(job);
DespawnVisualAndForget(job.JobId);
jobs.RemoveAt(index);
}
private void FreeFootprintTiles(Vector2Int anchor, Vector2Int footprint,
bool alsoMakeWalkable)
{
var loader = LevelLoader.Instance;
if (loader == null || !loader.IsLoaded) return;
foreach (var t in GridCoordinates.GetFootprintTiles(anchor, footprint))
{
loader.SetOccupied(t, false);
if (alsoMakeWalkable) loader.SetWalkable(t, true);
}
}
private void RefundJobGold(BuildJob job)
{
var goldManager = PlayerGoldManager.GetForClient(OwnerClientId);
if (goldManager == null) return;
goldManager.AwardGold(job.GoldSpent);
}
private void DespawnVisualAndForget(ulong jobId)
{
if (!jobIdToVisual.TryGetValue(jobId, out var visualObj)) return;
if (visualObj != null && visualObj.IsSpawned)
visualObj.Despawn(destroy: true);
jobIdToVisual.Remove(jobId);
}
// ----- Helpers ----------------------------------------------------
private static PlayerSlot OwnerToSlot(ulong clientId)
{
// STUB — replaced when MatchState lands. Same mapping as elsewhere.
byte slotByte = (byte)(clientId + 1);
if (slotByte < 1 || slotByte > 9) return PlayerSlot.None;
return (PlayerSlot)slotByte;
}
private static Vector2Int ResolveFootprint(int towerTypeId)
{
var def = TowerPlacementManager.GetDefinition(towerTypeId);
return def != null ? def.FootprintSize : new Vector2Int(2, 2);
}
}
}