From 362b76ae75e2501e254074f32e177a83c2aeb8e2 Mon Sep 17 00:00:00 2001 From: Ian Woods Date: Fri, 3 Jul 2026 14:05:08 -0700 Subject: [PATCH] towers fall from the sky - neat! --- .../Prefabs/Towers/Tower_BasicArrow.prefab | 61 ++++- .../Prefabs/Towers/Tower_SiegeCannon.prefab | 71 +++++- .../Prefabs/Towers/Tower_TeslaCoil.prefab | 61 ++++- Assets/_Project/Scripts/Combat/TowerCombat.cs | 13 + .../Scripts/Combat/TowerLandedSound.cs | 41 ++++ .../Scripts/Combat/TowerLandedSound.cs.meta | 2 + .../Scripts/Combat/TowerLandingVisual.cs | 104 ++++++++ .../Scripts/Combat/TowerLandingVisual.cs.meta | 2 + .../Scripts/Gameplay/BuildSiteVisual.cs | 231 ++---------------- .../Scripts/Gameplay/TowerInstance.cs | 29 +++ 10 files changed, 396 insertions(+), 219 deletions(-) create mode 100644 Assets/_Project/Scripts/Combat/TowerLandedSound.cs create mode 100644 Assets/_Project/Scripts/Combat/TowerLandedSound.cs.meta create mode 100644 Assets/_Project/Scripts/Combat/TowerLandingVisual.cs create mode 100644 Assets/_Project/Scripts/Combat/TowerLandingVisual.cs.meta diff --git a/Assets/_Project/Prefabs/Towers/Tower_BasicArrow.prefab b/Assets/_Project/Prefabs/Towers/Tower_BasicArrow.prefab index 72ae323..f21641d 100644 --- a/Assets/_Project/Prefabs/Towers/Tower_BasicArrow.prefab +++ b/Assets/_Project/Prefabs/Towers/Tower_BasicArrow.prefab @@ -70,6 +70,8 @@ GameObject: - component: {fileID: 805962841523123163} - component: {fileID: 8853488620519990682} - component: {fileID: 5360241685303413177} + - component: {fileID: 5793816278935258120} + - component: {fileID: 3412005213656160288} m_Layer: 0 m_Name: Tower_BasicArrow m_TagString: Untagged @@ -212,6 +214,8 @@ MonoBehaviour: ShowTopMostFoldoutHeaderGroup: 1 tintedRenderers: - {fileID: 4028055828417179692} + dropHeight: 15 + landingDuration: 0.4 --- !u!114 &805962841523123163 MonoBehaviour: m_ObjectHideFlags: 0 @@ -252,12 +256,67 @@ MonoBehaviour: m_Enabled: 1 m_EditorHideFlags: 0 m_Script: {fileID: 11500000, guid: 93bc533262394e1e8b122bbfad89391a, type: 3} - m_Name: + m_Name: m_EditorClassIdentifier: Assembly-CSharp::TD.Visuals.ConeMesh sides: 4 radius: 0.7071068 height: 1.5 angularOffsetDegrees: 45 +--- !u!114 &5793816278935258120 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 6482414459531823157} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 22d67b2aa780d786a93acfe06d1909b0, type: 3} + m_Name: + m_EditorClassIdentifier: Assembly-CSharp::TD.Combat.TowerLandedSound + landedSound: + clip: {fileID: 0} + volume: 0 + minPitch: 0 + maxPitch: 0 +--- !u!114 &3412005213656160288 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 6482414459531823157} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: bf337521317d9e297a652e97c666376f, type: 3} + m_Name: + m_EditorClassIdentifier: Assembly-CSharp::TD.Combat.TowerLandingVisual + fallCurve: + serializedVersion: 2 + m_Curve: + - serializedVersion: 3 + time: 0 + value: 0 + inSlope: 0 + outSlope: 0 + tangentMode: 0 + weightedMode: 0 + inWeight: 0 + outWeight: 0 + - serializedVersion: 3 + time: 1 + value: 1 + inSlope: 0 + outSlope: 0 + tangentMode: 0 + weightedMode: 0 + inWeight: 0 + outWeight: 0 + m_PreInfinity: 2 + m_PostInfinity: 2 + m_RotationOrder: 4 + impactVfxPrefab: {fileID: 0} + impactVfxLifetime: 3 --- !u!1 &7580197837852108944 GameObject: m_ObjectHideFlags: 0 diff --git a/Assets/_Project/Prefabs/Towers/Tower_SiegeCannon.prefab b/Assets/_Project/Prefabs/Towers/Tower_SiegeCannon.prefab index 1c6e32f..f7020d7 100644 --- a/Assets/_Project/Prefabs/Towers/Tower_SiegeCannon.prefab +++ b/Assets/_Project/Prefabs/Towers/Tower_SiegeCannon.prefab @@ -69,6 +69,8 @@ GameObject: - component: {fileID: 9137031893466587143} - component: {fileID: 805962841523123163} - component: {fileID: 8853488620519990682} + - component: {fileID: 3021373172845454683} + - component: {fileID: 4003901452716391977} m_Layer: 0 m_Name: Tower_SiegeCannon m_TagString: Untagged @@ -181,9 +183,9 @@ MonoBehaviour: m_Enabled: 1 m_EditorHideFlags: 0 m_Script: {fileID: 11500000, guid: d5a57f767e5e46a458fc5d3c628d0cbb, type: 3} - m_Name: + m_Name: m_EditorClassIdentifier: Unity.Netcode.Runtime::Unity.Netcode.NetworkObject - GlobalObjectIdHash: 3344551122 + GlobalObjectIdHash: 2153184693 InScenePlacedSourceGlobalObjectIdHash: 0 DeferredDespawnTick: 0 Ownership: 1 @@ -206,11 +208,13 @@ MonoBehaviour: m_Enabled: 1 m_EditorHideFlags: 0 m_Script: {fileID: 11500000, guid: fb111fc88b3d6a340a3abde5a1502af3, type: 3} - m_Name: + m_Name: m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.TowerInstance ShowTopMostFoldoutHeaderGroup: 1 tintedRenderers: - {fileID: 4028055828417179692} + dropHeight: 15 + landingDuration: 0.4 --- !u!114 &805962841523123163 MonoBehaviour: m_ObjectHideFlags: 0 @@ -221,7 +225,7 @@ MonoBehaviour: m_Enabled: 1 m_EditorHideFlags: 0 m_Script: {fileID: 11500000, guid: 7eb6cce6cd96b23478b7d2173cebf74d, type: 3} - m_Name: + m_Name: m_EditorClassIdentifier: Assembly-CSharp::TD.Combat.TowerCombat ShowTopMostFoldoutHeaderGroup: 1 enemyLayerMask: @@ -237,10 +241,65 @@ MonoBehaviour: m_Enabled: 1 m_EditorHideFlags: 0 m_Script: {fileID: 11500000, guid: c0edb0c5206ca454bbd7c300c6cc7574, type: 3} - m_Name: + m_Name: m_EditorClassIdentifier: Assembly-CSharp::TD.Combat.TowerRangeIndicator rangeProjector: {fileID: 8255517343120954594} projectionDepth: 50 +--- !u!114 &3021373172845454683 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 6482414459531823157} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 22d67b2aa780d786a93acfe06d1909b0, type: 3} + m_Name: + m_EditorClassIdentifier: Assembly-CSharp::TD.Combat.TowerLandedSound + landedSound: + clip: {fileID: 0} + volume: 0 + minPitch: 0 + maxPitch: 0 +--- !u!114 &4003901452716391977 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 6482414459531823157} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: bf337521317d9e297a652e97c666376f, type: 3} + m_Name: + m_EditorClassIdentifier: Assembly-CSharp::TD.Combat.TowerLandingVisual + fallCurve: + serializedVersion: 2 + m_Curve: + - serializedVersion: 3 + time: 0 + value: 0 + inSlope: 0 + outSlope: 0 + tangentMode: 0 + weightedMode: 0 + inWeight: 0 + outWeight: 0 + - serializedVersion: 3 + time: 1 + value: 1 + inSlope: 0 + outSlope: 0 + tangentMode: 0 + weightedMode: 0 + inWeight: 0 + outWeight: 0 + m_PreInfinity: 2 + m_PostInfinity: 2 + m_RotationOrder: 4 + impactVfxPrefab: {fileID: 0} + impactVfxLifetime: 3 --- !u!1 &7580197837852108944 GameObject: m_ObjectHideFlags: 0 @@ -283,7 +342,7 @@ MonoBehaviour: m_Enabled: 1 m_EditorHideFlags: 0 m_Script: {fileID: 11500000, guid: 0777d029ed3dffa4692f417d4aba19ca, type: 3} - m_Name: + m_Name: m_EditorClassIdentifier: Unity.RenderPipelines.Universal.Runtime::UnityEngine.Rendering.Universal.DecalProjector m_Material: {fileID: 2100000, guid: f99227cbde481ce47a2527e6bca709d2, type: 2} m_DrawDistance: 1000 diff --git a/Assets/_Project/Prefabs/Towers/Tower_TeslaCoil.prefab b/Assets/_Project/Prefabs/Towers/Tower_TeslaCoil.prefab index ede5f43..3694acf 100644 --- a/Assets/_Project/Prefabs/Towers/Tower_TeslaCoil.prefab +++ b/Assets/_Project/Prefabs/Towers/Tower_TeslaCoil.prefab @@ -101,6 +101,8 @@ GameObject: - component: {fileID: 4524938955752569428} - component: {fileID: -2590971686077483651} - component: {fileID: 1288485127287735786} + - component: {fileID: 1829137298732744432} + - component: {fileID: 8125712573731754244} m_Layer: 0 m_Name: Tower_TeslaCoil m_TagString: Untagged @@ -147,7 +149,7 @@ MonoBehaviour: m_Script: {fileID: 11500000, guid: d5a57f767e5e46a458fc5d3c628d0cbb, type: 3} m_Name: m_EditorClassIdentifier: Unity.Netcode.Runtime::Unity.Netcode.NetworkObject - GlobalObjectIdHash: 280718094 + GlobalObjectIdHash: 1363936954 InScenePlacedSourceGlobalObjectIdHash: 0 DeferredDespawnTick: 0 Ownership: 1 @@ -175,6 +177,8 @@ MonoBehaviour: ShowTopMostFoldoutHeaderGroup: 1 tintedRenderers: - {fileID: 0} + dropHeight: 15 + landingDuration: 0.4 --- !u!114 &805962841523123163 MonoBehaviour: m_ObjectHideFlags: 0 @@ -268,6 +272,61 @@ MonoBehaviour: volume: 0.496 minPitch: 1 maxPitch: 1 +--- !u!114 &1829137298732744432 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 6482414459531823157} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 22d67b2aa780d786a93acfe06d1909b0, type: 3} + m_Name: + m_EditorClassIdentifier: Assembly-CSharp::TD.Combat.TowerLandedSound + landedSound: + clip: {fileID: 0} + volume: 0 + minPitch: 0 + maxPitch: 0 +--- !u!114 &8125712573731754244 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 6482414459531823157} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: bf337521317d9e297a652e97c666376f, type: 3} + m_Name: + m_EditorClassIdentifier: Assembly-CSharp::TD.Combat.TowerLandingVisual + fallCurve: + serializedVersion: 2 + m_Curve: + - serializedVersion: 3 + time: 0 + value: 0 + inSlope: 0 + outSlope: 0 + tangentMode: 0 + weightedMode: 0 + inWeight: 0 + outWeight: 0 + - serializedVersion: 3 + time: 1 + value: 1 + inSlope: 0 + outSlope: 0 + tangentMode: 0 + weightedMode: 0 + inWeight: 0 + outWeight: 0 + m_PreInfinity: 2 + m_PostInfinity: 2 + m_RotationOrder: 4 + impactVfxPrefab: {fileID: 0} + impactVfxLifetime: 3 --- !u!1 &7101790219931971190 GameObject: m_ObjectHideFlags: 0 diff --git a/Assets/_Project/Scripts/Combat/TowerCombat.cs b/Assets/_Project/Scripts/Combat/TowerCombat.cs index 9c9ac7c..6c1a53f 100644 --- a/Assets/_Project/Scripts/Combat/TowerCombat.cs +++ b/Assets/_Project/Scripts/Combat/TowerCombat.cs @@ -58,6 +58,12 @@ namespace TD.Combat // is in range, the tower fires and the timer resets to 1 / AttacksPerSecond. private float attackCooldown; + // Counts down from towerInstance.LandingDuration, set in OnNetworkSpawn. Combat is + // fully inert until it reaches zero, so a tower can't target/fire while its mesh is + // still visually falling (TowerLandingVisual). Server-only; independent of whatever + // the client-side fall visual is doing to the (possibly shared, on a host) transform. + private float landingCooldown; + // Cached on OnNetworkSpawn — avoids GetComponent every Update. private TowerInstance towerInstance; @@ -102,6 +108,7 @@ namespace TD.Combat public override void OnNetworkSpawn() { towerInstance = GetComponent(); + landingCooldown = towerInstance.LandingDuration; replicatedTarget.OnValueChanged += HandleReplicatedTargetChanged; // Late-joining clients receive the NV's current value in the initial @@ -128,6 +135,12 @@ namespace TD.Combat { if (!IsServer) return; + if (landingCooldown > 0f) + { + landingCooldown -= Time.deltaTime; + return; + } + TowerDefinition def = towerInstance?.Definition; if (def == null || def.Range <= 0f || def.AttacksPerSecond <= 0f) return; diff --git a/Assets/_Project/Scripts/Combat/TowerLandedSound.cs b/Assets/_Project/Scripts/Combat/TowerLandedSound.cs new file mode 100644 index 0000000..b650d58 --- /dev/null +++ b/Assets/_Project/Scripts/Combat/TowerLandedSound.cs @@ -0,0 +1,41 @@ +// Assets/_Project/Scripts/Combat/TowerLandedSound.cs +using TD.Audio; +using TD.Gameplay; +using UnityEngine; + +namespace TD.Combat +{ + /// + /// Local-only audio for the tower's landing impact. Subscribes to + /// and plays a sound on every peer when the + /// tower's fall animation () finishes. + /// Attach to any tower prefab and assign its clip in the Inspector. + /// + [RequireComponent(typeof(TowerInstance))] + public class TowerLandedSound : MonoBehaviour + { + [SerializeField] private SoundConfig landedSound; + + private TowerInstance towerInstance; + + private void Awake() + { + towerInstance = GetComponent(); + } + + private void OnEnable() + { + if (towerInstance != null) towerInstance.OnLandedClient += HandleLanded; + } + + private void OnDisable() + { + if (towerInstance != null) towerInstance.OnLandedClient -= HandleLanded; + } + + private void HandleLanded() + { + AudioManager.Instance?.Play(landedSound.clip, AudioCategory.UI, landedSound.RandomPitch(), landedSound.volume); + } + } +} diff --git a/Assets/_Project/Scripts/Combat/TowerLandedSound.cs.meta b/Assets/_Project/Scripts/Combat/TowerLandedSound.cs.meta new file mode 100644 index 0000000..2adf2ce --- /dev/null +++ b/Assets/_Project/Scripts/Combat/TowerLandedSound.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 22d67b2aa780d786a93acfe06d1909b0 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Combat/TowerLandingVisual.cs b/Assets/_Project/Scripts/Combat/TowerLandingVisual.cs new file mode 100644 index 0000000..6031327 --- /dev/null +++ b/Assets/_Project/Scripts/Combat/TowerLandingVisual.cs @@ -0,0 +1,104 @@ +// Assets/_Project/Scripts/Combat/TowerLandingVisual.cs +using System.Collections; +using TD.Gameplay; +using UnityEngine; + +namespace TD.Combat +{ + /// + /// Local-only "falls from the sky and lands" spawn visual. Subscribes to + /// and, on every client (never on a + /// dedicated server, which has no camera to show it to), animates the tower's + /// transform down from above its already-correct + /// resting position over seconds. + /// + /// + /// No NetworkTransform involved. Tower prefabs have no NetworkTransform — + /// position is a one-shot snapshot sent at spawn. This animation is purely local per + /// peer (same pattern as 's audio cue); each peer computes + /// an identical animation independently from the same replicated landing position and + /// the same authored / + /// values, so no networking is required. + /// + /// Safe on a host despite sharing one Transform with server combat logic. + /// withholds all targeting/attack logic for + /// seconds after spawn (see its + /// landingCooldown field), so it never reads transform.position while this + /// animation is moving it. The two timers start from the same value in the same call + /// ( fires from OnNetworkSpawn), so they + /// finish within a frame of each other. + /// + /// Root transform, not a child wrapper. Tower prefabs render their mesh + /// directly on the root GameObject (no separate "Model" child exists to offset + /// instead), so this animates transform.position directly. Footprint/pathing is + /// unaffected — stamps walkability from its replicated + /// anchor tile (grid data), not from transform.position. + /// + [RequireComponent(typeof(TowerInstance))] + public class TowerLandingVisual : MonoBehaviour + { + [Tooltip("Eases the fall over LandingDuration. X = normalized time, Y = normalized " + + "fraction of DropHeight already descended (0 = still at drop height, 1 = landed).")] + [SerializeField] private AnimationCurve fallCurve = AnimationCurve.EaseInOut(0f, 0f, 1f, 1f); + + [Tooltip("Optional impact VFX (dust burst, etc.) instantiated at the landing point when " + + "the fall finishes. Leave empty for no VFX.")] + [SerializeField] private GameObject impactVfxPrefab; + + [Tooltip("Seconds before the instantiated impact VFX is destroyed. Ignored if " + + "impactVfxPrefab is unassigned.")] + [SerializeField] private float impactVfxLifetime = 3f; + + private TowerInstance towerInstance; + + private void Awake() + { + towerInstance = GetComponent(); + } + + private void OnEnable() + { + if (towerInstance != null) towerInstance.OnBuiltClient += HandleBuilt; + } + + private void OnDisable() + { + if (towerInstance != null) towerInstance.OnBuiltClient -= HandleBuilt; + } + + private void HandleBuilt() + { + // Dedicated (non-host) servers have no camera/visuals and must never move the + // shared transform. TowerCombat's landing gate is fully independent of this + // animation, so skipping it here on a pure server is a harmless no-op. + if (!towerInstance.IsClient) return; + + StartCoroutine(AnimateDrop()); + } + + private IEnumerator AnimateDrop() + { + Vector3 landedPosition = transform.position; // already correct (SeatOnGround, pre-spawn) + float dropHeight = towerInstance.DropHeight; + float duration = towerInstance.LandingDuration; + + transform.position = landedPosition + Vector3.up * dropHeight; + + float t = 0f; + while (t < duration) + { + t += Time.deltaTime; + float u = fallCurve.Evaluate(Mathf.Clamp01(t / duration)); + transform.position = landedPosition + Vector3.up * dropHeight * (1f - u); + yield return null; + } + + transform.position = landedPosition; // snap exactly, avoid float drift + + if (impactVfxPrefab != null) + Destroy(Instantiate(impactVfxPrefab, landedPosition, Quaternion.identity), impactVfxLifetime); + + towerInstance.ClientNotifyLanded(); + } + } +} diff --git a/Assets/_Project/Scripts/Combat/TowerLandingVisual.cs.meta b/Assets/_Project/Scripts/Combat/TowerLandingVisual.cs.meta new file mode 100644 index 0000000..8ad9019 --- /dev/null +++ b/Assets/_Project/Scripts/Combat/TowerLandingVisual.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: bf337521317d9e297a652e97c666376f \ No newline at end of file diff --git a/Assets/_Project/Scripts/Gameplay/BuildSiteVisual.cs b/Assets/_Project/Scripts/Gameplay/BuildSiteVisual.cs index be09946..f51b10d 100644 --- a/Assets/_Project/Scripts/Gameplay/BuildSiteVisual.cs +++ b/Assets/_Project/Scripts/Gameplay/BuildSiteVisual.cs @@ -8,15 +8,16 @@ 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. + /// 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 (transparent green or partial-height cube), no combat, + /// 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 @@ -27,10 +28,9 @@ namespace TD.Gameplay /// 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. + /// 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, @@ -48,10 +48,6 @@ namespace TD.Gameplay "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; @@ -63,13 +59,6 @@ namespace TD.Gameplay "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 " + @@ -77,16 +66,6 @@ namespace TD.Gameplay "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. @@ -133,7 +112,7 @@ namespace TD.Gameplay readPerm: NetworkVariableReadPermission.Everyone, writePerm: NetworkVariableWritePermission.Server); - // Current stage. Drives material swap and Y-scale animation. + // Current stage. Drives the site marker's material/tint swap. private readonly NetworkVariable currentStage = new NetworkVariable( BuildStage.Queued, @@ -244,17 +223,6 @@ namespace TD.Gameplay 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; @@ -307,10 +275,6 @@ namespace TD.Gameplay 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) @@ -324,7 +288,7 @@ namespace TD.Gameplay currentStage.OnValueChanged += HandleStageChanged; // Apply initial visual state based on the (now-replicated) values. - ApplyStageVisual(currentStage.Value); + ApplyStageMaterialAndTint(currentStage.Value); } public override void OnNetworkDespawn() @@ -376,30 +340,10 @@ namespace TD.Gameplay { // 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. + // 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()); - - // 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 ------------------------------------------------- @@ -522,9 +466,9 @@ namespace TD.Gameplay /// /// 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. + /// 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) { @@ -542,37 +486,11 @@ namespace TD.Gameplay private void HandleStageChanged(BuildStage previous, BuildStage current) { - ApplyStageVisual(current); + ApplyStageMaterialAndTint(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. + // 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) @@ -591,115 +509,6 @@ namespace TD.Gameplay } } - // ----- 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) diff --git a/Assets/_Project/Scripts/Gameplay/TowerInstance.cs b/Assets/_Project/Scripts/Gameplay/TowerInstance.cs index e459f01..519ff04 100644 --- a/Assets/_Project/Scripts/Gameplay/TowerInstance.cs +++ b/Assets/_Project/Scripts/Gameplay/TowerInstance.cs @@ -55,6 +55,18 @@ namespace TD.Gameplay "to exclude specific children.")] [SerializeField] private MeshRenderer[] tintedRenderers; + [Header("Landing (post-construction drop)")] + [Tooltip("World-unit height the tower visually drops from when construction completes. " + + "Consumed only by the client-side fall visual (TowerLandingVisual).")] + [SerializeField] private float dropHeight = 15f; + + [Tooltip("Seconds the fall-and-land animation takes. Single source of truth: " + + "TowerCombat withholds targeting/attacks for exactly this long " + + "(server-authoritative), and TowerLandingVisual uses the same value to time " + + "its client-side animation, so the two stay in lockstep without duplicating " + + "the number.")] + [SerializeField] private float landingDuration = 0.4f; + // ----- Networked state ------------------------------------------------ // The TowerTypeId (index into TowerPlacementManager's catalog) for this tower. @@ -132,6 +144,13 @@ namespace TD.Gameplay /// The footprint anchor tile (SW corner, world-tile coords). public Vector2Int AnchorTile => anchorTile.Value; + /// World-unit height the post-construction drop animation falls from. + public float DropHeight => dropHeight; + + /// Seconds the drop/land animation takes. Also how long TowerCombat withholds + /// targeting/attacks after construction completes. + public float LandingDuration => landingDuration; + // ----- ISelectable ---------------------------------------------------- // Absolute world-unit margin that the selection ring extends beyond the @@ -171,6 +190,16 @@ namespace TD.Gameplay /// Fired on ALL peers when this tower finishes construction and spawns. public event System.Action OnBuiltClient; + /// Fired locally on this peer when the client-side fall/land animation + /// finishes (see ). Analogous to + /// , but delayed by . + public event System.Action OnLandedClient; + + /// Called by when its local + /// fall/land animation finishes. Purely local — never invoked on a dedicated + /// (non-client) server, which never runs that animation. + public void ClientNotifyLanded() => OnLandedClient?.Invoke(); + // ----- Server-only initialization ------------------------------------- ///