// Assets/_Project/Scripts/Combat/TeslaArcVisual.cs using System.Collections.Generic; using UnityEngine; namespace TD.Combat { /// /// Local-only electrical-arc visual for /// towers (Tesla Coil). Subscribes to 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. /// /// /// Pure visualization — runs on every peer from the replicated fire broadcast; nothing /// here is networked. Bolts are pooled s reused across ticks. /// The glow (bloom) comes from the material + an HDR color above the bloom threshold; see /// and the project's post-processing Volume. /// [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 pool = new List(); private Light flashLight; private readonly List impactLightPool = new List(); // 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(); 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(); 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(); 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(); 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); } } } }