478 lines
No EOL
22 KiB
C#
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);
|
|
}
|
|
}
|
|
}
|
|
} |