// Assets/_Project/Scripts/Gameplay/BuildSiteVisual.cs
using Unity.Netcode;
using UnityEngine;
using TD.Core;
using TD.Towers;
using TD.VFX;
namespace TD.Gameplay
{
///
/// Visual representation of an in-flight : 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
/// takes its place at construction-complete.
///
///
/// Why a separate prefab from TowerInstance. 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.
///
/// Stage replication. Stage and ConstructionStartServerTime are
/// replicated as NetworkVariables so all peers compute identical visuals locally
/// from NetworkManager.ServerTime.TimeAsFloat. Only the server writes;
/// clients read.
///
/// Visual model. 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.
///
/// No grid stamping here. 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.
///
[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("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 phases")]
[Tooltip("Project-default construction-stage visuals used when the tower being built " +
"doesn't define its own ConstructionPhases. When this (or the tower's set) " +
"has phases, the build site swaps between those phase prefabs as it builds. " +
"Leave empty to fall back to the legacy cube Y-scale growth below.")]
[SerializeField] private ConstructionPhaseSet defaultPhaseSet;
[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;
[Header("Construction stages (cube fallback)")]
[Tooltip("FALLBACK ONLY (no phase set assigned): number of discrete growth stages " +
"while constructing. 4 = 1/4 → 2/4 → 3/4 → 4/4 height.")]
[SerializeField] private int stageCount = 4;
[Tooltip("FALLBACK ONLY: 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 owner slot for color tinting. Mirrors TowerInstance.
private readonly NetworkVariable ownerSlot =
new NetworkVariable(
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 anchor =
new NetworkVariable(
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 towerTypeId =
new NetworkVariable(
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 goldSpent =
new NetworkVariable(
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 isShelved =
new NetworkVariable(
false,
readPerm: NetworkVariableReadPermission.Everyone,
writePerm: NetworkVariableWritePermission.Server);
// Current stage. Drives material swap and Y-scale animation.
private readonly NetworkVariable currentStage =
new NetworkVariable(
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 constructionStartServerTime =
new NetworkVariable(
-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 accumulatedConstructionTime =
new NetworkVariable(
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 buildTime =
new NetworkVariable(
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.
/// The current build stage. Read by clients for click-target tests.
public BuildStage CurrentStage => currentStage.Value;
/// True iff this visual has been shelved (no longer in its Builder's queue).
public bool IsShelved => isShelved.Value;
/// Footprint anchor (SW corner). Used by server to rebuild a BuildJob on resume.
public Vector2Int Anchor => anchor.Value;
/// Tower type ID. Used by server to rebuild a BuildJob on resume.
public int TowerTypeId => towerTypeId.Value;
/// Gold paid for this build. Used by server to rebuild a BuildJob on resume.
public int GoldSpent => goldSpent.Value;
/// Accumulated construction time (across pause/resume cycles). Used on resume.
public float AccumulatedConstructionTime => accumulatedConstructionTime.Value;
///
/// Returns [0,1] normalized construction progress. Safe to call on any client.
/// Returns 0 while Queued; freezes at accumulated fraction while Paused.
///
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 ------------------------------------------------
/// 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.
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;
// ----- Construction-phase runtime (local, all peers) --------------
// Resolved on spawn from the tower's ConstructionPhases or the defaultPhaseSet.
// When non-null with PhaseCount > 0, the build site swaps between these
// instantiated phase prefabs instead of Y-scaling the fallback cube.
private ConstructionPhaseSet effectivePhaseSet;
private GameObject[] phaseInstances; // one instantiated child per phase
private MeshRenderer[][] phaseRenderers; // cached renderers per phase, for tinting
private int activePhaseIndex = -1;
private bool usePhases;
// Spawned child targeting-reticle effect (local, all peers). Fed progress in Update;
// destroyed automatically with this NetworkObject at completion/cancel.
private TowerDropReticle dropReticleInstance;
// ----- Lifecycle --------------------------------------------------
///
/// Server-only: stores the data that will write
/// into NetworkVariables. Must be called between Instantiate and Spawn().
///
///
/// Owner identity is conveyed through NGO ownership (via SpawnWithOwnership),
/// not through this method — see .
///
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();
// 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;
}
// Resolve and instantiate the construction-phase visuals (or fall back to the
// cube). Depends on towerTypeId, which is replicated by now on every peer.
ResolvePhaseSet();
// 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.
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();
}
// 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. Kept before the Constructing early-return so it also animates
// the queued/paused states.
if (dropReticleInstance != null)
dropReticleInstance.SetProgress(ComputeProgressNormalized());
// While constructing, advance the visual based on server time. Runs on every
// peer so visuals stay synchronized. Paused does NOT update — the visual is
// frozen at the pause point.
if (currentStage.Value != BuildStage.Constructing) return;
if (usePhases)
{
int index = PhaseIndexFromProgress();
if (index != activePhaseIndex)
{
ShowPhase(index);
// Renderers changed with the swap — re-apply material + owner tint.
ApplyStageMaterialAndTint(BuildStage.Constructing);
}
return;
}
// Fallback: smoothly grow the cube's Y-scale through the stages.
ApplyYScale(ComputeConstructingYScale());
}
// ----- Server API -------------------------------------------------
///
/// 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.
///
public void ServerMarkShelved()
{
if (!IsServer) return;
isShelved.Value = true;
}
///
/// 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.
///
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;
///
/// Owner-only RPC. Routes to . Hooked up to the
/// Cancel action button in the HUD's action menu.
///
[Rpc(SendTo.Server, InvokePermission = RpcInvokePermission.Owner)]
public void RequestCancelRpc()
{
ServerCancel();
}
///
/// Server-only: cancel this build. Two paths depending on shelve state:
/// - Shelved: this visual is standalone (not in any builder's queue).
/// Refund gold ourselves and despawn —
/// restores the footprint's grid state because isShelved is true.
/// - In a builder's queue: route through the owning builder's
/// , which already handles
/// refund + grid restore + visual despawn through its job-cleanup path.
///
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);
}
///
/// Server-only: transitions the visual from Queued (or Paused) to Constructing
/// and records the server time for stage progression. Caller is responsible
/// for setting from the BuildJob's
/// AccumulatedConstructionTime — non-zero values mean this is a resume.
///
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;
}
///
/// 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.
///
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)
{
if (usePhases)
{
// Queued shows the first phase; constructing/paused show the phase
// matching current progress. ShowPhase repoints tintedRenderers at the
// active phase instance, so material + tint are applied AFTER it.
int index = stage == BuildStage.Queued ? 0 : PhaseIndexFromProgress();
ShowPhase(index);
ApplyStageMaterialAndTint(stage);
return;
}
// Fallback: legacy cube Y-scale growth (no phase set wired).
ApplyStageMaterialAndTint(stage);
switch (stage)
{
case BuildStage.Queued: ApplyYScale(queuedYScale); break;
case BuildStage.Constructing: ApplyYScale(ComputeConstructingYScale()); break;
// ComputePausedYScale uses accumulatedConstructionTime alone when
// constructionStartServerTime is -1 (the pause sentinel).
case BuildStage.Paused: ApplyYScale(ComputePausedYScale()); break;
}
}
// Applies the stage's material + owner tint to whatever renderers are currently
// active — the cube fallback, or the active phase instance'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;
}
}
// ----- Construction phases ----------------------------------------
// Resolves the effective phase set (tower's own, else the project default) and
// instantiates one inactive child per phase. Sets usePhases=false (cube fallback)
// if neither set has any phases. Runs on every peer in OnNetworkSpawn.
private void ResolvePhaseSet()
{
var def = TowerPlacementManager.GetDefinition(towerTypeId.Value);
effectivePhaseSet =
(def != null && def.ConstructionPhases != null && def.ConstructionPhases.PhaseCount > 0)
? def.ConstructionPhases
: defaultPhaseSet;
int n = effectivePhaseSet != null ? effectivePhaseSet.PhaseCount : 0;
if (n <= 0)
{
usePhases = false;
return;
}
usePhases = true;
phaseInstances = new GameObject[n];
phaseRenderers = new MeshRenderer[n][];
// Hide the legacy fallback cube — the instantiated phase prefabs replace it.
if (scaleTarget != null) scaleTarget.gameObject.SetActive(false);
for (int i = 0; i < n; i++)
{
var prefab = effectivePhaseSet.GetPhase(i);
if (prefab == null) continue;
var go = Instantiate(prefab, transform);
go.transform.localPosition = Vector3.zero;
go.transform.localRotation = Quaternion.identity;
go.SetActive(false);
phaseInstances[i] = go;
phaseRenderers[i] = go.GetComponentsInChildren(true);
}
}
// Activates only phase and points tintedRenderers at its
// renderers so the existing material/tint helpers drive the active phase.
private void ShowPhase(int index)
{
if (phaseInstances == null) return;
index = Mathf.Clamp(index, 0, phaseInstances.Length - 1);
for (int i = 0; i < phaseInstances.Length; i++)
if (phaseInstances[i] != null)
phaseInstances[i].SetActive(i == index);
activePhaseIndex = index;
tintedRenderers = phaseRenderers[index];
}
// Maps current normalized progress to a phase index (0..PhaseCount-1).
private int PhaseIndexFromProgress()
{
int n = effectivePhaseSet != null ? effectivePhaseSet.PhaseCount : 1;
if (n <= 1) return 0;
float p = ComputeProgressNormalized();
return Mathf.Clamp(Mathf.FloorToInt(p * n), 0, n - 1);
}
// 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);
}
}
}
}