// Assets/_Project/Scripts/Gameplay/Builder.cs
using System.Collections.Generic;
using Unity.Netcode;
using UnityEngine;
using TD.Core;
using TD.Towers;
namespace TD.Gameplay
{
///
/// 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.
///
///
/// Pure visual avatar. 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.
///
/// Terrain-aware height. Each frame the server casts a ray straight
/// down from the builder against and sets Y to
/// hit.point.y + heightOffset. Falls back to
/// if the ray misses. Towers must not be on the terrain layer.
///
/// Range gating. is the public
/// query that TowerPlacementManager uses to validate placement requests.
/// Range is measured center-of-builder to nearest-point-of-footprint in world units.
///
/// Build queue. Each Builder owns a of
/// . The server appends jobs from
/// TowerPlacementManager.ProcessRequest via .
/// 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 TowerInstance
/// and the head job is removed. Cancellation drops every job in the queue and
/// refunds 100% per the design doc.
///
/// Static registry. Like , builders
/// register themselves in a static dictionary keyed by OwnerClientId on
/// spawn so server gameplay code can find a player's builder without scene
/// traversal.
///
[RequireComponent(typeof(NetworkObject))]
public class Builder : NetworkBehaviour
{
// ----- Static registry --------------------------------------------
private static readonly Dictionary s_byClientId
= new Dictionary();
///
/// Returns the Builder owned by the given client, or null if none is currently spawned.
/// Safe to call on server or client.
///
public static Builder GetForClient(ulong clientId)
{
s_byClientId.TryGetValue(clientId, out var builder);
return builder;
}
///
/// Convenience: the local client's own builder. Returns null on a dedicated server
/// or before the local player has spawned.
///
public static Builder Local
{
get
{
var nm = NetworkManager.Singleton;
if (nm == null || !nm.IsClient) return null;
return GetForClient(nm.LocalClientId);
}
}
// ----- Inspector --------------------------------------------------
[Header("Settings")]
[Tooltip("Shared tunable values for all builders. Create via TD/Builder Settings.")]
[SerializeField] private BuilderSettings settings;
[Header("Build queue")]
[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 SkinnedMeshRenderer[] tintedRenderers;
[Header("Animation")]
[Tooltip("Animator on the character model child. Drives IsMoving and IsConstructing " +
"bool parameters each frame on all clients.")]
[SerializeField] private Animator animator;
// ----- Networked state --------------------------------------------
// Server-authoritative target position. The server moves the builder toward this
// each frame; clients render the builder smoothly via NetworkTransform's interpolation.
// We replicate the target (not the live position) so the server's intent is visible
// to clients, but the rendered position is whatever NetworkTransform interpolates to.
private readonly NetworkVariable targetPosition = new NetworkVariable(
value: Vector3.zero,
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 jobs;
///
/// 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.
///
public NetworkList 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 jobIdToVisual
= new Dictionary();
// 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 -------------------------------------------
/// The builder's current world position (its actual transform position,
/// not the target).
public Vector3 CurrentPosition => transform.position;
/// The builder's target position. Server moves toward this each frame.
public Vector3 TargetPosition => targetPosition.Value;
/// True if the builder has arrived at its target (within
/// ).
public bool IsAtTarget =>
Vector3.SqrMagnitude(transform.position - targetPosition.Value)
< settings.arrivalThreshold * settings.arrivalThreshold;
/// Build range in world units.
public float BuildRange => settings.buildRange;
/// Maximum jobs allowed in the queue.
public int MaxQueueDepth => settings.maxQueueDepth;
/// True if a tile is currently part of any queued or constructing job.
///
/// Used by TowerPlacementManager 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 .
///
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();
}
public override void OnNetworkSpawn()
{
s_byClientId[OwnerClientId] = this;
if (IsServer)
{
// 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 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
// would throw on this MonoBehaviour at scene load.
private MaterialPropertyBlock colorPropertyBlock;
// Both _Color (legacy Standard) and _BaseColor (URP Lit) — writing both lets the
// tint apply regardless of which shader the prefab uses. Unknown property writes
// are silently ignored by the shader.
private static readonly int ColorPropertyId = Shader.PropertyToID("_Color");
private static readonly int BaseColorPropertyId = Shader.PropertyToID("_BaseColor");
private void ApplyOwnerColor()
{
// Owner color comes from the slot mapping. Same stub mapping as elsewhere —
// replaced when MatchState lands.
byte slotByte = (byte)(OwnerClientId + 1);
PlayerSlot slot = (slotByte >= 1 && slotByte <= 9) ? (PlayerSlot)slotByte : PlayerSlot.None;
Color c = PlayerColors.Get(slot);
c.a = 1f;
colorPropertyBlock ??= new MaterialPropertyBlock();
colorPropertyBlock.SetColor(ColorPropertyId, c);
colorPropertyBlock.SetColor(BaseColorPropertyId, c);
// 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);
}
}
// ----- Animation parameter hashes (cached to avoid per-frame string lookup) ---
private static readonly int IsMovingHash = Animator.StringToHash("IsMoving");
private static readonly int IsConstructingHash = Animator.StringToHash("IsConstructing");
// ----- Per-frame update -------------------------------------------
private void Update()
{
if (IsServer)
{
ServerDriveQueue();
ServerStepMovement();
}
UpdateAnimatorState();
}
private void UpdateAnimatorState()
{
if (animator == null) return;
Vector3 flatCurrent = new Vector3(transform.position.x, 0f, transform.position.z);
Vector3 flatTarget = new Vector3(targetPosition.Value.x, 0f, targetPosition.Value.z);
bool isMoving = Vector3.SqrMagnitude(flatCurrent - flatTarget)
> settings.arrivalThreshold * settings.arrivalThreshold;
bool isConstructing = jobs.Count > 0 && jobs[0].Stage == BuildStage.Constructing;
animator.SetBool(IsMovingHash, isMoving);
animator.SetBool(IsConstructingHash, isConstructing);
}
private void ServerStepMovement()
{
Vector3 current = transform.position;
Vector3 target = targetPosition.Value;
// Flatten to XZ for distance/movement calculations; Y is driven by terrain raycast.
Vector3 currentXZ = new Vector3(current.x, 0f, current.z);
Vector3 targetXZ = new Vector3(target.x, 0f, target.z);
Vector3 newXZ;
bool moving;
if (Vector3.SqrMagnitude(currentXZ - targetXZ) <= settings.arrivalThreshold * settings.arrivalThreshold)
{
newXZ = targetXZ;
moving = false;
}
else
{
newXZ = Vector3.MoveTowards(currentXZ, targetXZ, settings.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 + settings.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, settings.turnRateDegPerSec * Time.deltaTime);
}
}
}
///
/// Casts a ray straight down at and returns the hit Y, or
/// if nothing was hit on the
/// terrain layer.
///
private float SampleTerrainY(Vector3 xzPos)
{
// Ray origin: high above the map. terrainRaycastMaxDistance defines how far to cast.
Vector3 origin = new Vector3(xzPos.x, settings.terrainRaycastMaxDistance, xzPos.z);
if (Physics.Raycast(origin, Vector3.down, out RaycastHit hit,
settings.terrainRaycastMaxDistance * 2f, settings.terrainLayerMask))
{
return hit.point.y;
}
// Fallback: builder hovers above the buildable plane.
return GridCoordinates.BUILDABLE_PLANE_Y;
}
// ----- Server move API --------------------------------------------
///
/// Server-side entry point: directly sets the move target. Called by the input
/// controller's Rpc handler after validation. Out-of-map positions are rejected.
///
public void ServerSetMoveTarget(Vector3 worldPos)
{
if (!IsServer)
{
Debug.LogError("[Builder] ServerSetMoveTarget called on a client.");
return;
}
// Clamp to map area: convert XZ to a tile and check IsInMap.
var loader = LevelLoader.Instance;
if (loader != null && loader.IsLoaded)
{
Vector2Int tile = GridCoordinates.WorldToGrid(worldPos);
if (!loader.IsInMap(tile))
{
// Out-of-map move requests are rejected silently.
return;
}
}
// Y is overwritten by terrain raycast each Update; we only honor X and Z here.
targetPosition.Value = new Vector3(worldPos.x, 0f, worldPos.z);
}
// ----- Owner-only move RPC ----------------------------------------
///
/// Owner-only Rpc: a client requests their builder move to a world position.
/// Server validates (in-map check) and applies via .
///
[Rpc(SendTo.Server, InvokePermission = RpcInvokePermission.Owner)]
public void RequestMoveRpc(Vector3 worldPos)
{
ServerSetMoveTarget(worldPos);
}
// ----- Range query (used by Builder's own queue driver) ----------
///
/// True if the tower with the given anchor and footprint size is within build range
/// of this builder's CURRENT position. Range is measured from builder center to the
/// nearest point of the tower footprint, in world units.
///
///
/// Used by 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.
///
/// "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."
///
public bool IsTileWithinBuildRange(Vector2Int anchor, Vector2Int footprintSize)
{
Vector3 builderXZ = new Vector3(transform.position.x, 0f, transform.position.z);
// Find the point on the footprint rectangle nearest to the builder.
float minX = anchor.x * GridCoordinates.TILE_SIZE - GridCoordinates.TILE_SIZE * 0.5f;
float maxX = (anchor.x + footprintSize.x - 1) * GridCoordinates.TILE_SIZE
+ GridCoordinates.TILE_SIZE * 0.5f;
float minZ = anchor.y * GridCoordinates.TILE_SIZE - GridCoordinates.TILE_SIZE * 0.5f;
float maxZ = (anchor.y + footprintSize.y - 1) * GridCoordinates.TILE_SIZE
+ GridCoordinates.TILE_SIZE * 0.5f;
float nearestX = Mathf.Clamp(builderXZ.x, minX, maxX);
float nearestZ = Mathf.Clamp(builderXZ.z, minZ, maxZ);
Vector3 nearestPoint = new Vector3(nearestX, 0f, nearestZ);
return Vector3.SqrMagnitude(builderXZ - nearestPoint)
<= settings.buildRange * settings.buildRange;
}
// ===================================================================
// BUILD QUEUE (server-side)
// ===================================================================
///
/// 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.
///
public bool ServerEnqueueJob(Vector2Int anchor, int towerTypeId, int goldSpent,
out ulong jobId)
{
jobId = 0;
if (!IsServer) return false;
if (jobs.Count >= settings.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();
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();
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();
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 -------------------------------------------
///
/// 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.
///
[Rpc(SendTo.Server, InvokePermission = RpcInvokePermission.Owner)]
public void RequestCancelAllJobsRpc()
{
if (!IsServer) return;
ServerCancelAllJobs();
}
///
/// Server-side: cancel every job in the queue. Public so other server code
/// (disconnect cleanup, future "level reset" events) can invoke it directly.
///
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)
// ===================================================================
///
/// Owner-only Rpc: the player issued a "move to here" command (right-click
/// on empty buildable plane). Server applies the new move target and:
///
/// - 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.
/// - If head is Queued → refund the entire queue (no progress to preserve).
/// - Empty queue → just a move command. No queue effects.
///
///
[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);
}
///
/// 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.
///
[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();
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();
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);
}
}
}