UnityTowerDefense/Assets/_Project/Scripts/Gameplay/Builder.cs
2026-05-21 23:36:19 -07:00

1069 lines
No EOL
49 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Assets/_Project/Scripts/Gameplay/Builder.cs
using System.Collections.Generic;
using Unity.Netcode;
using UnityEngine;
using TD.Core;
using TD.Towers;
using TD.UI.Minimap;
namespace TD.Gameplay
{
/// <summary>
/// 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
/// (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 <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.
/// Range is measured center-of-builder to nearest-point-of-footprint in world units.</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, IMinimapEntity, ISelectable
{
// ----- Static registry --------------------------------------------
private static readonly Dictionary<ulong, Builder> s_byClientId
= new Dictionary<ulong, Builder>();
/// <summary>
/// Returns the Builder owned by the given client, or null if none is currently spawned.
/// Safe to call on server or client.
/// </summary>
public static Builder GetForClient(ulong clientId)
{
s_byClientId.TryGetValue(clientId, out var builder);
return builder;
}
/// <summary>
/// Convenience: the local client's own builder. Returns null on a dedicated server
/// or before the local player has spawned.
/// </summary>
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<Vector3> targetPosition = new NetworkVariable<Vector3>(
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<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,
/// not the target).</summary>
public Vector3 CurrentPosition => transform.position;
/// <summary>The builder's target position. Server moves toward this each frame.</summary>
public Vector3 TargetPosition => targetPosition.Value;
/// <summary>True if the builder has arrived at its target (within
/// <see cref="BuilderSettings.arrivalThreshold"/>).</summary>
public bool IsAtTarget =>
Vector3.SqrMagnitude(transform.position - targetPosition.Value)
< settings.arrivalThreshold * settings.arrivalThreshold;
/// <summary>Build range in world units.</summary>
public float BuildRange => settings.buildRange;
/// <summary>Maximum jobs allowed in the queue.</summary>
public int MaxQueueDepth => settings.maxQueueDepth;
// ----- ISelectable ------------------------------------------------
/// <summary>Display name shown in the HUD portrait. Stub until MatchState provides player names.</summary>
public string DisplayName
{
get
{
PlayerSlot slot = OwnerToSlot(OwnerClientId);
int n = (int)slot;
return n >= 1 && n <= 9 ? $"Builder (P{n})" : "Builder";
}
}
public SelectableKind Kind => SelectableKind.Builder;
public Transform SelectionTransform => transform;
// Builders are point units; the visible silhouette is roughly 1 unit wide.
// 0.6 puts a small visible gap between the silhouette and the ring.
// Bump up if BuilderSettings later exposes a width or selection radius.
public float SelectionRadius => 0.6f;
/// <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;
MinimapEntityRegistry.Register(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);
MinimapEntityRegistry.Deregister(this);
// Clear local selection if THIS builder was selected. Without this,
// SelectionState (and any subscriber holding our reference — HUD,
// SelectionVisualizer) keeps pointing at a soon-to-be-destroyed Unity
// object and throws MissingReferenceException on the next access.
// Local-only state, so safe to touch from any peer.
if (SelectionState.Instance != null && SelectionState.Instance.IsSelected(this))
SelectionState.Instance.Clear();
// 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.)
// ----- IMinimapEntity ---------------------------------------------
//
// Position is read live (NetworkTransform interpolation handles remote-client smoothing).
// Color comes from PlayerMatchState slot assignment.
Vector3 IMinimapEntity.WorldPosition => transform.position;
Color IMinimapEntity.MinimapColor
=> PlayerColors.Get(PlayerMatchState.SlotForClient(OwnerClientId));
MinimapIconKind IMinimapEntity.IconKind => MinimapIconKind.Builder;
// Diameter of the builder dot in world units. The view enforces a pixel-size floor so
// builders remain visible at full zoom-out regardless of this value.
float IMinimapEntity.MinimapWorldSize => 0.6f;
// ----- 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()
{
Color c = PlayerColors.Get(PlayerMatchState.SlotForClient(OwnerClientId));
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);
}
}
}
/// <summary>
/// Casts a ray straight down at <paramref name="xzPos"/> and returns the hit Y, or
/// <see cref="GridCoordinates.BUILDABLE_PLANE_Y"/> if nothing was hit on the
/// terrain layer.
/// </summary>
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 --------------------------------------------
/// <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 rejected.
/// </summary>
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 ----------------------------------------
/// <summary>
/// Owner-only Rpc: a client requests their builder move to a world position.
/// Server validates (in-map check) and applies via <see cref="ServerSetMoveTarget"/>.
/// </summary>
[Rpc(SendTo.Server, InvokePermission = RpcInvokePermission.Owner)]
public void RequestMoveRpc(Vector3 worldPos)
{
ServerSetMoveTarget(worldPos);
}
// ----- 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
/// of this builder's CURRENT position. Range is measured from builder center to the
/// nearest point of the tower footprint, in world units.
/// </summary>
/// <remarks>
/// <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)
{
Vector3 builderXZ = new Vector3(transform.position.x, 0f, transform.position.z);
// Find the point on the footprint rectangle nearest to the builder. Tile N spans
// world [N, N+1] (edge-aligned), so a footprint at anchor (ax, ay) with size (sx, sy)
// spans world [ax, ax+sx] × [ay, ay+sy].
float minX = anchor.x * GridCoordinates.TILE_SIZE;
float maxX = (anchor.x + footprintSize.x) * GridCoordinates.TILE_SIZE;
float minZ = anchor.y * GridCoordinates.TILE_SIZE;
float maxZ = (anchor.y + footprintSize.y) * GridCoordinates.TILE_SIZE;
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)
// ===================================================================
/// <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 >= 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<BuildSiteVisual>();
if (visual == null)
{
Debug.LogError("[Builder] buildSiteVisualPrefab is missing a " +
"BuildSiteVisual component.");
Destroy(go);
return;
}
PlayerSlot owner = PlayerMatchState.SlotForClient(OwnerClientId);
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);
}
/// <summary>
/// Server-only: cancel the job in this builder's queue whose anchor matches
/// <paramref name="targetAnchor"/>. Refunds gold, frees the footprint tiles
/// (restoring walkability if the stage was blocking), and despawns the
/// build-site visual. Returns true if a matching job was found and
/// cancelled. Used by <see cref="BuildSiteVisual.RequestCancelRpc"/> so the
/// player can cancel a specific in-progress build from the HUD without
/// affecting other queued/constructing builds.
/// </summary>
public bool ServerCancelJobAtAnchor(Vector2Int targetAnchor)
{
if (!IsServer) return false;
for (int i = 0; i < jobs.Count; i++)
{
if (jobs[i].Anchor == targetAnchor)
{
ServerCancelJobAt(i);
return true;
}
}
return false;
}
// Cancels the job at index i. Used for cancel-all and targeted cancel paths.
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)
=> PlayerMatchState.SlotForClient(clientId);
private static Vector2Int ResolveFootprint(int towerTypeId)
{
var def = TowerPlacementManager.GetDefinition(towerTypeId);
return def != null ? def.FootprintSize : new Vector2Int(2, 2);
}
}
}