Adding Tesla Coil Tower and Arc VFX

New tower!
This commit is contained in:
Matt F 2026-06-25 11:35:23 -07:00
parent 7b93137216
commit 699ea34268
20 changed files with 1317 additions and 2 deletions

View file

@ -0,0 +1,118 @@
// Assets/_Project/Scripts/Combat/TeslaArcVisual.cs
using System.Collections.Generic;
using UnityEngine;
namespace TD.Combat
{
/// <summary>
/// Local-only electrical-arc visual for <see cref="TD.Core.TargetType.AllInRange"/>
/// towers (Tesla Coil). Subscribes to <see cref="TowerCombat.OnAreaFired"/> and draws a
/// jagged line from the tower's emitter point to every enemy hit, flashed for a moment.
/// </summary>
/// <remarks>
/// Pure visualization — runs on every peer from the replicated fire broadcast; nothing
/// here is networked. Arcs are pooled <see cref="LineRenderer"/>s reused across ticks.
/// </remarks>
[RequireComponent(typeof(TowerCombat))]
public class TeslaArcVisual : MonoBehaviour
{
[Tooltip("Point the arcs originate from — the central top of the coil. " +
"If unset, falls back to one unit above the tower root.")]
[SerializeField] private Transform emitter;
[Tooltip("Material for the arc lines. Assign an unlit/additive material for a glow. " +
"If unset, a fallback Sprites/Default material is created at runtime.")]
[SerializeField] private Material arcMaterial;
[SerializeField] private Color arcColor = new Color(0.6f, 0.85f, 1f, 1f);
[SerializeField] private float arcWidth = 0.06f;
[Tooltip("How long each arc flash stays visible, in seconds.")]
[SerializeField] private float arcDuration = 0.12f;
[Tooltip("Number of points per arc. More = more jagged detail.")]
[SerializeField] private int segmentsPerArc = 6;
[Tooltip("Random offset applied to interior arc points for the lightning look.")]
[SerializeField] private float jitter = 0.2f;
private TowerCombat combat;
private readonly List<LineRenderer> pool = new List<LineRenderer>();
private float hideAt = -1f;
private void Awake() => combat = GetComponent<TowerCombat>();
private void OnEnable()
{
if (combat != null) combat.OnAreaFired += HandleAreaFired;
}
private void OnDisable()
{
if (combat != null) combat.OnAreaFired -= HandleAreaFired;
}
private void Update()
{
if (hideAt >= 0f && Time.time >= hideAt)
{
for (int i = 0; i < pool.Count; i++) pool[i].enabled = false;
hideAt = -1f;
}
}
private void HandleAreaFired(Vector3[] positions)
{
if (positions == null) return;
Vector3 origin = emitter != null ? emitter.position : transform.position + Vector3.up;
for (int i = 0; i < positions.Length; i++)
{
var lr = GetLine(i);
DrawArc(lr, origin, positions[i]);
lr.enabled = true;
}
// Disable any leftover lines from a previous, larger tick.
for (int i = positions.Length; i < pool.Count; i++)
pool[i].enabled = false;
hideAt = Time.time + arcDuration;
}
private LineRenderer GetLine(int index)
{
while (pool.Count <= index)
{
var go = new GameObject($"Arc_{pool.Count}");
go.transform.SetParent(transform, worldPositionStays: false);
var lr = go.AddComponent<LineRenderer>();
lr.useWorldSpace = true;
lr.widthMultiplier = arcWidth;
lr.numCapVertices = 2;
lr.textureMode = LineTextureMode.Stretch;
lr.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off;
lr.material = arcMaterial != null
? arcMaterial
: new Material(Shader.Find("Sprites/Default"));
lr.startColor = lr.endColor = arcColor;
lr.enabled = false;
pool.Add(lr);
}
return pool[index];
}
private void DrawArc(LineRenderer lr, Vector3 a, Vector3 b)
{
int n = Mathf.Max(2, segmentsPerArc);
lr.positionCount = n;
for (int s = 0; s < n; s++)
{
float t = s / (float)(n - 1);
Vector3 p = Vector3.Lerp(a, b, t);
// Jitter interior points only — endpoints stay anchored to the coil and enemy.
if (s != 0 && s != n - 1)
p += Random.insideUnitSphere * jitter;
lr.SetPosition(s, p);
}
}
}
}

