towers fall from the sky - neat!

This commit is contained in:
Ian Woods 2026-07-03 14:05:08 -07:00
parent d14305d804
commit 362b76ae75
10 changed files with 396 additions and 219 deletions

View file

@ -70,6 +70,8 @@ GameObject:
- component: {fileID: 805962841523123163} - component: {fileID: 805962841523123163}
- component: {fileID: 8853488620519990682} - component: {fileID: 8853488620519990682}
- component: {fileID: 5360241685303413177} - component: {fileID: 5360241685303413177}
- component: {fileID: 5793816278935258120}
- component: {fileID: 3412005213656160288}
m_Layer: 0 m_Layer: 0
m_Name: Tower_BasicArrow m_Name: Tower_BasicArrow
m_TagString: Untagged m_TagString: Untagged
@ -212,6 +214,8 @@ MonoBehaviour:
ShowTopMostFoldoutHeaderGroup: 1 ShowTopMostFoldoutHeaderGroup: 1
tintedRenderers: tintedRenderers:
- {fileID: 4028055828417179692} - {fileID: 4028055828417179692}
dropHeight: 15
landingDuration: 0.4
--- !u!114 &805962841523123163 --- !u!114 &805962841523123163
MonoBehaviour: MonoBehaviour:
m_ObjectHideFlags: 0 m_ObjectHideFlags: 0
@ -258,6 +262,61 @@ MonoBehaviour:
radius: 0.7071068 radius: 0.7071068
height: 1.5 height: 1.5
angularOffsetDegrees: 45 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 --- !u!1 &7580197837852108944
GameObject: GameObject:
m_ObjectHideFlags: 0 m_ObjectHideFlags: 0

View file

@ -69,6 +69,8 @@ GameObject:
- component: {fileID: 9137031893466587143} - component: {fileID: 9137031893466587143}
- component: {fileID: 805962841523123163} - component: {fileID: 805962841523123163}
- component: {fileID: 8853488620519990682} - component: {fileID: 8853488620519990682}
- component: {fileID: 3021373172845454683}
- component: {fileID: 4003901452716391977}
m_Layer: 0 m_Layer: 0
m_Name: Tower_SiegeCannon m_Name: Tower_SiegeCannon
m_TagString: Untagged m_TagString: Untagged
@ -183,7 +185,7 @@ MonoBehaviour:
m_Script: {fileID: 11500000, guid: d5a57f767e5e46a458fc5d3c628d0cbb, type: 3} m_Script: {fileID: 11500000, guid: d5a57f767e5e46a458fc5d3c628d0cbb, type: 3}
m_Name: m_Name:
m_EditorClassIdentifier: Unity.Netcode.Runtime::Unity.Netcode.NetworkObject m_EditorClassIdentifier: Unity.Netcode.Runtime::Unity.Netcode.NetworkObject
GlobalObjectIdHash: 3344551122 GlobalObjectIdHash: 2153184693
InScenePlacedSourceGlobalObjectIdHash: 0 InScenePlacedSourceGlobalObjectIdHash: 0
DeferredDespawnTick: 0 DeferredDespawnTick: 0
Ownership: 1 Ownership: 1
@ -211,6 +213,8 @@ MonoBehaviour:
ShowTopMostFoldoutHeaderGroup: 1 ShowTopMostFoldoutHeaderGroup: 1
tintedRenderers: tintedRenderers:
- {fileID: 4028055828417179692} - {fileID: 4028055828417179692}
dropHeight: 15
landingDuration: 0.4
--- !u!114 &805962841523123163 --- !u!114 &805962841523123163
MonoBehaviour: MonoBehaviour:
m_ObjectHideFlags: 0 m_ObjectHideFlags: 0
@ -241,6 +245,61 @@ MonoBehaviour:
m_EditorClassIdentifier: Assembly-CSharp::TD.Combat.TowerRangeIndicator m_EditorClassIdentifier: Assembly-CSharp::TD.Combat.TowerRangeIndicator
rangeProjector: {fileID: 8255517343120954594} rangeProjector: {fileID: 8255517343120954594}
projectionDepth: 50 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 --- !u!1 &7580197837852108944
GameObject: GameObject:
m_ObjectHideFlags: 0 m_ObjectHideFlags: 0

