// Assets/_Project/Scripts/Gameplay/BuildSiteVisual.cs using Unity.Collections; using Unity.Netcode; using UnityEngine; using TD.Core; using TD.Towers; using TD.UI; 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 { // ----- 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 definitionName = new NetworkVariable( default, readPerm: NetworkVariableReadPermission.Everyone, writePerm: NetworkVariableWritePermission.Server); // 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); } // ----- 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 -------------------------------------------------- /// /// 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; } 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(); // 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); // 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().Initialize(this); } 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 ------------------------------------------------- /// /// 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; } /// /// 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) { 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); } } } }