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

478 lines
No EOL
22 KiB
C#

// Assets/_Project/Scripts/Gameplay/BuildSiteVisual.cs
using Unity.Collections;
using Unity.Netcode;
using UnityEngine;
using TD.Core;
using TD.Towers;
namespace TD.Gameplay
{
/// <summary>
/// Visual representation of an in-flight <see cref="BuildJob"/>: the green
/// queued ghost, and the staged construction animation (4 stages of growing
/// height for the testing cube). One NetworkObject per active job. Despawned
/// when the job is cancelled or when the real <see cref="TowerInstance"/>
/// takes its place at construction-complete.
/// </summary>
/// <remarks>
/// <para><b>Why a separate prefab from TowerInstance.</b> The build-site visual
/// has different rendering (transparent green or partial-height cube), 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 prefab inspector points at a re-tinted copy
/// of the tower's mesh (the same testing cube). At Stage = Queued, the visual
/// is a translucent green cube at full footprint scale but reduced Y. At
/// Stage = Constructing, the cube grows in 4 sub-stages over BuildTime.</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
{
// ----- 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("Transform that gets Y-scaled to represent construction progress. " +
"Typically the visual mesh's transform. The growth axis is local Y.")]
[SerializeField] private Transform scaleTarget;
[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("Construction stages")]
[Tooltip("Number of discrete growth stages while constructing. 4 matches the " +
"design doc (1/4 → 2/4 → 3/4 → 4/4 height).")]
[SerializeField] private int stageCount = 4;
[Tooltip("Y-scale applied to scaleTarget when Stage == Queued. Visually " +
"distinct from any constructing height so the queued ghost reads as " +
"'intent, not progress'.")]
[SerializeField] private float queuedYScale = 0.15f;
// ----- Networked state --------------------------------------------
// Replicated definition name so clients can resolve the source TowerDefinition
// (matches TowerInstance's pattern). Used for footprint size only — the visual
// prefab is the same regardless of tower type for now.
private readonly NetworkVariable<FixedString64Bytes> definitionName =
new NetworkVariable<FixedString64Bytes>(
default,
readPerm: NetworkVariableReadPermission.Everyone,
writePerm: NetworkVariableWritePermission.Server);
// 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 material swap and Y-scale animation.
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;
// ----- Pre-spawn init data (server) -------------------------------
private string pendingDefName;
private PlayerSlot pendingOwner = PlayerSlot.None;
private float pendingBuildTime;
private Vector2Int pendingAnchor;
private int pendingTowerTypeId;
private int pendingGoldSpent;
private bool hasPendingInit;
// ----- 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;
}
pendingDefName = def != null ? def.name : string.Empty;
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)
{
definitionName.Value = new FixedString64Bytes(pendingDefName ?? string.Empty);
ownerSlot.Value = pendingOwner;
buildTime.Value = pendingBuildTime;
anchor.Value = pendingAnchor;
towerTypeId.Value = pendingTowerTypeId;
goldSpent.Value = pendingGoldSpent;
hasPendingInit = false;
}
// Subscribe to value changes so visual updates are reactive.
currentStage.OnValueChanged += HandleStageChanged;
// Apply initial visual state based on the (now-replicated) values.
ApplyStageVisual(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();
}
}
// 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()
{
// While constructing, smoothly interpolate Y-scale through the stages
// based on server time. This runs on every peer (server + clients) so
// visuals stay synchronized regardless of who's looking.
// Paused stage does NOT update — Y-scale is frozen at the pause point.
if (currentStage.Value != BuildStage.Constructing) return;
float yScale = ComputeConstructingYScale();
ApplyYScale(yScale);
}
// ----- 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;
}
/// <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. Y-scale freezes at the level matching
/// the accumulated time. 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)
{
ApplyStageVisual(current);
}
private void ApplyStageVisual(BuildStage stage)
{
switch (stage)
{
case BuildStage.Queued:
SwapMaterial(queuedMaterial);
ApplyYScale(queuedYScale);
break;
case BuildStage.Constructing:
SwapMaterial(constructingMaterial);
ApplyOwnerTint();
ApplyYScale(ComputeConstructingYScale());
break;
case BuildStage.Paused:
// Use paused material if assigned; fall back to constructing
// material if not (still readable, just less distinct).
SwapMaterial(pausedMaterial != null ? pausedMaterial : constructingMaterial);
ApplyOwnerTint();
// Freeze Y-scale at whatever the accumulated progress represents.
// ComputeConstructingYScale uses accumulatedConstructionTime alone
// when constructionStartServerTime is -1 (the pause sentinel).
ApplyYScale(ComputePausedYScale());
break;
}
}
// Stage index 0..stageCount-1 based on elapsed server time PLUS any accumulated
// time from previous Constructing runs (resume support).
// Returned Y-scale is (stageIndex + 1) / stageCount, so stage 0 = 1/4,
// stage 1 = 2/4, ..., stage stageCount-1 = 4/4 = full height.
private float ComputeConstructingYScale()
{
float bt = buildTime.Value;
if (bt <= 0f || stageCount <= 0) return 1f;
float currentRunElapsed = (float)NetworkManager.Singleton.ServerTime.Time
- constructionStartServerTime.Value;
float total = currentRunElapsed + accumulatedConstructionTime.Value;
float perStage = bt / stageCount;
int stageIndex = Mathf.Clamp(
Mathf.FloorToInt(total / perStage),
0, stageCount - 1);
return (stageIndex + 1f) / stageCount;
}
// While Paused, only the accumulated time matters (no current run is in flight).
private float ComputePausedYScale()
{
float bt = buildTime.Value;
if (bt <= 0f || stageCount <= 0) return 1f;
float total = accumulatedConstructionTime.Value;
float perStage = bt / stageCount;
int stageIndex = Mathf.Clamp(
Mathf.FloorToInt(total / perStage),
0, stageCount - 1);
return (stageIndex + 1f) / stageCount;
}
private void ApplyYScale(float y)
{
if (scaleTarget == null) return;
Vector3 s = scaleTarget.localScale;
scaleTarget.localScale = new Vector3(s.x, y, s.z);
}
// ----- 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);
}
}
}
}