View file

@ -101,6 +101,8 @@ GameObject:
- component: {fileID: 4524938955752569428} - component: {fileID: 4524938955752569428}
- component: {fileID: -2590971686077483651} - component: {fileID: -2590971686077483651}
- component: {fileID: 1288485127287735786} - component: {fileID: 1288485127287735786}
- component: {fileID: 1829137298732744432}
- component: {fileID: 8125712573731754244}
m_Layer: 0 m_Layer: 0
m_Name: Tower_TeslaCoil m_Name: Tower_TeslaCoil
m_TagString: Untagged m_TagString: Untagged
@ -147,7 +149,7 @@ MonoBehaviour:
m_Script: {fileID: 11500000, guid: d5a57f767e5e46a458fc5d3c628d0cbb, type: 3} m_Script: {fileID: 11500000, guid: d5a57f767e5e46a458fc5d3c628d0cbb, type: 3}
m_Name: m_Name:
m_EditorClassIdentifier: Unity.Netcode.Runtime::Unity.Netcode.NetworkObject m_EditorClassIdentifier: Unity.Netcode.Runtime::Unity.Netcode.NetworkObject
GlobalObjectIdHash: 280718094 GlobalObjectIdHash: 1363936954
InScenePlacedSourceGlobalObjectIdHash: 0 InScenePlacedSourceGlobalObjectIdHash: 0
DeferredDespawnTick: 0 DeferredDespawnTick: 0
Ownership: 1 Ownership: 1
@ -175,6 +177,8 @@ MonoBehaviour:
ShowTopMostFoldoutHeaderGroup: 1 ShowTopMostFoldoutHeaderGroup: 1
tintedRenderers: tintedRenderers:
- {fileID: 0} - {fileID: 0}
dropHeight: 15
landingDuration: 0.4
--- !u!114 &805962841523123163 --- !u!114 &805962841523123163
MonoBehaviour: MonoBehaviour:
m_ObjectHideFlags: 0 m_ObjectHideFlags: 0
@ -268,6 +272,61 @@ MonoBehaviour:
volume: 0.496 volume: 0.496
minPitch: 1 minPitch: 1
maxPitch: 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 --- !u!1 &7101790219931971190
GameObject: GameObject:
m_ObjectHideFlags: 0 m_ObjectHideFlags: 0

View file