View file

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: a7c3e1f95b2d4068b1a9c4e7f2d50b83
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -90,6 +90,13 @@ namespace TD.Combat
/// </summary>
public event Action<Vector3> OnFire;
/// <summary>
/// Fired locally on ALL peers when an <see cref="TargetType.AllInRange"/> tower
/// attacks, carrying the world positions of every enemy hit this tick. A visual
/// component (e.g. <c>TeslaArcVisual</c>) subscribes to draw an arc to each.
/// </summary>
public event Action<Vector3[]> OnAreaFired;
// ----- NGO lifecycle -----------------------------------------------
public override void OnNetworkSpawn()
@ -300,10 +307,14 @@ namespace TD.Combat
public readonly float ProjectileSpeed;
public readonly int ChainCount;
public readonly float ChainRange;
// The tower's attack range. Carried here so the AllInRange path knows how far
// to sweep without re-reading the TowerDefinition.
public readonly float Range;
public CombatProfile(float damage, DamageType damageType, TargetType targetType,
float splashRadius, float slowFactor, float dotDamagePerSecond,
float effectDuration, float projectileSpeed, int chainCount, float chainRange)
float effectDuration, float projectileSpeed, int chainCount, float chainRange,
float range)
{
Damage = damage;
DamageType = damageType;
@ -315,6 +326,7 @@ namespace TD.Combat
ProjectileSpeed = projectileSpeed;
ChainCount = chainCount;
ChainRange = chainRange;
Range = range;
}
}
@ -354,7 +366,8 @@ namespace TD.Combat
}
return new CombatProfile(damage, damageType, targetType, splash, slow, dot,
duration, def.ProjectileSpeed, def.ChainCount, def.ChainRange);
duration, def.ProjectileSpeed, def.ChainCount, def.ChainRange,
def.Range);
}
// ----- Damage application ------------------------------------------
@ -377,9 +390,33 @@ namespace TD.Combat
case TargetType.Chain:
ApplyChain(p, primary, owner);
break;
case TargetType.AllInRange:
ApplyAllInRange(p, owner);
break;
}
}
// Hits every enemy within the tower's range this tick (Tesla Coil). Broadcasts the
// hit positions so a visual component can draw an arc to each.
private void ApplyAllInRange(in CombatProfile p, PlayerSlot owner)
{
int count = Physics.OverlapSphereNonAlloc(
transform.position, p.Range, s_overlapBuffer, enemyLayerMask);
var hitPositions = new List<Vector3>(count);
for (int i = 0; i < count; i++)
{
var eh = s_overlapBuffer[i].GetComponent<EnemyHealth>();
if (eh == null || eh.IsDead) continue;
HitEnemy(p, eh, owner);
hitPositions.Add(eh.transform.position);
}
if (hitPositions.Count > 0)
AreaFiredClientRpc(hitPositions.ToArray());
}
private void ApplySplash(in CombatProfile p, EnemyHealth primary,
Vector3 origin, PlayerSlot owner)
{
@ -513,6 +550,14 @@ namespace TD.Combat
// and draw a line renderer through these world positions.
}
[ClientRpc]
private void AreaFiredClientRpc(Vector3[] hitPositions)
{
// Visual consumers (TeslaArcVisual) subscribe to OnAreaFired to draw an
// electrical arc from the tower's emitter to each hit enemy.
OnAreaFired?.Invoke(hitPositions);
}
// ----- NV callback (fires on all peers) ----------------------------
private void HandleReplicatedTargetChanged(