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

@ -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<TowerInstance>();
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;

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