Major updates to the HUD and selectable objects

This commit is contained in:
Matt F 2026-05-11 23:57:35 -07:00
parent 5bc757b385
commit c100db52e5
23 changed files with 1615 additions and 614 deletions

View file

@ -4,7 +4,6 @@ using Unity.Netcode;
using UnityEngine;
using TD.Core;
using TD.Towers;
using TD.UI;
namespace TD.Gameplay
{
@ -40,7 +39,7 @@ namespace TD.Gameplay
/// races with the Builder. See lessons in the project context doc.</para>
/// </remarks>
[RequireComponent(typeof(NetworkObject))]
public class BuildSiteVisual : NetworkBehaviour
public class BuildSiteVisual : NetworkBehaviour, ISelectable
{
// ----- Inspector --------------------------------------------------
@ -201,6 +200,36 @@ namespace TD.Gameplay
return Mathf.Clamp01((currentRunElapsed + accumulatedConstructionTime.Value) / bt);
}
// ----- ISelectable ------------------------------------------------
/// <summary>Name shown in the HUD portrait. Pulls from the resolved
/// TowerDefinition; falls back to a generic label if the registry hasn't
/// resolved the def yet on this client.</summary>
public string DisplayName
{
get
{
var def = TowerPlacementManager.GetDefinition(towerTypeId.Value);
return def != null ? def.DisplayName : "Tower (building)";
}
}
public SelectableKind Kind => SelectableKind.BuildSite;
public Transform SelectionTransform => transform;
// Match the TowerInstance's radius formula so the selection ring is the
// same size before vs. after the build completes — no visual pop on transition.
public float SelectionRadius
{
get
{
var def = TowerPlacementManager.GetDefinition(towerTypeId.Value);
Vector2Int fp = def != null ? def.FootprintSize : new Vector2Int(1, 1);
return Mathf.Max(fp.x, fp.y) * 0.5f * GridCoordinates.TILE_SIZE + 0.5f;
}
}
// ----- Pre-spawn init data (server) -------------------------------
private string pendingDefName;
@ -266,12 +295,6 @@ namespace TD.Gameplay
// Apply initial visual state based on the (now-replicated) values.
ApplyStageVisual(currentStage.Value);
// Attach a local (non-networked) progress bar — each client creates its own.
// Destroyed automatically when this NetworkObject is despawned (it's a child).
var barHost = new GameObject("ProgressBar");
barHost.transform.SetParent(transform, false);
barHost.AddComponent<BuildProgressBar>().Initialize(this);
}
public override void OnNetworkDespawn()
@ -287,6 +310,15 @@ namespace TD.Gameplay
{
RestoreFootprintGridState();
}
// Local-only selection hygiene. If this visual was the active selection
// AND nothing has transferred selection to a replacement (e.g., a
// TowerInstance that just spawned at the same anchor), clear so HUD/
// visualizer don't hold a destroyed reference. The completion path
// spawns TowerInstance BEFORE this despawn arrives on the client; that
// spawn already transferred selection, so this check passes through.
if (SelectionState.Instance != null && SelectionState.Instance.IsSelected(this))
SelectionState.Instance.Clear();
}
// Server-only: restore walkability=true and occupancy=false on this build site's
@ -347,6 +379,82 @@ namespace TD.Gameplay
isShelved.Value = false;
}
// ----- Player-initiated cancel (HUD action) -----------------------
// Server-only guard against double cancel — Cancel RPC could arrive twice
// if the player clicks quickly before the visual finishes despawning.
private bool serverCancelled;
/// <summary>
/// Owner-only RPC. Routes to <see cref="ServerCancel"/>. Hooked up to the
/// Cancel action button in the HUD's action menu.
/// </summary>
[Rpc(SendTo.Server, InvokePermission = RpcInvokePermission.Owner)]
public void RequestCancelRpc()
{
ServerCancel();
}
/// <summary>
/// Server-only: cancel this build. Two paths depending on shelve state:
/// - <b>Shelved</b>: this visual is standalone (not in any builder's queue).
/// Refund gold ourselves and despawn — <see cref="OnNetworkDespawn"/>
/// restores the footprint's grid state because <c>isShelved</c> is true.
/// - <b>In a builder's queue</b>: route through the owning builder's
/// <see cref="Builder.ServerCancelJobAtAnchor"/>, which already handles
/// refund + grid restore + visual despawn through its job-cleanup path.
/// </summary>
public void ServerCancel()
{
if (!IsServer) return;
if (serverCancelled) return; // idempotent guard
serverCancelled = true;
if (isShelved.Value)
{
RefundOwner();
if (NetworkObject.IsSpawned)
NetworkObject.Despawn(destroy: true);
return;
}
// Queued / Constructing / Paused — owned by a builder's job queue.
var builder = Builder.GetForClient(OwnerClientId);
if (builder != null)
{
bool found = builder.ServerCancelJobAtAnchor(anchor.Value);
if (found) return;
// Race: visual exists but no matching job (e.g., job just completed
// and the visual is mid-despawn). Fall through to manual cleanup.
Debug.LogWarning($"[BuildSiteVisual] ServerCancel: no matching job " +
$"at anchor {anchor.Value} on builder for client " +
$"{OwnerClientId}. Performing manual refund+despawn.");
}
else
{
Debug.LogWarning("[BuildSiteVisual] ServerCancel: owning builder " +
"not found. Performing manual refund+despawn.");
}
// Manual fallback for the race / no-builder cases. Restore grid since
// the builder isn't going to do it for us.
RefundOwner();
if (currentStage.Value == BuildStage.Constructing
|| currentStage.Value == BuildStage.Paused)
{
RestoreFootprintGridState();
}
if (NetworkObject.IsSpawned)
NetworkObject.Despawn(destroy: true);
}
private void RefundOwner()
{
var goldManager = PlayerGoldManager.GetForClient(OwnerClientId);
if (goldManager == null) return;
goldManager.AwardGold(goldSpent.Value);
}
/// <summary>
/// Server-only: transitions the visual from Queued (or Paused) to Constructing
/// and records the server time for stage progression. Caller is responsible