// 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 { /// /// 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, IMinimapEntity, ISelectable { // ----- 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; // ----- ISelectable ------------------------------------------------ /// Display name shown in the HUD portrait. Stub until MatchState provides player names. 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; /// 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; 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); } } } /// /// 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; } PlayerSlot owner = PlayerMatchState.SlotForClient(OwnerClientId); 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); } /// /// Server-only: cancel the job in this builder's queue whose anchor matches /// . 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 so the /// player can cancel a specific in-progress build from the HUD without /// affecting other queued/constructing builds. /// 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); } } }