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