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

548 lines
No EOL
25 KiB
C#

// Assets/_Project/Scripts/Gameplay/BuildSiteVisual.cs
using Unity.Netcode;
using UnityEngine;
using TD.Core;
using TD.Towers;
using TD.VFX;
namespace TD.Gameplay
{
/// <summary>
/// Visual representation of an in-flight <see cref="BuildJob"/>: a static, non-growing
/// site marker (color-tinted by stage) plus the ground targeting reticle
/// (<see cref="TowerDropReticle"/>) that shrinks as construction progresses. One
/// NetworkObject per active job. Despawned when the job is cancelled or when the real
/// <see cref="TowerInstance"/> takes its place at construction-complete, at which point
/// <c>TowerLandingVisual</c> plays the tower's fall-from-sky arrival.
/// </summary>
/// <remarks>
/// <para><b>Why a separate prefab from TowerInstance.</b> The build-site visual
/// has different rendering (translucent green site marker), no combat,
/// no grid-stamping (the Builder owns those state transitions), and a much
/// shorter lifecycle. Sharing a prefab with TowerInstance would mean adding
/// "am I real or a ghost" branching to every TowerInstance code path. Two
/// prefabs, two responsibilities.</para>
///
/// <para><b>Stage replication.</b> Stage and ConstructionStartServerTime are
/// replicated as NetworkVariables so all peers compute identical visuals locally
/// from <c>NetworkManager.ServerTime.TimeAsFloat</c>. Only the server writes;
/// clients read.</para>
///
/// <para><b>Visual model.</b> The site marker's material swaps by stage (queued/
/// constructing/paused) but never grows or changes shape — construction progress is
/// communicated entirely by the reticle shrinking, not by the marker itself.</para>
///
/// <para><b>No grid stamping here.</b> Walkability and occupancy stamps are
/// driven by the Builder (queue-time stamps occupied=true / walkable=true,
/// construction-start stamps walkable=false, completion is unchanged because
/// TowerInstance takes over). Adding stamping here would create double-write
/// races with the Builder. See lessons in the project context doc.</para>
/// </remarks>
[RequireComponent(typeof(NetworkObject))]
public class BuildSiteVisual : NetworkBehaviour, ISelectable
{
// ----- Inspector --------------------------------------------------
[Header("Visuals")]
[Tooltip("Renderers tinted with the queued-ghost color. Typically every " +
"MeshRenderer on the prefab. Auto-populated from children if empty.")]
[SerializeField] private MeshRenderer[] tintedRenderers;
[Tooltip("Material applied while the job is Queued (translucent green).")]
[SerializeField] private Material queuedMaterial;
[Tooltip("Material applied while the job is Constructing (opaque, tinted by owner).")]
[SerializeField] private Material constructingMaterial;
[Tooltip("Material applied while the job is Paused. Visually distinct from " +
"Constructing so players can tell at a glance which builds need a builder. " +
"Suggested: muted/grey-tinted variant of the constructing material.")]
[SerializeField] private Material pausedMaterial;
[Header("Drop reticle FX")]
[Tooltip("Optional targeting-reticle effect (ground decal + beam + motes) shown for the " +
"life of this build. Spawned as a child on every peer and fed construction " +
"progress each frame so it shrinks as the tower builds, then destroyed with " +
"this visual at completion/cancel. Leave empty to build with no reticle.")]
[SerializeField] private TowerDropReticle dropReticlePrefab;
// ----- Networked state --------------------------------------------
// Replicated owner slot for color tinting. Mirrors TowerInstance.
private readonly NetworkVariable<PlayerSlot> ownerSlot =
new NetworkVariable<PlayerSlot>(
PlayerSlot.None,
readPerm: NetworkVariableReadPermission.Everyone,
writePerm: NetworkVariableWritePermission.Server);
// Anchor tile (SW corner of the footprint, world-tile coords). Replicated so
// shelved visuals are self-describing — when a player clicks one to resume,
// the server can rebuild a BuildJob from these fields without consulting any
// separate registry.
private readonly NetworkVariable<Vector2Int> anchor =
new NetworkVariable<Vector2Int>(
Vector2Int.zero,
readPerm: NetworkVariableReadPermission.Everyone,
writePerm: NetworkVariableWritePermission.Server);
// Tower type ID (index into TowerPlacementManager.towerDefinitions[]). Replicated
// for the same self-describing reason as Anchor.
private readonly NetworkVariable<int> towerTypeId =
new NetworkVariable<int>(
0,
readPerm: NetworkVariableReadPermission.Everyone,
writePerm: NetworkVariableWritePermission.Server);
// Gold the player paid to queue this build. Carried on the visual so resume
// can refund the correct amount if the player later cancels. (Cancellation
// gesture deferred to HUD; this field is the data dependency.)
private readonly NetworkVariable<int> goldSpent =
new NetworkVariable<int>(
0,
readPerm: NetworkVariableReadPermission.Everyone,
writePerm: NetworkVariableWritePermission.Server);
// True iff this build site has been "shelved" — removed from its owning Builder's
// queue and now a standalone object waiting to be resumed via right-click.
// Shelved visuals are responsible for their own grid-state cleanup on despawn
// (since the Builder no longer tracks them in jobIdToVisual).
private readonly NetworkVariable<bool> isShelved =
new NetworkVariable<bool>(
false,
readPerm: NetworkVariableReadPermission.Everyone,
writePerm: NetworkVariableWritePermission.Server);
// Current stage. Drives the site marker's material/tint swap.
private readonly NetworkVariable<BuildStage> currentStage =
new NetworkVariable<BuildStage>(
BuildStage.Queued,
readPerm: NetworkVariableReadPermission.Everyone,
writePerm: NetworkVariableWritePermission.Server);
// Server time at which the current Constructing run began. -1 while Queued or Paused.
// Used together with accumulatedConstructionTime to compute total progress:
// total = (now - constructionStartServerTime) + accumulatedConstructionTime.
private readonly NetworkVariable<float> constructionStartServerTime =
new NetworkVariable<float>(
-1f,
readPerm: NetworkVariableReadPermission.Everyone,
writePerm: NetworkVariableWritePermission.Server);
// Construction time accumulated across previous Constructing runs (for pause/resume).
// 0 for jobs that have never been paused. At pause, set to the elapsed time of the
// current run added to whatever was already accumulated.
private readonly NetworkVariable<float> accumulatedConstructionTime =
new NetworkVariable<float>(
0f,
readPerm: NetworkVariableReadPermission.Everyone,
writePerm: NetworkVariableWritePermission.Server);
// BuildTime is replicated rather than looked up so clients don't need to
// resolve the TowerDefinition before they can render progress.
// (Resolution can race with the first stage update otherwise.)
private readonly NetworkVariable<float> buildTime =
new NetworkVariable<float>(
0f,
readPerm: NetworkVariableReadPermission.Everyone,
writePerm: NetworkVariableWritePermission.Server);
// ----- Public accessors (read by the input controller on click) --
// NOTE: ownership identity comes from the inherited NetworkBehaviour.OwnerClientId,
// which is correct because we use SpawnWithOwnership in Builder.SpawnBuildSiteVisual.
// No redundant NetworkVariable for ownership.
/// <summary>The current build stage. Read by clients for click-target tests.</summary>
public BuildStage CurrentStage => currentStage.Value;
/// <summary>True iff this visual has been shelved (no longer in its Builder's queue).</summary>
public bool IsShelved => isShelved.Value;
/// <summary>Footprint anchor (SW corner). Used by server to rebuild a BuildJob on resume.</summary>
public Vector2Int Anchor => anchor.Value;
/// <summary>Tower type ID. Used by server to rebuild a BuildJob on resume.</summary>
public int TowerTypeId => towerTypeId.Value;
/// <summary>Gold paid for this build. Used by server to rebuild a BuildJob on resume.</summary>
public int GoldSpent => goldSpent.Value;
/// <summary>Accumulated construction time (across pause/resume cycles). Used on resume.</summary>
public float AccumulatedConstructionTime => accumulatedConstructionTime.Value;
/// <summary>
/// Returns [0,1] normalized construction progress. Safe to call on any client.
/// Returns 0 while Queued; freezes at accumulated fraction while Paused.
/// </summary>
public float ComputeProgressNormalized()
{
float bt = buildTime.Value;
if (bt <= 0f) return 0f;
float currentRunElapsed = constructionStartServerTime.Value > 0f
? (float)NetworkManager.Singleton.ServerTime.Time - constructionStartServerTime.Value
: 0f;
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 PlayerSlot pendingOwner = PlayerSlot.None;
private float pendingBuildTime;
private Vector2Int pendingAnchor;
private int pendingTowerTypeId;
private int pendingGoldSpent;
private bool hasPendingInit;
// Spawned child targeting-reticle effect (local, all peers). Fed progress in Update;
// destroyed automatically with this NetworkObject at completion/cancel.
private TowerDropReticle dropReticleInstance;
// ----- Lifecycle --------------------------------------------------
/// <summary>
/// Server-only: stores the data that <see cref="OnNetworkSpawn"/> will write
/// into NetworkVariables. Must be called between Instantiate and Spawn().
/// </summary>
/// <remarks>
/// Owner identity is conveyed through NGO ownership (via SpawnWithOwnership),
/// not through this method — see <see cref="NetworkBehaviour.OwnerClientId"/>.
/// </remarks>
public void InitializeServer(TowerDefinition def, PlayerSlot owner,
Vector2Int anchorTile, int towerTypeIdValue,
int goldSpentValue)
{
var nm = NetworkManager.Singleton;
if (nm == null || !nm.IsServer)
{
Debug.LogError("[BuildSiteVisual] InitializeServer called when not running as server.");
return;
}
pendingOwner = owner;
pendingBuildTime = def != null ? def.BuildTime : 0f;
pendingAnchor = anchorTile;
pendingTowerTypeId = towerTypeIdValue;
pendingGoldSpent = goldSpentValue;
hasPendingInit = true;
}
public override void OnNetworkSpawn()
{
// Auto-populate tinted renderers if not configured in the inspector.
if (tintedRenderers == null || tintedRenderers.Length == 0)
tintedRenderers = GetComponentsInChildren<MeshRenderer>();
// Server: now that the NetworkObject is spawned, write the pending init
// values into NetworkVariables. NGO captures these into the initial sync
// message so clients see correct values on their first OnNetworkSpawn.
if (IsServer && hasPendingInit)
{
ownerSlot.Value = pendingOwner;
buildTime.Value = pendingBuildTime;
anchor.Value = pendingAnchor;
towerTypeId.Value = pendingTowerTypeId;
goldSpent.Value = pendingGoldSpent;
hasPendingInit = false;
}
// Spawn the targeting-reticle FX as a local child. It reads this build site's
// replicated progress (fed in Update) and dies with us at completion/cancel.
if (dropReticlePrefab != null)
{
dropReticleInstance = Instantiate(dropReticlePrefab, transform);
dropReticleInstance.transform.localPosition = Vector3.zero;
dropReticleInstance.transform.localRotation = Quaternion.identity;
}
// Subscribe to value changes so visual updates are reactive.
currentStage.OnValueChanged += HandleStageChanged;
// Apply initial visual state based on the (now-replicated) values.
ApplyStageMaterialAndTint(currentStage.Value);
}
public override void OnNetworkDespawn()
{
currentStage.OnValueChanged -= HandleStageChanged;
// Server-only cleanup: if this visual was shelved at the time it was
// despawned (e.g., the player disconnected while a tower was shelved),
// restore walkability and occupancy on the footprint. Non-shelved
// visuals are owned by their Builder, which handles cleanup via
// jobIdToVisual; we mustn't double-free in that case.
if (IsServer && isShelved.Value)
{
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
// footprint. Called when a shelved visual is despawned without going through a
// normal "resume → cancel" cycle (e.g., player disconnect cleanup).
private void RestoreFootprintGridState()
{
var loader = LevelLoader.Instance;
if (loader == null || !loader.IsLoaded) return;
var def = TowerPlacementManager.GetDefinition(towerTypeId.Value);
if (def == null) return;
foreach (var tile in GridCoordinates.GetFootprintTiles(
anchor.Value, def.FootprintSize))
{
loader.SetOccupied(tile, false);
loader.SetWalkable(tile, true);
}
}
// ----- Per-frame visual update (all peers) ------------------------
private void Update()
{
// Drive the targeting reticle on every peer, every stage: 0 while Queued
// (full-size reticle, awaiting a builder), shrinking while Constructing, frozen
// while Paused. This is the only per-frame visual effect on the build site —
// nothing about the site's mesh grows or changes shape during construction.
if (dropReticleInstance != null)
dropReticleInstance.SetProgress(ComputeProgressNormalized());
}
// ----- Server API -------------------------------------------------
/// <summary>
/// Server-only: marks this visual as shelved. The visual remains in the world
/// but no longer belongs to the owning Builder's queue. Stage stays Paused;
/// this method just flips the IsShelved flag so the visual takes responsibility
/// for its own grid-state cleanup on despawn.
/// </summary>
public void ServerMarkShelved()
{
if (!IsServer) return;
isShelved.Value = true;
}
/// <summary>
/// Server-only: marks this visual as unshelved (taken back into a Builder's queue).
/// Called when the player right-clicks a shelved visual to resume construction.
/// Grid-state cleanup is once again the Builder's responsibility via jobIdToVisual.
/// </summary>
public void ServerMarkUnshelved()
{
if (!IsServer) return;
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
/// for setting <paramref name="accumulatedTimeBeforeThisRun"/> from the BuildJob's
/// AccumulatedConstructionTime — non-zero values mean this is a resume.
/// </summary>
public void ServerBeginConstructing(float accumulatedTimeBeforeThisRun)
{
if (!IsServer) return;
if (currentStage.Value == BuildStage.Constructing) return;
accumulatedConstructionTime.Value = accumulatedTimeBeforeThisRun;
constructionStartServerTime.Value =
(float)NetworkManager.Singleton.ServerTime.Time;
currentStage.Value = BuildStage.Constructing;
}
/// <summary>
/// Server-only: transitions Constructing → Paused AND writes the new
/// accumulated construction time (freezing the reticle's shrink progress at that
/// point). After this returns, the visual is fully self-describing — it carries
/// enough state to be shelved and later resumed.
/// </summary>
public void ServerPauseAndPersistAccumulated(float totalAccumulated)
{
if (!IsServer) return;
if (currentStage.Value != BuildStage.Constructing) return;
accumulatedConstructionTime.Value = totalAccumulated;
// Reset constructionStartServerTime to -1 so any future progress reads
// know there's no active timer running.
constructionStartServerTime.Value = -1f;
currentStage.Value = BuildStage.Paused;
}
// ----- Visual state machine ---------------------------------------
private void HandleStageChanged(BuildStage previous, BuildStage current)
{
ApplyStageMaterialAndTint(current);
}
// Applies the stage's material + owner tint to the site marker's renderers.
// Paused falls back to the constructing material if no paused material is assigned.
private void ApplyStageMaterialAndTint(BuildStage stage)
{
switch (stage)
{
case BuildStage.Queued:
SwapMaterial(queuedMaterial); // green ghost — no owner tint
break;
case BuildStage.Constructing:
SwapMaterial(constructingMaterial);
ApplyOwnerTint();
break;
case BuildStage.Paused:
SwapMaterial(pausedMaterial != null ? pausedMaterial : constructingMaterial);
ApplyOwnerTint();
break;
}
}
// ----- Material handling ------------------------------------------
private void SwapMaterial(Material mat)
{
if (mat == null || tintedRenderers == null) return;
foreach (var rend in tintedRenderers)
{
if (rend == null) continue;
rend.sharedMaterial = mat;
}
}
// Reused per-instance to avoid GC. Lazy because Unity disallows
// construction in field initializers.
private MaterialPropertyBlock colorPropertyBlock;
private static readonly int ColorPropertyId = Shader.PropertyToID("_Color");
private static readonly int BaseColorPropertyId = Shader.PropertyToID("_BaseColor");
private void ApplyOwnerTint()
{
if (tintedRenderers == null) return;
Color c = PlayerColors.Get(ownerSlot.Value);
c.a = 1f;
colorPropertyBlock ??= new MaterialPropertyBlock();
colorPropertyBlock.SetColor(ColorPropertyId, c);
colorPropertyBlock.SetColor(BaseColorPropertyId, c);
foreach (var rend in tintedRenderers)
{
if (rend == null) continue;
rend.SetPropertyBlock(colorPropertyBlock);
}
}
}
}