@ -58,6 +58,12 @@ namespace TD.Combat
// is in range, the tower fires and the timer resets to 1 / AttacksPerSecond. // is in range, the tower fires and the timer resets to 1 / AttacksPerSecond.
private float attackCooldown; 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. // Cached on OnNetworkSpawn — avoids GetComponent every Update.
private TowerInstance towerInstance; private TowerInstance towerInstance;
@ -102,6 +108,7 @@ namespace TD.Combat
public override void OnNetworkSpawn() public override void OnNetworkSpawn()
{ {
towerInstance = GetComponent<TowerInstance>(); towerInstance = GetComponent<TowerInstance>();
landingCooldown = towerInstance.LandingDuration;
replicatedTarget.OnValueChanged += HandleReplicatedTargetChanged; replicatedTarget.OnValueChanged += HandleReplicatedTargetChanged;
// Late-joining clients receive the NV's current value in the initial // Late-joining clients receive the NV's current value in the initial
@ -128,6 +135,12 @@ namespace TD.Combat
{ {
if (!IsServer) return; if (!IsServer) return;
if (landingCooldown > 0f)
{
landingCooldown -= Time.deltaTime;
return;
}
TowerDefinition def = towerInstance?.Definition; TowerDefinition def = towerInstance?.Definition;
if (def == null || def.Range <= 0f || def.AttacksPerSecond <= 0f) return; if (def == null || def.Range <= 0f || def.AttacksPerSecond <= 0f) return;

View file

@ -0,0 +1,41 @@
// Assets/_Project/Scripts/Combat/TowerLandedSound.cs
using TD.Audio;
using TD.Gameplay;
using UnityEngine;
namespace TD.Combat
{
/// <summary>
/// Local-only audio for the tower's landing impact. Subscribes to
/// <see cref="TowerInstance.OnLandedClient"/> and plays a sound on every peer when the
/// tower's fall animation (<see cref="TowerLandingVisual"/>) finishes.
/// Attach to any tower prefab and assign its clip in the Inspector.
/// </summary>
[RequireComponent(typeof(TowerInstance))]
public class TowerLandedSound : MonoBehaviour
{
[SerializeField] private SoundConfig landedSound;
private TowerInstance towerInstance;
private void Awake()
{
towerInstance = GetComponent<TowerInstance>();
}
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);
}
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 22d67b2aa780d786a93acfe06d1909b0

View file

@ -0,0 +1,104 @@
// Assets/_Project/Scripts/Combat/TowerLandingVisual.cs
using System.Collections;
using TD.Gameplay;
using UnityEngine;
namespace TD.Combat
{
/// <summary>
/// Local-only "falls from the sky and lands" spawn visual. Subscribes to
/// <see cref="TowerInstance.OnBuiltClient"/> and, on every client (never on a
/// dedicated server, which has no camera to show it to), animates the tower's
/// transform down from <see cref="TowerInstance.DropHeight"/> above its already-correct
/// resting position over <see cref="TowerInstance.LandingDuration"/> seconds.
/// </summary>
/// <remarks>
/// <para><b>No NetworkTransform involved.</b> Tower prefabs have no NetworkTransform —
/// position is a one-shot snapshot sent at spawn. This animation is purely local per
/// peer (same pattern as <see cref="TowerBuiltSound"/>'s audio cue); each peer computes
/// an identical animation independently from the same replicated landing position and
/// the same authored <see cref="TowerInstance.DropHeight"/>/<see cref="TowerInstance.LandingDuration"/>
/// values, so no networking is required.</para>
///
/// <para><b>Safe on a host despite sharing one Transform with server combat logic.</b>
/// <see cref="TowerCombat"/> withholds all targeting/attack logic for
/// <see cref="TowerInstance.LandingDuration"/> seconds after spawn (see its
/// <c>landingCooldown</c> field), so it never reads <c>transform.position</c> while this
/// animation is moving it. The two timers start from the same value in the same call
/// (<see cref="TowerInstance.OnBuiltClient"/> fires from <c>OnNetworkSpawn</c>), so they
/// finish within a frame of each other.</para>
///
/// <para><b>Root transform, not a child wrapper.</b> Tower prefabs render their mesh
/// directly on the root GameObject (no separate "Model" child exists to offset
/// instead), so this animates <c>transform.position</c> directly. Footprint/pathing is
/// unaffected — <see cref="TowerInstance"/> stamps walkability from its replicated
/// anchor tile (grid data), not from <c>transform.position</c>.</para>
/// </remarks>
[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<TowerInstance>();
}
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();
}
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: bf337521317d9e297a652e97c666376f

View file

@ -8,15 +8,16 @@ using TD.VFX;
namespace TD.Gameplay namespace TD.Gameplay
{ {
/// <summary> /// <summary>
/// Visual representation of an in-flight <see cref="BuildJob"/>: the green /// Visual representation of an in-flight <see cref="BuildJob"/>: a static, non-growing
/// queued ghost, and the staged construction animation (4 stages of growing /// site marker (color-tinted by stage) plus the ground targeting reticle
/// height for the testing cube). One NetworkObject per active job. Despawned /// (<see cref="TowerDropReticle"/>) that shrinks as construction progresses. One
/// when the job is cancelled or when the real <see cref="TowerInstance"/> /// NetworkObject per active job. Despawned when the job is cancelled or when the real
/// takes its place at construction-complete. /// <see cref="TowerInstance"/> takes its place at construction-complete, at which point
/// <c>TowerLandingVisual</c> plays the tower's fall-from-sky arrival.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// <para><b>Why a separate prefab from TowerInstance.</b> The build-site visual /// <para><b>Why a separate prefab from TowerInstance.</b> 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 /// no grid-stamping (the Builder owns those state transitions), and a much
/// shorter lifecycle. Sharing a prefab with TowerInstance would mean adding /// shorter lifecycle. Sharing a prefab with TowerInstance would mean adding
/// "am I real or a ghost" branching to every TowerInstance code path. Two /// "am I real or a ghost" branching to every TowerInstance code path. Two
@ -27,10 +28,9 @@ namespace TD.Gameplay
/// from <c>NetworkManager.ServerTime.TimeAsFloat</c>. Only the server writes; /// from <c>NetworkManager.ServerTime.TimeAsFloat</c>. Only the server writes;
/// clients read.</para> /// clients read.</para>
/// ///
/// <para><b>Visual model.</b> The prefab inspector points at a re-tinted copy /// <para><b>Visual model.</b> The site marker's material swaps by stage (queued/
/// of the tower's mesh (the same testing cube). At Stage = Queued, the visual /// constructing/paused) but never grows or changes shape — construction progress is
/// is a translucent green cube at full footprint scale but reduced Y. At /// communicated entirely by the reticle shrinking, not by the marker itself.</para>
/// Stage = Constructing, the cube grows in 4 sub-stages over BuildTime.</para>
/// ///
/// <para><b>No grid stamping here.</b> Walkability and occupancy stamps are /// <para><b>No grid stamping here.</b> Walkability and occupancy stamps are
/// driven by the Builder (queue-time stamps occupied=true / walkable=true, /// 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.")] "MeshRenderer on the prefab. Auto-populated from children if empty.")]
[SerializeField] private MeshRenderer[] tintedRenderers; [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).")] [Tooltip("Material applied while the job is Queued (translucent green).")]
[SerializeField] private Material queuedMaterial; [SerializeField] private Material queuedMaterial;
@ -63,13 +59,6 @@ namespace TD.Gameplay
"Suggested: muted/grey-tinted variant of the constructing material.")] "Suggested: muted/grey-tinted variant of the constructing material.")]
[SerializeField] private Material pausedMaterial; [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")] [Header("Drop reticle FX")]
[Tooltip("Optional targeting-reticle effect (ground decal + beam + motes) shown for the " + [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 " + "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.")] "this visual at completion/cancel. Leave empty to build with no reticle.")]
[SerializeField] private TowerDropReticle dropReticlePrefab; [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 -------------------------------------------- // ----- Networked state --------------------------------------------
// Replicated owner slot for color tinting. Mirrors TowerInstance. // Replicated owner slot for color tinting. Mirrors TowerInstance.
@ -133,7 +112,7 @@ namespace TD.Gameplay
readPerm: NetworkVariableReadPermission.Everyone, readPerm: NetworkVariableReadPermission.Everyone,
writePerm: NetworkVariableWritePermission.Server); 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<BuildStage> currentStage = private readonly NetworkVariable<BuildStage> currentStage =
new NetworkVariable<BuildStage>( new NetworkVariable<BuildStage>(
BuildStage.Queued, BuildStage.Queued,
@ -244,17 +223,6 @@ namespace TD.Gameplay
private int pendingGoldSpent; private int pendingGoldSpent;
private bool hasPendingInit; 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; // Spawned child targeting-reticle effect (local, all peers). Fed progress in Update;
// destroyed automatically with this NetworkObject at completion/cancel. // destroyed automatically with this NetworkObject at completion/cancel.
private TowerDropReticle dropReticleInstance; private TowerDropReticle dropReticleInstance;
@ -307,10 +275,6 @@ namespace TD.Gameplay
hasPendingInit = false; 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 // 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. // replicated progress (fed in Update) and dies with us at completion/cancel.
if (dropReticlePrefab != null) if (dropReticlePrefab != null)
@ -324,7 +288,7 @@ namespace TD.Gameplay
currentStage.OnValueChanged += HandleStageChanged; currentStage.OnValueChanged += HandleStageChanged;
// Apply initial visual state based on the (now-replicated) values. // Apply initial visual state based on the (now-replicated) values.
ApplyStageVisual(currentStage.Value); ApplyStageMaterialAndTint(currentStage.Value);
} }
public override void OnNetworkDespawn() public override void OnNetworkDespawn()
@ -376,30 +340,10 @@ namespace TD.Gameplay
{ {
// Drive the targeting reticle on every peer, every stage: 0 while Queued // Drive the targeting reticle on every peer, every stage: 0 while Queued
// (full-size reticle, awaiting a builder), shrinking while Constructing, frozen // (full-size reticle, awaiting a builder), shrinking while Constructing, frozen
// while Paused. Kept before the Constructing early-return so it also animates // while Paused. This is the only per-frame visual effect on the build site —
// the queued/paused states. // nothing about the site's mesh grows or changes shape during construction.
if (dropReticleInstance != null) if (dropReticleInstance != null)
dropReticleInstance.SetProgress(ComputeProgressNormalized()); 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 API -------------------------------------------------
@ -522,9 +466,9 @@ namespace TD.Gameplay
/// <summary> /// <summary>
/// Server-only: transitions Constructing → Paused AND writes the new /// Server-only: transitions Constructing → Paused AND writes the new
/// accumulated construction time. Y-scale freezes at the level matching /// accumulated construction time (freezing the reticle's shrink progress at that
/// the accumulated time. After this returns, the visual is fully self- /// point). After this returns, the visual is fully self-describing — it carries
/// describing — it carries enough state to be shelved and later resumed. /// enough state to be shelved and later resumed.
/// </summary> /// </summary>
public void ServerPauseAndPersistAccumulated(float totalAccumulated) public void ServerPauseAndPersistAccumulated(float totalAccumulated)
{ {
@ -542,37 +486,11 @@ namespace TD.Gameplay
private void HandleStageChanged(BuildStage previous, BuildStage current) private void HandleStageChanged(BuildStage previous, BuildStage current)
{ {
ApplyStageVisual(current); ApplyStageMaterialAndTint(current);
} }
private void ApplyStageVisual(BuildStage stage) // 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.
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) private void ApplyStageMaterialAndTint(BuildStage stage)
{ {
switch (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<MeshRenderer>(true);
}
}
// Activates only phase <paramref name="index"/> 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 ------------------------------------------ // ----- Material handling ------------------------------------------
private void SwapMaterial(Material mat) private void SwapMaterial(Material mat)

View file

@ -55,6 +55,18 @@ namespace TD.Gameplay
"to exclude specific children.")] "to exclude specific children.")]
[SerializeField] private MeshRenderer[] tintedRenderers; [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 ------------------------------------------------ // ----- Networked state ------------------------------------------------
// The TowerTypeId (index into TowerPlacementManager's catalog) for this tower. // The TowerTypeId (index into TowerPlacementManager's catalog) for this tower.
@ -132,6 +144,13 @@ namespace TD.Gameplay
/// <summary>The footprint anchor tile (SW corner, world-tile coords).</summary> /// <summary>The footprint anchor tile (SW corner, world-tile coords).</summary>
public Vector2Int AnchorTile => anchorTile.Value; public Vector2Int AnchorTile => anchorTile.Value;
/// <summary>World-unit height the post-construction drop animation falls from.</summary>
public float DropHeight => dropHeight;
/// <summary>Seconds the drop/land animation takes. Also how long TowerCombat withholds
/// targeting/attacks after construction completes.</summary>
public float LandingDuration => landingDuration;
// ----- ISelectable ---------------------------------------------------- // ----- ISelectable ----------------------------------------------------
// Absolute world-unit margin that the selection ring extends beyond the // Absolute world-unit margin that the selection ring extends beyond the
@ -171,6 +190,16 @@ namespace TD.Gameplay
/// <summary>Fired on ALL peers when this tower finishes construction and spawns.</summary> /// <summary>Fired on ALL peers when this tower finishes construction and spawns.</summary>
public event System.Action OnBuiltClient; public event System.Action OnBuiltClient;
/// <summary>Fired locally on this peer when the client-side fall/land animation
/// finishes (see <see cref="TD.Combat.TowerLandingVisual"/>). Analogous to
/// <see cref="OnBuiltClient"/>, but delayed by <see cref="LandingDuration"/>.</summary>
public event System.Action OnLandedClient;
/// <summary>Called by <see cref="TD.Combat.TowerLandingVisual"/> when its local
/// fall/land animation finishes. Purely local — never invoked on a dedicated
/// (non-client) server, which never runs that animation.</summary>
public void ClientNotifyLanded() => OnLandedClient?.Invoke();
// ----- Server-only initialization ------------------------------------- // ----- Server-only initialization -------------------------------------
/// <summary> /// <summary>