// 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 : a static, non-growing /// site marker (color-tinted by stage) plus the ground targeting reticle /// () that shrinks as construction progresses. One /// NetworkObject per active job. Despawned when the job is cancelled or when the real /// takes its place at construction-complete, at which point /// TowerLandingVisual plays the tower's fall-from-sky arrival. /// /// /// Why a separate prefab from TowerInstance. 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. /// /// 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 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. /// /// 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("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 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 the site marker's material/tint swap. 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; // 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; } // 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 ------------------------------------------------- /// /// 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 (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. /// 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); } } } }