305 lines
13 KiB
C#
305 lines
13 KiB
C#
// 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 an
|
|
/// arced, jittering bolt from the tower's emitter to every enemy hit, re-randomized each
|
|
/// frame so it crackles, then hidden after a short flash.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Pure visualization — runs on every peer from the replicated fire broadcast; nothing
|
|
/// here is networked. Bolts are pooled <see cref="LineRenderer"/>s reused across ticks.
|
|
/// The glow (bloom) comes from the material + an HDR color above the bloom threshold; see
|
|
/// <see cref="emissionIntensity"/> and the project's post-processing Volume.
|
|
/// </remarks>
|
|
[RequireComponent(typeof(TowerCombat))]
|
|
public class TeslaArcVisual : MonoBehaviour
|
|
{
|
|
[Tooltip("Point the bolts 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 bolts. Use an UNLIT ADDITIVE material for a convincing " +
|
|
"glow (a Lit material won't bloom well on a thin line). If unset, a fallback " +
|
|
"additive material is created at runtime.")]
|
|
[SerializeField] private Material arcMaterial;
|
|
|
|
[Tooltip("Base bolt color. Multiplied by Emission Intensity to push it into HDR so " +
|
|
"Bloom picks it up.")]
|
|
[ColorUsage(true, true)]
|
|
[SerializeField] private Color arcColor = new Color(0.55f, 0.8f, 1f, 1f);
|
|
|
|
[Tooltip("HDR multiplier on the color. Must exceed the Bloom threshold (>1) for the " +
|
|
"bolt to glow. Raise for a hotter, brighter arc.")]
|
|
[SerializeField] private float emissionIntensity = 4f;
|
|
|
|
[SerializeField] private float arcWidth = 0.06f;
|
|
|
|
[Tooltip("How long each flash stays visible, in seconds.")]
|
|
[SerializeField] private float arcDuration = 0.12f;
|
|
|
|
[Tooltip("Points per bolt. More = smoother arc and finer crackle. ~12-20 looks good.")]
|
|
[SerializeField] private int segmentsPerArc = 16;
|
|
|
|
[Tooltip("Overall arc/bow as a fraction of bolt length (0 = straight line). The bolt " +
|
|
"lifts toward the middle for an electrical-arc curve.")]
|
|
[SerializeField] private float arcHeight = 0.18f;
|
|
|
|
[Tooltip("Random crackle displacement (world units) at the middle of the bolt, " +
|
|
"tapering to 0 at both ends so it stays anchored to the coil and enemy.")]
|
|
[SerializeField] private float jitter = 0.18f;
|
|
|
|
[Header("Light flash")]
|
|
[Tooltip("Pulse a real point light at the emitter when firing so the bolts actually " +
|
|
"illuminate nearby enemies (emission/bloom alone don't cast light).")]
|
|
[SerializeField] private bool castLight = true;
|
|
|
|
[Tooltip("Color of the flash light.")]
|
|
[SerializeField] private Color lightColor = new Color(0.6f, 0.85f, 1f);
|
|
|
|
[Tooltip("Peak intensity of the flash. The light flickers between this and ~70% of " +
|
|
"it while firing for an electrical pulse.")]
|
|
[SerializeField] private float lightIntensity = 8f;
|
|
|
|
[Tooltip("Range of the emitter flash light — covers enemies near the coil.")]
|
|
[SerializeField] private float lightRange = 12f;
|
|
|
|
[Tooltip("Also flash a small light at each bolt's IMPACT point, so enemies struck at " +
|
|
"the far edge of range get lit by the bolt — not just those near the coil.")]
|
|
[SerializeField] private bool castImpactLight = true;
|
|
|
|
[Tooltip("Peak intensity of each impact light (usually lower than the emitter flash).")]
|
|
[SerializeField] private float impactLightIntensity = 4f;
|
|
|
|
[Tooltip("Range of each impact light — only needs to cover the struck enemy, so keep it small.")]
|
|
[SerializeField] private float impactLightRange = 5f;
|
|
|
|
[Tooltip("Performance cap on simultaneous impact lights. URP limits how many lights " +
|
|
"affect each object, and many towers add up — targets beyond this cap rely on " +
|
|
"the emitter flash. Raise carefully.")]
|
|
[SerializeField] private int maxImpactLights = 8;
|
|
|
|
private TowerCombat combat;
|
|
private readonly List<LineRenderer> pool = new List<LineRenderer>();
|
|
private Light flashLight;
|
|
private readonly List<Light> impactLightPool = new List<Light>();
|
|
|
|
// Snapshot of the current flash so Update can re-randomize the bolts each frame.
|
|
private Vector3[] activeTargets;
|
|
private int activeCount;
|
|
private bool active;
|
|
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 (!active) return;
|
|
|
|
if (Time.time >= hideAt)
|
|
{
|
|
HideAll();
|
|
active = false;
|
|
return;
|
|
}
|
|
|
|
// Re-draw every frame with fresh jitter so the bolts crackle while visible.
|
|
RedrawBolts();
|
|
}
|
|
|
|
private void HandleAreaFired(Vector3[] positions)
|
|
{
|
|
if (positions == null || positions.Length == 0) return;
|
|
|
|
activeTargets = positions; // RPC hands us a fresh array each call — safe to keep
|
|
activeCount = positions.Length;
|
|
active = true;
|
|
hideAt = Time.time + arcDuration;
|
|
|
|
RedrawBolts();
|
|
}
|
|
|
|
private void RedrawBolts()
|
|
{
|
|
Vector3 origin = emitter != null ? emitter.position : transform.position + Vector3.up;
|
|
|
|
for (int i = 0; i < activeCount; i++)
|
|
{
|
|
var lr = GetLine(i);
|
|
DrawBolt(lr, origin, activeTargets[i]);
|
|
lr.enabled = true;
|
|
}
|
|
|
|
// Disable any leftover bolts from a previous, larger tick.
|
|
for (int i = activeCount; i < pool.Count; i++)
|
|
pool[i].enabled = false;
|
|
|
|
// Pulse the emitter light so the coil/source lights nearby enemies.
|
|
if (castLight)
|
|
{
|
|
var fl = GetFlashLight();
|
|
fl.intensity = lightIntensity * Random.Range(0.7f, 1f); // electrical flicker
|
|
fl.enabled = true;
|
|
}
|
|
|
|
// Pulse a light at each bolt's impact point so far-struck enemies get lit by the
|
|
// bolt itself. Capped for performance; extra targets rely on the emitter flash.
|
|
if (castImpactLight)
|
|
{
|
|
int lit = Mathf.Min(activeCount, maxImpactLights);
|
|
for (int i = 0; i < lit; i++)
|
|
{
|
|
var il = GetImpactLight(i);
|
|
il.transform.position = activeTargets[i];
|
|
il.intensity = impactLightIntensity * Random.Range(0.7f, 1f);
|
|
il.enabled = true;
|
|
}
|
|
for (int i = lit; i < impactLightPool.Count; i++)
|
|
impactLightPool[i].enabled = false;
|
|
}
|
|
}
|
|
|
|
private void HideAll()
|
|
{
|
|
for (int i = 0; i < pool.Count; i++)
|
|
pool[i].enabled = false;
|
|
|
|
if (flashLight != null) flashLight.enabled = false;
|
|
for (int i = 0; i < impactLightPool.Count; i++)
|
|
impactLightPool[i].enabled = false;
|
|
}
|
|
|
|
// Lazily creates a small world-space point light for a bolt's impact point.
|
|
private Light GetImpactLight(int index)
|
|
{
|
|
while (impactLightPool.Count <= index)
|
|
{
|
|
var go = new GameObject($"ArcImpactLight_{impactLightPool.Count}");
|
|
go.transform.SetParent(transform, worldPositionStays: true);
|
|
|
|
var l = go.AddComponent<Light>();
|
|
l.type = LightType.Point;
|
|
l.color = lightColor;
|
|
l.range = impactLightRange;
|
|
l.shadows = LightShadows.None;
|
|
l.intensity = 0f;
|
|
l.enabled = false;
|
|
impactLightPool.Add(l);
|
|
}
|
|
return impactLightPool[index];
|
|
}
|
|
|
|
// Lazily creates the point light at the emitter (parented so it tracks the coil).
|
|
private Light GetFlashLight()
|
|
{
|
|
if (flashLight == null)
|
|
{
|
|
var go = new GameObject("ArcFlashLight");
|
|
Transform parent = emitter != null ? emitter : transform;
|
|
go.transform.SetParent(parent, worldPositionStays: false);
|
|
go.transform.localPosition = Vector3.zero;
|
|
|
|
flashLight = go.AddComponent<Light>();
|
|
flashLight.type = LightType.Point;
|
|
flashLight.color = lightColor;
|
|
flashLight.range = lightRange;
|
|
flashLight.shadows = LightShadows.None; // perf: brief flashes don't need shadows
|
|
flashLight.intensity = 0f;
|
|
flashLight.enabled = false;
|
|
}
|
|
return flashLight;
|
|
}
|
|
|
|
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 = 4;
|
|
lr.numCornerVertices = 4;
|
|
lr.alignment = LineAlignment.View; // face the camera, like a billboard
|
|
lr.textureMode = LineTextureMode.Stretch;
|
|
lr.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off;
|
|
lr.material = arcMaterial != null ? arcMaterial : CreateFallbackMaterial();
|
|
|
|
// HDR color so Bloom in the post stack picks the bolt up as a glow.
|
|
Color hdr = arcColor * Mathf.Max(1f, emissionIntensity);
|
|
hdr.a = 1f;
|
|
lr.startColor = lr.endColor = hdr;
|
|
|
|
lr.enabled = false;
|
|
pool.Add(lr);
|
|
}
|
|
return pool[index];
|
|
}
|
|
|
|
// Additive unlit fallback so bolts still glow if no material is assigned. Prefer
|
|
// assigning a real material in the inspector.
|
|
private static Material CreateFallbackMaterial()
|
|
{
|
|
var shader = Shader.Find("Universal Render Pipeline/Particles/Unlit")
|
|
?? Shader.Find("Sprites/Default");
|
|
return new Material(shader);
|
|
}
|
|
|
|
// Builds an arced, jagged bolt from a to b. A smooth sine bow gives the overall arc;
|
|
// tapered random perpendicular offsets give the electrical crackle. Endpoints stay
|
|
// exactly on the coil and the enemy.
|
|
private void DrawBolt(LineRenderer lr, Vector3 a, Vector3 b)
|
|
{
|
|
int n = Mathf.Max(2, segmentsPerArc);
|
|
lr.positionCount = n;
|
|
|
|
Vector3 delta = b - a;
|
|
float len = delta.magnitude;
|
|
Vector3 dir = len > 1e-4f ? delta / len : Vector3.forward;
|
|
|
|
// Bow toward world-up, projected perpendicular to the bolt (any perpendicular for
|
|
// near-vertical bolts), plus a sideways axis for 3D crackle.
|
|
Vector3 bowDir = Vector3.up - dir * Vector3.Dot(Vector3.up, dir);
|
|
if (bowDir.sqrMagnitude < 1e-4f) bowDir = Vector3.Cross(dir, Vector3.right);
|
|
bowDir.Normalize();
|
|
Vector3 sideDir = Vector3.Cross(dir, bowDir);
|
|
|
|
float bow = arcHeight * len;
|
|
|
|
for (int s = 0; s < n; s++)
|
|
{
|
|
float t = s / (float)(n - 1);
|
|
Vector3 p = a + delta * t;
|
|
|
|
// Smooth arc — sine peaks at the middle, zero at both ends.
|
|
float curve = Mathf.Sin(t * Mathf.PI);
|
|
p += bowDir * (bow * curve);
|
|
|
|
// Crackle on interior points, tapered to nothing at the ends.
|
|
if (s != 0 && s != n - 1)
|
|
{
|
|
p += bowDir * (Random.Range(-jitter, jitter) * curve);
|
|
p += sideDir * (Random.Range(-jitter, jitter) * curve);
|
|
}
|
|
|
|
lr.SetPosition(s, p);
|
|
}
|
|
}
|
|
}
|
|
}
|