UnityTowerDefense/Assets/_Project/Scripts/Gameplay/Builder.cs

1021 lines
No EOL
47 KiB
C#
Raw 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;
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
{
// ----- 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("Movement")]
[Tooltip("Speed at which the builder moves toward its target position, in world " +
"units per second.")]
[SerializeField] private float moveSpeed = 8f;
[Tooltip("Distance below which the builder is considered to have arrived at its " +
"target. Smaller = more precise but more jitter; larger = less precise " +
"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.")]
[SerializeField] private float heightOffset = 2f;
[Tooltip("Maximum distance to cast downward when sampling terrain height. Should " +
"exceed your map's vertical range.")]
[SerializeField] private float terrainRaycastMaxDistance = 100f;
[Tooltip("Physics layer mask used for terrain height sampling. Towers MUST NOT be " +
"on this layer — only ground geometry. Falls back to the buildable plane Y " +
"if no terrain hit.")]
[SerializeField] private LayerMask terrainLayerMask;
[Header("Build range")]
[Tooltip("Maximum distance from the builder's center to a tower's anchor tile center " +
"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
// 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="arrivalThreshold"/>).</summary>
public bool IsAtTarget =>
Vector3.SqrMagnitude(transform.position - targetPosition.Value)
< arrivalThreshold * arrivalThreshold;
/// <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;
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);
}
}
// ----- Per-frame movement (server only) ---------------------------
private void Update()
{
if (!IsServer) return;
// 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;
// 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) <= 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>
/// 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, terrainRaycastMaxDistance, xzPos.z);
if (Physics.Raycast(origin, Vector3.down, out RaycastHit hit,
terrainRaycastMaxDistance * 2f, 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.
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)
<= 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);
}
}
}