using BumpCombat.Constants; using System.Collections.Generic; using UnityEngine; namespace BumpCombat.Defense { [DisallowMultipleComponent] public sealed class DefenseTower : MonoBehaviour { private DefensePrototypeController owner; private DefenseConstants tuning; private float attackCooldown; private TextMesh label; private LineRenderer effectRange; private static Material effectMaterial; private int summonCount; private int nextSummonIndex; private int targetSummonCount; private float summonRespawnRemaining; private readonly Stack freeSummonSlots = new(); private readonly Dictionary deadSummons = new(); private readonly HashSet attackEffectRoots = new(); private GameObject flameBeamEffect; private SpriteRenderer flameBeamRenderer; private DefenseRouteAgent flameBeamTarget; private float flameBeamStreakStartedAt; private float flameBeamLastSuccessfulAttackAt; private bool flameBeamStreakActive; private SpriteRenderer bodyRenderer; private SpriteRenderer supportAuraRenderer; private SpriteRenderer supportRangeRenderer; private Sprite[] supportRangeFrames; private float supportRangeFrameTime; private int supportRangeFrame; private Sprite[] bodyIdleFrames; private Sprite[] bodyAttackFrames; private string bodyIdleKey; private string bodyAttackKey; private float bodyFrameTime; private int bodyFrame; private bool bodyAttacking; private string supportAuraKey; private Sprite[] supportAuraFrames; public DefenseTowerKind Kind { get; private set; } public DefenseTowerBranch Branch { get; private set; } public int Tier { get; private set; } = 1; public int PadIndex { get; private set; } public bool IsOperational => isActiveAndEnabled && owner != null && owner.CanOperateTowers; public bool IsDamaging => Kind != DefenseTowerKind.Empowering; public Color HitFeedbackColor => GetTowerColor(); public float SummonUnitMaximumHealth => tuning != null ? tuning.SummonUnitMaximumHealth : 1f; public float SummonUnitCollisionRadius => tuning != null ? tuning.SummonUnitCollisionRadius : 0.32f; public DefenseRouteAgent CommandTarget { get; private set; } public void Configure( DefensePrototypeController controller, DefenseTowerKind kind, int padIndex, DefenseConstants constants) { owner = controller; Kind = kind; PadIndex = padIndex; tuning = constants; Tier = 1; Branch = DefenseTowerBranch.None; summonCount = 0; targetSummonCount = Kind == DefenseTowerKind.Summon ? tuning.SummonUnitCount : 0; CreateVisuals(); if (Kind == DefenseTowerKind.Summon) { AddSummonUnits(tuning.SummonUnitCount); } } public bool Upgrade(DefenseTowerBranch branch) { if (Tier >= 4 || (Tier < 3 && branch != DefenseTowerBranch.None) || (Tier == 3 && branch != DefenseTowerBranch.First && branch != DefenseTowerBranch.Second)) { return false; } Tier++; if (Tier == 4) { Branch = branch; } if (Kind == DefenseTowerKind.Summon) { int upgradedSummonCount = Tier == 4 && Branch == DefenseTowerBranch.First ? 1 : targetSummonCount + (Tier == 4 ? 2 : 1); ReplaceSummonUnits(upgradedSummonCount); } UpdateLabel(); UpdateEffectRange(); UpdateBodyArt(); UpdateSupportAura(); foreach (DefenseSummonUnit unit in GetComponentsInChildren()) { unit.RefreshArt(); } return true; } private void OnDisable() { if (supportAuraRenderer != null) { supportAuraRenderer.enabled = false; } ClearAttackEffects(); EndFlamethrowerBeam(immediate: true, resetStreak: true); CommandTarget = null; } private void TrackAttackEffect(GameObject effect) { if (effect == null) return; attackEffectRoots.Add(effect); DefenseSpriteSequence[] sequences = effect.GetComponentsInChildren(true); if (sequences.Length == 0) { DestroyAttackEffectWhenComplete(effect); return; } foreach (DefenseSpriteSequence sequence in sequences) { TrackAttackSequence(effect, sequence); } } private void TrackAttackSequence(GameObject effect, DefenseSpriteSequence sequence) { if (effect == null || sequence == null) return; attackEffectRoots.Add(effect); sequence.SetCompletionCallback(() => DestroyAttackEffectWhenComplete(effect)); } private void DestroyAttackEffectWhenComplete(GameObject effect) { if (effect == null || !attackEffectRoots.Contains(effect)) return; foreach (DefenseSpriteSequence sequence in effect.GetComponentsInChildren(true)) { if (!sequence.IsFinished) return; } attackEffectRoots.Remove(effect); Destroy(effect); } private void ClearAttackEffects() { foreach (GameObject effect in attackEffectRoots) { if (effect != null) Destroy(effect); } attackEffectRoots.Clear(); } private bool IsFireT4Flamethrower => Kind == DefenseTowerKind.Direct && Tier == 4 && Branch == DefenseTowerBranch.Second; private bool IsFlamethrowerTargetValid(DefenseRouteAgent target) { return target != null && !target.HasArrived && target.Enemy != null && !target.Enemy.IsDead && Vector2.Distance(transform.position, target.transform.position) <= GetEffectRange(); } private void UpdateFlamethrowerBeam() { if (flameBeamStreakActive && Time.time - flameBeamLastSuccessfulAttackAt >= tuning.FireT4BeamResetAfterSeconds) { EndFlamethrowerBeam(); ResetFlamethrowerStreak(); return; } if (flameBeamEffect == null) return; if (!IsOperational || !IsFireT4Flamethrower || !IsFlamethrowerTargetValid(flameBeamTarget)) { EndFlamethrowerBeam(); return; } Sprite[] frames = DefenseTowerArt.Load("Effects/Fire/Eyeball Monster_Beam-Clean"); if (Time.time - flameBeamStreakStartedAt >= DefenseTowerArtBounds.FireBeamIgnitionSeconds && flameBeamRenderer.sprite != frames[1]) flameBeamRenderer.sprite = frames[1]; FitFlamethrowerBeam(GetTargetHitCenter(flameBeamTarget, flameBeamTarget.transform.position)); } private bool PulseFlamethrower(DefenseRouteAgent target, float tierScale) { float now = Time.time; if (flameBeamStreakActive && now - flameBeamLastSuccessfulAttackAt >= tuning.FireT4BeamResetAfterSeconds) { EndFlamethrowerBeam(); ResetFlamethrowerStreak(); } if (flameBeamEffect == null || flameBeamTarget != target) { EndFlamethrowerBeam(immediate: true); Sprite[] frames = DefenseTowerArt.Load("Effects/Fire/Eyeball Monster_Beam-Clean"); flameBeamEffect = new GameObject("Fire T4 Sustained Beam"); flameBeamEffect.transform.SetParent(transform.parent, false); GameObject beamObject = new("Beam Sprite"); beamObject.transform.SetParent(flameBeamEffect.transform, false); flameBeamRenderer = beamObject.AddComponent(); flameBeamRenderer.sortingOrder = 14; flameBeamRenderer.sprite = frames[0]; flameBeamTarget = target; if (!flameBeamStreakActive) flameBeamStreakStartedAt = now; SetHitRendererSorting(flameBeamRenderer, target); FitFlamethrowerBeam(GetTargetHitCenter(target, target.transform.position)); } float elapsed = Mathf.Max(0f, now - flameBeamStreakStartedAt); float streakMultiplier = elapsed >= tuning.FireT4BeamMaximumTierSeconds ? tuning.FireT4BeamMaximumMultiplier : elapsed >= tuning.FireT4BeamSecondTierSeconds ? tuning.FireT4BeamSecondTierMultiplier : 1f; bool hit = owner.TryApplyTowerDamage(this, target, tuning.DirectTowerDamage * tierScale * tuning.FireT4BeamBaseDamageMultiplier * streakMultiplier); if (!hit) { EndFlamethrowerBeam(); return false; } flameBeamStreakActive = true; flameBeamLastSuccessfulAttackAt = now; PlayBodyAttack(target.transform.position.x < transform.position.x); FitFlamethrowerBeam(GetTargetHitCenter(target, target.transform.position)); return true; } private void FitFlamethrowerBeam(Vector2 end) { if (flameBeamRenderer == null || flameBeamRenderer.sprite == null) return; Vector2 start = GetStaffTipWorldPosition(); Vector2 direction = end - start; float distance = direction.magnitude; float ppu = flameBeamRenderer.sprite.pixelsPerUnit; float scaleX = distance * ppu / (DefenseTowerArtBounds.FireBeamEndX - DefenseTowerArtBounds.FireBeamStartX); float angle = distance > 0f ? Mathf.Atan2(direction.y, direction.x) * Mathf.Rad2Deg : 0f; Transform beam = flameBeamRenderer.transform; beam.rotation = Quaternion.Euler(0f, 0f, angle); float elapsed = flameBeamStreakActive ? Mathf.Max(0f, Time.time - flameBeamStreakStartedAt) : 0f; float widthMultiplier = elapsed >= tuning.FireT4BeamMaximumTierSeconds ? tuning.FireT4BeamMaximumWidthMultiplier : elapsed >= tuning.FireT4BeamSecondTierSeconds ? tuning.FireT4BeamSecondTierWidthMultiplier : DefenseTowerArtBounds.FireT4BeamBaseWidthMultiplier; beam.localScale = new Vector3(scaleX, widthMultiplier, 1f); Vector2 localStart = new( (DefenseTowerArtBounds.FireBeamStartX - flameBeamRenderer.sprite.pivot.x) / ppu, (flameBeamRenderer.sprite.rect.height - DefenseTowerArtBounds.FireBeamCenterY - flameBeamRenderer.sprite.pivot.y) / ppu); beam.position = start - (Vector2)beam.right * (localStart.x * scaleX) - (Vector2)beam.up * (localStart.y * widthMultiplier); } private Vector2 GetStaffTipWorldPosition() { if (bodyRenderer == null || bodyRenderer.sprite == null) return transform.position; Vector2 tipPixels = DefenseTowerArtBounds.StaffTipPixels(bodyAttacking, bodyFrame); Vector2 localPixels = tipPixels - bodyRenderer.sprite.pivot; localPixels.y = bodyRenderer.sprite.rect.height - tipPixels.y - bodyRenderer.sprite.pivot.y; if (bodyRenderer.flipX) localPixels.x = -localPixels.x; return bodyRenderer.transform.TransformPoint(localPixels / bodyRenderer.sprite.pixelsPerUnit); } private void ResetFlamethrowerStreak() { flameBeamStreakActive = false; flameBeamStreakStartedAt = 0f; flameBeamLastSuccessfulAttackAt = 0f; } private void EndFlamethrowerBeam(bool immediate = false, bool resetStreak = false) { if (flameBeamEffect != null) { Sprite[] frames = DefenseTowerArt.Load("Effects/Fire/Eyeball Monster_Beam-Clean"); if (flameBeamRenderer != null && frames.Length >= 3) flameBeamRenderer.sprite = frames[2]; if (immediate) Destroy(flameBeamEffect); else Destroy(flameBeamEffect, DefenseTowerArtBounds.FireBeamExtinguishSeconds); flameBeamEffect = null; flameBeamRenderer = null; flameBeamTarget = null; } if (resetStreak) ResetFlamethrowerStreak(); } private void Update() { BumpCombat.Core.RunManager runManager = BumpCombat.Core.RunManager.Instance; if (runManager != null && (runManager.IsGameOver || runManager.IsRunCleared || runManager.IsTitleScreen)) { ClearAttackEffects(); EndFlamethrowerBeam(immediate: true, resetStreak: true); } UpdateBodyAnimation(); UpdateFlamethrowerBeam(); if (Kind == DefenseTowerKind.Summon && IsOperational && summonCount < targetSummonCount) { summonRespawnRemaining -= Time.deltaTime; if (summonRespawnRemaining <= 0f) { AddSummonUnits(1); summonRespawnRemaining = tuning.SummonUnitRespawnSeconds; } } if (Kind == DefenseTowerKind.Summon && IsOperational) { CommandTarget = owner.FindNearestTarget( transform.position, tuning.SummonAreaRadius, Kind); if (CommandTarget != null && bodyRenderer != null) { bodyRenderer.flipX = CommandTarget.transform.position.x < transform.position.x; } } if (Kind == DefenseTowerKind.Empowering) UpdateEffectRange(); else if (effectRange != null) effectRange.transform.localPosition = DefenseTowerArtBounds.CharacterShadowOffset( Kind, bodyRenderer != null && bodyRenderer.flipX); UpdateSupportAura(); UpdateSupportRangeAnimation(); if (!IsOperational || Kind == DefenseTowerKind.Summon || Kind == DefenseTowerKind.Empowering) { return; } attackCooldown -= Time.deltaTime; if (attackCooldown > 0f) { return; } if (owner.TryAttackFromTower(this)) { float interval = Kind switch { DefenseTowerKind.Direct => tuning.DirectTowerAttackInterval, DefenseTowerKind.Lightning => tuning.LightningInterval, DefenseTowerKind.Cold => tuning.ColdInterval, _ => tuning.SeismicInterval, }; if (Tier == 4 && Kind == DefenseTowerKind.Direct && Branch == DefenseTowerBranch.Second) { interval *= 0.4f; } attackCooldown = Mathf.Max(0.05f, interval / (1f + 0.15f * (Tier - 1)) * owner.GetTowerAttackIntervalMultiplier(this)); } } public bool TryPulse() { if (!IsOperational || Kind == DefenseTowerKind.Empowering || Kind == DefenseTowerKind.Summon) { return false; } float range = GetEffectRange(); DefenseRouteAgent target; if (IsFireT4Flamethrower) { if (IsFlamethrowerTargetValid(flameBeamTarget)) target = flameBeamTarget; else { EndFlamethrowerBeam(); target = owner.FindNearestTarget(transform.position, range, Kind); } } else { target = owner.FindNearestTarget( transform.position, range, Kind, Kind == DefenseTowerKind.Cold || Kind == DefenseTowerKind.Seismic); } if (target == null) { if (IsFireT4Flamethrower) EndFlamethrowerBeam(); return false; } Vector2 center = target.transform.position; Vector2 lightningVisualCenter = DefenseTowerArtBounds.EnemyShadowWorldPosition( target.Enemy, target.transform.position); float scale = 1f + tuning.TowerDamageBonusPerTier * (Tier - 1); if (IsFireT4Flamethrower) return PulseFlamethrower(target, scale); float impactRadius = 0f; bool applied; System.Action projectileImpact = null; switch (Kind) { case DefenseTowerKind.Direct: if (Tier == 4) { impactRadius = 1.8f; applied = true; projectileImpact = () => { if (target != null && !target.HasArrived && target.Enemy != null && !target.Enemy.IsDead) { AttackArea(target.transform.position, impactRadius, tuning.DirectTowerDamage * scale * 1.6f); } }; } else { applied = true; projectileImpact = () => owner.TryApplyTowerDamage(this, target, tuning.DirectTowerDamage * scale); } break; case DefenseTowerKind.Lightning: impactRadius = Tier == 4 ? (Branch == DefenseTowerBranch.First ? 3f : 2.4f) : tuning.LightningRadius; applied = AttackArea(center, impactRadius, tuning.LightningDamage * scale * (Tier == 4 && Branch == DefenseTowerBranch.Second ? 1.4f : 1f), Tier == 4 && Branch == DefenseTowerBranch.First ? 3 : int.MaxValue); break; case DefenseTowerKind.Cold: if (Tier == 4 && Branch == DefenseTowerBranch.Second) { impactRadius = 1.65f; applied = true; } else { applied = true; projectileImpact = () => { if (target == null || target.HasArrived || target.Enemy == null || target.Enemy.IsDead) return; owner.TryApplyTowerSlow(this, target, tuning.ColdSlowSeconds * (1f + 0.25f * (Tier - 1)), tuning.ColdSlowFraction); owner.TryApplyTowerDamage(this, target, tuning.ColdDamage * scale * (Tier == 4 ? 1.8f : 1f)); if (Tier == 4) owner.TryApplyTowerStun(this, target, 0.3f); }; } break; case DefenseTowerKind.Seismic: impactRadius = Tier == 4 ? (Branch == DefenseTowerBranch.Second ? 2.2f : 1.8f) : tuning.SeismicRadius; applied = Tier == 4 && Branch == DefenseTowerBranch.Second ? AttackArea(center, impactRadius, tuning.SeismicDamage * scale, int.MaxValue, 2f, 0.5f) : AttackArea(center, impactRadius, tuning.SeismicDamage * scale, int.MaxValue, 0f, 0f, tuning.SeismicStunSeconds * (Tier == 4 ? 2f : 1f)); break; default: return false; } if (applied) { PlayBodyAttack(center.x < transform.position.x); bool shadowCenteredCircle = Kind == DefenseTowerKind.Seismic || IsBlizzard; ShowAttackEffect(center, impactRadius, visualCenter: shadowCenteredCircle ? lightningVisualCenter : null, projectileImpact: projectileImpact, projectileTarget: target, damageScale: scale); } return applied; } private bool AttackArea( Vector2 center, float radius, float damage, int maxTargets = int.MaxValue, float slowSeconds = 0f, float slowFraction = 0f, float stunSeconds = 0f) { bool applied = false; int count = 0; foreach (DefenseRouteAgent agent in owner.GetTargetsInRange(center, radius, Kind)) { if (count++ >= maxTargets) { break; } bool damaged = owner.TryApplyTowerDamage(this, agent, damage); bool slowed = slowSeconds > 0f && owner.TryApplyTowerSlow(this, agent, slowSeconds, slowFraction); bool stunned = stunSeconds > 0f && owner.TryApplyTowerStun(this, agent, stunSeconds); applied |= damaged || slowed || stunned; } return applied; } public bool TryAttack(DefenseRouteAgent target) { if (target == null || owner == null || !IsOperational) { return false; } float damage = Kind == DefenseTowerKind.Summon ? tuning.SummonDamage : tuning.DirectTowerDamage; damage *= 1f + tuning.TowerDamageBonusPerTier * (Tier - 1); if (Tier == 4 && Kind == DefenseTowerKind.Summon && Branch == DefenseTowerBranch.First) { damage *= 1.8f; } if (Tier == 4 && Kind == DefenseTowerKind.Summon && Branch == DefenseTowerBranch.Second) { return true; } bool hit = owner.TryApplyTowerDamage(this, target, damage); if (hit && Tier == 4 && Kind == DefenseTowerKind.Summon && Branch == DefenseTowerBranch.First) { owner.TryApplyTowerStun(this, target, 0.3f); } return hit; } public void ShowSummonAttackEffect(Vector2 origin, DefenseRouteAgent target) { if (!CanApplyProjectileImpact(target) || Kind != DefenseTowerKind.Summon) return; Vector2 center = target.transform.position; if (Tier == 4 && Branch == DefenseTowerBranch.Second) { GameObject effect = new("Summon Attack Effect"); effect.transform.SetParent(transform.parent, false); for (int i = 0; i < 4; i++) { Vector2 position = Vector2.Lerp(origin, center, (i + 1) / 5f); CreateAnimatedArt(effect.transform, $"Shot Sprite {i + 1}", "Summons/LavaSlime/GroundSpike-Segment", position, Vector2.one, 0f, 14, startDelay: i * 0.12f); } SpriteRenderer projectile = CreateAnimatedArt(effect.transform, "Shot Sprite 5", "Summons/LavaSlime/GroundSpike-Segment", origin, Vector2.one, 0f, 14, startDelay: 0.48f); projectile.GetComponent().Travel( origin, center, 0.16f, () => ApplyProjectileImpact(effect, target, null, 0f, () => { float damage = tuning.SummonDamage * (1f + tuning.TowerDamageBonusPerTier * (Tier - 1)); owner.TryApplyTowerDamage(this, target, damage); }), () => target != null && !target.HasArrived && target.Enemy != null ? (Vector2)target.transform.position : center, () => IsOperational); TrackAttackEffect(effect); return; } ShowAttackEffect(center, 0f, source: origin, projectileTarget: target); } private float GetEffectRange() { if (Kind == DefenseTowerKind.Summon) { return tuning.SummonAreaRadius; } if (Kind == DefenseTowerKind.Empowering) { return tuning.SupportRadius; } return Kind == DefenseTowerKind.Direct && Tier == 4 && Branch == DefenseTowerBranch.Second ? tuning.TowerRange * 0.5f : tuning.TowerRange; } private void CreateEffectRange() { GameObject rangeObject = new("Effect Range"); rangeObject.transform.SetParent(transform, false); if (Kind == DefenseTowerKind.Empowering) { supportRangeRenderer = rangeObject.AddComponent(); supportRangeRenderer.sortingOrder = 3; supportRangeFrames = DefenseTowerArt.Load("SupportRange/T1-Aura"); if (supportRangeFrames.Length > 0) { supportRangeRenderer.sprite = supportRangeFrames[0]; supportRangeRenderer.color = new Color(1f, 1f, 1f, tuning.SupportRangeAlpha); } UpdateEffectRange(); return; } effectRange = rangeObject.AddComponent(); effectRange.useWorldSpace = false; effectRange.loop = true; effectRange.positionCount = 48; effectRange.sharedMaterial = GetEffectMaterial(); effectRange.sortingOrder = 3; Color color = GetTowerColor(); color.a = Kind == DefenseTowerKind.Empowering ? 0.35f : IsBlizzard ? DefenseTowerArtBounds.BlizzardRangeAlpha : 0.22f; effectRange.startColor = color; effectRange.endColor = color; UpdateEffectRange(); } private void UpdateEffectRange() { if (Kind == DefenseTowerKind.Empowering && supportRangeRenderer != null) { Sprite sprite = supportRangeRenderer.sprite; if (sprite == null) return; RectInt bounds = DefenseConstants.SupportRangeAlphaBounds[Mathf.Clamp(supportRangeFrame, 0, DefenseConstants.SupportRangeAlphaBounds.Length - 1)]; Vector2 lossyScale = transform.lossyScale; Vector2 localScale = new( tuning.SupportRadius * 2f * sprite.pixelsPerUnit / (bounds.width * Mathf.Max(0.001f, lossyScale.x)), tuning.SupportRadius * 2f * sprite.pixelsPerUnit / (bounds.height * Mathf.Max(0.001f, lossyScale.y))); Color[] colors = tuning.SupportTierColors; Color sourceColor = colors != null && colors.Length > 0 ? colors[Mathf.Clamp(Tier - 1, 0, colors.Length - 1)] : new Color32(151, 169, 119, 102); sourceColor.a *= tuning.SupportRangeAlpha; supportRangeRenderer.color = sourceColor; Vector2 alphaCenterBottomLeft = new( bounds.x + bounds.width * 0.5f, 100f - bounds.y - bounds.height * 0.5f); Vector2 correctionLocal = Vector2.Scale( (alphaCenterBottomLeft - sprite.pivot) / sprite.pixelsPerUnit, localScale); Vector2 shadow = DefenseTowerArtBounds.CharacterShadowOffset( Kind, bodyRenderer != null && bodyRenderer.flipX); supportRangeRenderer.transform.localPosition = shadow - correctionLocal; supportRangeRenderer.transform.localScale = new Vector3(localScale.x, localScale.y, 1f); return; } if (effectRange == null) { return; } Color rangeColor = GetTowerColor(); rangeColor.a = IsBlizzard ? DefenseTowerArtBounds.BlizzardRangeAlpha : 0.22f; effectRange.startColor = rangeColor; effectRange.endColor = rangeColor; effectRange.transform.localPosition = DefenseTowerArtBounds.CharacterShadowOffset( Kind, bodyRenderer != null && bodyRenderer.flipX); float worldScale = Mathf.Max(0.001f, transform.lossyScale.x); float radius = GetEffectRange() / worldScale; effectRange.startWidth = 0.055f / worldScale; effectRange.endWidth = effectRange.startWidth; for (int i = 0; i < effectRange.positionCount; i++) { float angle = i * Mathf.PI * 2f / effectRange.positionCount; effectRange.SetPosition(i, new Vector3( Mathf.Cos(angle) * radius, Mathf.Sin(angle) * radius, 0f)); } } private void ShowAttackEffect( Vector2 center, float radius, Vector2? source = null, Vector2? visualCenter = null, System.Action projectileImpact = null, DefenseRouteAgent projectileTarget = null, float damageScale = 1f) { Vector2 origin = source ?? (Vector2)transform.position; Vector2 direction = center - origin; GameObject effect = new($"{Kind} Attack Effect"); effect.transform.SetParent(transform.parent, false); bool shadowCenteredCircle = Kind == DefenseTowerKind.Seismic || IsBlizzard; Vector2 effectCenter = shadowCenteredCircle ? visualCenter ?? center : center; effect.transform.position = effectCenter; Color color = GetTowerColor(); color.a = 0.9f; switch (Kind) { case DefenseTowerKind.Direct: ShowFireArt(effect.transform, origin, center, direction, projectileImpact, projectileTarget); break; case DefenseTowerKind.Lightning: Vector2 impactCenter = visualCenter ?? center; Vector2 lightningOrigin = Tier == 4 && Branch == DefenseTowerBranch.Second ? impactCenter : GetStaffTipWorldPosition(); ShowLightningArt(effect.transform, lightningOrigin, impactCenter, impactCenter - lightningOrigin, radius, projectileTarget); break; case DefenseTowerKind.Cold: ShowColdArt(effect.transform, origin, center, direction, radius, shadowCenteredCircle ? effectCenter : (Vector2?)null, projectileImpact, projectileTarget, damageScale); break; case DefenseTowerKind.Seismic: string controlKey = Tier == 4 ? (Branch == DefenseTowerBranch.First ? "Effects/Control/StoneGaze" : "Effects/Control/Temporal") : $"Effects/Control/T{Tier}-Aura"; int visibleWidth = Tier == 4 ? (Branch == DefenseTowerBranch.First ? 40 : 45) : 32 + (Tier - 1) * 6; float controlScale = GetSeismicGroundMarkerScale( projectileTarget, controlKey, visibleWidth, effect.transform); SpriteRenderer control = CreateAnimatedArt(effect.transform, "Impact Sprite", controlKey, effectCenter, Vector2.one * controlScale, 0f, 13); SetGroundHitRendererSorting(control, projectileTarget); break; case DefenseTowerKind.Summon: if (Tier == 4 && Branch == DefenseTowerBranch.Second) { for (int i = 0; i < 5; i++) { Vector2 spikePosition = Vector2.Lerp(origin, center, (i + 1) / 5f); CreateAnimatedArt(effect.transform, $"Shot Sprite {i + 1}", "Summons/LavaSlime/GroundSpike-Segment", spikePosition, Vector2.one, 0f, 14, startDelay: i * 0.12f); } } else { SpriteRenderer impact = CreateAnimatedArt(effect.transform, "Impact Sprite", "Effects/Fire/Impact", GetTargetHitCenter(projectileTarget, center), Vector2.one * 0.45f, 0f, 13); SetHitRendererSorting(impact, projectileTarget); } break; } if (radius > 0f && Kind != DefenseTowerKind.Seismic) { Color radiusColor = color; if (IsBlizzard) radiusColor.a = DefenseTowerArtBounds.BlizzardImpactRadiusAlpha; LineRenderer area = CreateEffectLine( effect.transform, "Impact Radius", radiusColor, 0.07f); area.loop = true; area.positionCount = 40; for (int i = 0; i < area.positionCount; i++) { float angle = i * Mathf.PI * 2f / area.positionCount; area.SetPosition(i, new Vector3( Mathf.Cos(angle) * radius, Mathf.Sin(angle) * radius, 0f)); } SetHitRendererSorting(area, projectileTarget); float radiusLifetime = Kind == DefenseTowerKind.Cold && Tier == 4 && Branch == DefenseTowerBranch.Second ? DefenseTowerArtBounds.BlizzardLastLandingSeconds + 0.22f : 0.22f; Destroy(area.gameObject, radiusLifetime); } TrackAttackEffect(effect); } private static Vector2 GetTargetHitCenter(DefenseRouteAgent target, Vector2 fallback) => target != null && target.Enemy != null ? (Vector2)target.Enemy.transform.position : fallback; private static float GetSeismicGroundMarkerScale( DefenseRouteAgent target, string controlKey, int maximumVisiblePixels, Transform effectRoot) { SpriteRenderer targetRenderer = target != null && target.Enemy != null ? target.Enemy.GetComponent() : null; Sprite targetSprite = targetRenderer != null ? targetRenderer.sprite : null; Sprite[] controlFrames = DefenseTowerArt.Load(controlKey); Sprite controlSprite = controlFrames.Length > 0 ? controlFrames[0] : null; if (targetSprite == null || controlSprite == null) return 0f; float markerWorldWidth = DefenseTowerArtBounds.SeismicGroundMarkerWidthPixels * Mathf.Abs(targetRenderer.transform.lossyScale.x) / targetSprite.pixelsPerUnit; float parentScale = effectRoot != null ? Mathf.Abs(effectRoot.lossyScale.x) : 1f; float frameWidthWorld = maximumVisiblePixels / controlSprite.pixelsPerUnit; return markerWorldWidth / (frameWidthWorld * Mathf.Max(0.001f, parentScale)); } private static void SetGroundHitRendererSorting(Renderer renderer, DefenseRouteAgent target) { if (renderer == null || target == null || target.Enemy == null) return; SpriteRenderer targetRenderer = target.Enemy.GetComponent(); if (targetRenderer == null) return; renderer.sortingLayerID = targetRenderer.sortingLayerID; renderer.sortingOrder = targetRenderer.sortingOrder - 1; } private static void SetHitRendererSorting(Renderer renderer, DefenseRouteAgent target) { if (renderer == null || target == null || target.Enemy == null) return; SpriteRenderer targetRenderer = target.Enemy.GetComponent(); if (targetRenderer == null) return; renderer.sortingLayerID = targetRenderer.sortingLayerID; renderer.sortingOrder = targetRenderer.sortingOrder + DefenseTowerArtBounds.TowerHitSortingOffset; } private void ApplyProjectileImpact( GameObject effect, DefenseRouteAgent target, string impactKey, float impactScale, System.Action damageAction) { if (!CanApplyProjectileImpact(target)) return; Vector2 impactPosition = GetTargetHitCenter(target, target.transform.position); damageAction?.Invoke(); if (string.IsNullOrEmpty(impactKey) || effect == null) return; SpriteRenderer impact = CreateAnimatedArt( effect.transform, "Impact Sprite", impactKey, impactPosition, Vector2.one * impactScale, 0f, 13); SetHitRendererSorting(impact, target); TrackAttackSequence(effect, impact.GetComponent()); } private bool CanApplyProjectileImpact(DefenseRouteAgent target) { return IsOperational && target != null && !target.HasArrived && target.Enemy != null && !target.Enemy.IsDead; } private void ShowFireArt( Transform parent, Vector2 origin, Vector2 center, Vector2 direction, System.Action projectileImpact, DefenseRouteAgent projectileTarget) { if (Tier == 4 && Branch == DefenseTowerBranch.First) { Vector2 end = GetTargetHitCenter(projectileTarget, center); Vector2 start = end + new Vector2(-0.81f, 0.81f); SpriteRenderer meteor = CreateAnimatedArt(parent, "Impact Sprite", "Effects/Fire/Meteor-Flight", start, Vector2.one, 0f, 13, "Effects/Fire/Meteor-Impact"); SetHitRendererSorting(meteor, projectileTarget); DefenseSpriteSequence sequence = meteor.GetComponent(); sequence.Travel(start, end, 0.32f, () => ApplyProjectileImpact( parent.gameObject, projectileTarget, null, 0f, projectileImpact), () => GetTargetHitCenter(projectileTarget, center), () => IsOperational); return; } if (Tier < 4) { origin = GetStaffTipWorldPosition(); string key = $"Effects/Fire/T{Tier}-Fireball"; Vector2 end = GetTargetHitCenter(projectileTarget, center); direction = end - origin; SpriteRenderer shot = CreateAnimatedArt(parent, "Shot Sprite", key, Vector2.Lerp(origin, end, 0.5f), Vector2.one * 0.85f, Mathf.Atan2(direction.y, direction.x) * Mathf.Rad2Deg, 14); DefenseSpriteSequence sequence = shot.GetComponent(); sequence.Travel(origin, end, 0.18f, () => ApplyProjectileImpact( parent.gameObject, projectileTarget, "Effects/Fire/Impact", 1f, projectileImpact), () => GetTargetHitCenter(projectileTarget, center), () => IsOperational); } } private void ShowLightningArt( Transform parent, Vector2 origin, Vector2 center, Vector2 direction, float radius, DefenseRouteAgent target) { if (Tier == 4 && Branch == DefenseTowerBranch.Second) { SpriteRenderer storm = CreateAnimatedArt(parent, "Shot Sprite", "Effects/Lightning/LightningStorm", center, Vector2.one, 0f, 13); storm.GetComponent() .FitLightningStormGround(center, radius); SetHitRendererSorting(storm, target); } else { string key = Tier == 4 ? "Effects/Lightning/Arc" : $"Effects/Lightning/T{Tier}-Bolt"; SpriteRenderer bolt = CreateAnimatedArt(parent, "Shot Sprite", key, origin, Vector2.one, Mathf.Atan2(direction.y, direction.x) * Mathf.Rad2Deg, 14); bolt.GetComponent().FitLightningBeam(origin, center, 1f); } if (Tier == 4) { float waveScale = Mathf.Max(0.8f, radius / 1.4f); SpriteRenderer wave = CreateAnimatedArt(parent, "Impact Sprite", "Effects/Lightning/ImpactWave", center, Vector2.one * waveScale, 0f, 13, startDelay: 0.15f); wave.GetComponent().CenterImpactWave(center, waveScale); SetHitRendererSorting(wave, target); } } private void ShowColdArt( Transform parent, Vector2 origin, Vector2 center, Vector2 direction, float radius, Vector2? visualCenter, System.Action projectileImpact, DefenseRouteAgent projectileTarget, float damageScale) { if (Tier == 4 && Branch == DefenseTowerBranch.Second) { ShowBlizzardSalvo(parent, visualCenter ?? center, radius, projectileTarget, landing => AttackBlizzardWave( landing, radius, tuning.ColdDamage * damageScale * 0.5f)); return; } string key = Tier == 4 ? "Effects/Ice/Freezebolt" : "Effects/Ice/T1-Volley"; origin = GetStaffTipWorldPosition(); direction = center - origin; Vector2 end = GetTargetHitCenter(projectileTarget, center); int projectileCount = Tier == 4 ? tuning.FreezeboltProjectileCount : Tier; float burstInterval = tuning.ColdProjectileBurstInterval; for (int i = 0; i < projectileCount; i++) { SpriteRenderer shot = CreateAnimatedArt(parent, $"Shot Sprite {i + 1}", key, Vector2.Lerp(origin, end, 0.5f), Vector2.one, Mathf.Atan2(direction.y, direction.x) * Mathf.Rad2Deg, 14, startDelay: i * burstInterval); shot.GetComponent().Travel(origin, end, 0.18f, () => ApplyProjectileImpact( parent.gameObject, projectileTarget, "Effects/Ice/Impact", 1f, projectileImpact), () => GetTargetHitCenter(projectileTarget, center), () => IsOperational, hideAtArrival: true, startProvider: GetStaffTipWorldPosition); } } private void ShowBlizzardSalvo( Transform parent, Vector2 center, float radius, DefenseRouteAgent target, System.Action landingDamageAction) { for (int i = 0; i < DefenseTowerArtBounds.BlizzardShardCount; i++) { float launchDelay = i * DefenseTowerArtBounds.BlizzardLaunchIntervalSeconds; Vector2 landing = center + DefenseTowerArtBounds.BlizzardLandingOffset(i, radius); float height = DefenseTowerArtBounds.BlizzardDropHeight + i % 4 * DefenseTowerArtBounds.BlizzardDropHeightStep; float rotation = 90f + (i % 5 - 2) * 7f; Vector2 start = landing + Vector2.up * height; float landingDelay = launchDelay + DefenseTowerArtBounds.BlizzardLandingSeconds; SpriteRenderer wave = CreateAnimatedArt(parent, $"Blizzard Wave {i}", "Effects/Ice/Blizzard-Wave-v1", landing, Vector2.one * DefenseTowerArtBounds.BlizzardWaveScale, 0f, 13, startDelay: landingDelay); wave.GetComponent().CenterImpactWave( landing, DefenseTowerArtBounds.BlizzardWaveScale); SetHitRendererSorting(wave, target); SpriteRenderer impact = CreateAnimatedArt(parent, $"Blizzard Impact {i}", "Effects/Ice/Impact", landing, Vector2.one * DefenseTowerArtBounds.BlizzardImpactScale, 0f, 14, startDelay: landingDelay); SetHitRendererSorting(impact, target); SpriteRenderer shard = CreateAnimatedArt(parent, $"Blizzard Shard {i}", "Effects/Ice/Shard-Blizzard-v1", start, Vector2.one * DefenseTowerArtBounds.BlizzardShardScale, rotation, 14, startDelay: launchDelay); SetHitRendererSorting(shard, target); shard.GetComponent().Travel( start, landing, DefenseTowerArtBounds.BlizzardLandingSeconds, () => landingDamageAction?.Invoke(landing), canAdvance: () => IsOperational); Vector2 trailOffset = Vector2.up * DefenseTowerArtBounds.BlizzardTrailOffset; SpriteRenderer trail = CreateAnimatedArt(parent, $"Blizzard Trail {i}", "Effects/Ice/Shard-Blizzard-v1", start + trailOffset, Vector2.one * DefenseTowerArtBounds.BlizzardTrailScale, rotation, 13, startDelay: launchDelay); trail.color = new Color(0.72f, 0.9f, 1f, DefenseTowerArtBounds.BlizzardTrailAlpha); SetHitRendererSorting(trail, target); trail.GetComponent().Travel( start + trailOffset, landing + trailOffset, DefenseTowerArtBounds.BlizzardLandingSeconds, canAdvance: () => IsOperational); } } private bool AttackBlizzardWave(Vector2 center, float castRadius, float damage) { float radiusX = DefenseTowerArtBounds.BlizzardWaveRadiusX; float radiusY = DefenseTowerArtBounds.BlizzardWaveRadiusY; // GetTargetsInRange measures from the agent root; the generous broadphase // leaves room for the enemy sprite's shadow offset before the ellipse test. float broadphaseRadius = castRadius + radiusX; bool applied = false; foreach (DefenseRouteAgent agent in owner.GetTargetsInRange( center, broadphaseRadius, Kind)) { if (agent == null || agent.HasArrived || agent.Enemy == null || agent.Enemy.IsDead) { continue; } Vector2 groundPosition = DefenseTowerArtBounds.EnemyShadowWorldPosition( agent.Enemy, agent.transform.position); Vector2 normalized = groundPosition - center; float ellipseDistance = normalized.x * normalized.x / (radiusX * radiusX) + normalized.y * normalized.y / (radiusY * radiusY); if (ellipseDistance > 1f) continue; bool damaged = owner.TryApplyTowerDamage(this, agent, damage); bool slowed = owner.TryApplyTowerSlow(this, agent, tuning.ColdSlowSeconds * 1.5f, tuning.ColdSlowFraction); bool stunned = owner.TryApplyTowerStun(this, agent, 0.35f); applied |= damaged || slowed || stunned; } return applied; } private bool IsBlizzard => Kind == DefenseTowerKind.Cold && Tier == 4 && Branch == DefenseTowerBranch.Second; private SpriteRenderer CreateAnimatedArt( Transform parent, string name, string key, Vector2 position, Vector2 scale, float rotation, int sortingOrder, string nextKey = null, float startDelay = 0f) { GameObject spriteObject = new(name); spriteObject.transform.SetParent(parent, false); spriteObject.transform.position = position; spriteObject.transform.localScale = new Vector3(scale.x, scale.y, 1f); spriteObject.transform.rotation = Quaternion.Euler(0f, 0f, rotation); SpriteRenderer renderer = spriteObject.AddComponent(); renderer.color = Color.white; renderer.sortingOrder = sortingOrder; Sprite[] frames = DefenseTowerArt.Load(key); Sprite[] next = string.IsNullOrEmpty(nextKey) ? null : DefenseTowerArt.Load(nextKey); DefenseSpriteSequence sequence = spriteObject.AddComponent(); sequence.Play(renderer, key, frames, true, nextKey, next, startDelay); sequence.SetCanAdvance(() => IsOperational); return renderer; } private static float ScaleToVisibleWidth(float worldWidth, float visiblePixels) { return worldWidth / (visiblePixels / 32f); } private static LineRenderer CreateEffectLine( Transform parent, string name, Color color, float width) { GameObject shape = new(name); shape.transform.SetParent(parent, false); LineRenderer line = shape.AddComponent(); line.useWorldSpace = false; line.sharedMaterial = GetEffectMaterial(); line.startWidth = width; line.endWidth = width; line.startColor = color; line.endColor = color; line.sortingOrder = 14; return line; } private static Material GetEffectMaterial() { if (effectMaterial == null) { effectMaterial = new Material(Shader.Find("Sprites/Default")); effectMaterial.hideFlags = HideFlags.HideAndDontSave; } return effectMaterial; } private void ReplaceSummonUnits(int count) { DefenseSummonUnit[] existingUnits = GetComponentsInChildren(true); summonCount = 0; nextSummonIndex = 0; targetSummonCount = count; summonRespawnRemaining = 0f; freeSummonSlots.Clear(); deadSummons.Clear(); foreach (DefenseSummonUnit unit in existingUnits) { unit.RetireForReplacement(); Destroy(unit.gameObject); } AddSummonUnits(count); } public void NotifySummonUnitDied(DefenseSummonUnit unit) { if (unit == null || unit.Owner != this) return; summonCount = Mathf.Max(0, summonCount - 1); if (!deadSummons.ContainsKey(unit.SlotIndex)) { deadSummons.Add(unit.SlotIndex, unit); freeSummonSlots.Push(unit.SlotIndex); } summonRespawnRemaining = Mathf.Max(summonRespawnRemaining, tuning.SummonUnitRespawnSeconds); } private void AddSummonUnits(int count) { for (int i = 0; i < count; i++) { int unitIndex = freeSummonSlots.Count > 0 ? freeSummonSlots.Pop() : nextSummonIndex++; if (deadSummons.TryGetValue(unitIndex, out DefenseSummonUnit deadUnit)) { deadSummons.Remove(unitIndex); if (deadUnit != null) Destroy(deadUnit.gameObject); } summonCount++; GameObject unitObject = new($"Summon Unit {unitIndex + 1}"); unitObject.transform.SetParent(transform, false); unitObject.transform.localPosition = new Vector3( (unitIndex % 3 - 1) * 0.8f, -0.55f - 0.8f * (unitIndex / 3), 0f); DefenseSummonUnit unit = unitObject.AddComponent(); unit.Configure(this, owner, unitObject.transform.localPosition, unitIndex); } } private void CreateVisuals() { SpriteRenderer rootRenderer = gameObject.GetComponent(); if (rootRenderer == null) { rootRenderer = gameObject.AddComponent(); } rootRenderer.sprite = DefensePrototypeController.WhiteSprite; rootRenderer.color = Color.white; rootRenderer.enabled = false; transform.localScale = new Vector3(0.72f, 0.72f, 1f); GameObject bodyObject = new("Tower Body"); bodyObject.transform.SetParent(transform, false); bodyObject.transform.localScale = new Vector3(2.1f, 2.1f, 1f); bodyRenderer = bodyObject.AddComponent(); bodyRenderer.color = Color.white; bodyRenderer.sortingOrder = 8; UpdateBodyArt(); GameObject labelObject = new("Tower Label"); labelObject.transform.SetParent(transform, false); labelObject.transform.localPosition = new Vector3(0f, 0.85f, 0f); label = labelObject.AddComponent(); label.anchor = TextAnchor.MiddleCenter; label.alignment = TextAlignment.Center; label.characterSize = 0.16f; label.fontSize = 32; label.color = Color.white; label.GetComponent().sortingOrder = 10; UpdateLabel(); CreateEffectRange(); } private void UpdateBodyArt() { bodyIdleKey = DefenseTowerArt.BodyKey(Kind, Tier, "Idle"); bodyIdleFrames = DefenseTowerArt.Load(bodyIdleKey); bodyAttackKey = Kind switch { DefenseTowerKind.Direct or DefenseTowerKind.Lightning or DefenseTowerKind.Cold => DefenseTowerArt.BodyKey(Kind, Tier, "Attack01"), DefenseTowerKind.Seismic => DefenseTowerArt.BodyKey(Kind, Tier, "Attack"), DefenseTowerKind.Empowering => DefenseTowerArt.BodyKey(Kind, Tier, "Heal"), _ => null, }; bodyAttackFrames = bodyAttackKey == null ? System.Array.Empty() : DefenseTowerArt.Load(bodyAttackKey); bodyAttacking = false; bodyFrame = 0; bodyFrameTime = 0f; if (bodyRenderer != null && bodyIdleFrames.Length > 0) { bodyRenderer.sprite = bodyIdleFrames[0]; } } private void PlayBodyAttack(bool faceLeft) { if (bodyRenderer == null || bodyAttackFrames == null || bodyAttackFrames.Length == 0) { return; } bodyRenderer.flipX = faceLeft; bodyAttacking = true; bodyFrame = 0; bodyFrameTime = 0f; bodyRenderer.sprite = bodyAttackFrames[0]; } private void UpdateBodyAnimation() { if (bodyRenderer == null || bodyIdleFrames == null || bodyIdleFrames.Length == 0) { return; } bodyFrameTime += Time.deltaTime; Sprite[] frames = bodyAttacking ? bodyAttackFrames : bodyIdleFrames; string key = bodyAttacking ? bodyAttackKey : bodyIdleKey; if (frames == null || frames.Length == 0) { bodyAttacking = false; return; } while (bodyFrameTime >= DefenseTowerArt.FrameSeconds(key, bodyFrame)) { bodyFrameTime -= DefenseTowerArt.FrameSeconds(key, bodyFrame); bodyFrame++; if (bodyFrame >= frames.Length) { if (bodyAttacking) { bodyAttacking = false; bodyFrame = 0; frames = bodyIdleFrames; key = bodyIdleKey; } else { bodyFrame = 0; } } bodyRenderer.sprite = frames[bodyFrame]; } } private void UpdateSupportAura() { DefenseTower support = owner != null ? owner.GetActiveSupportFor(this) : null; if (support == null) { if (supportAuraRenderer != null) supportAuraRenderer.enabled = false; return; } if (supportAuraRenderer == null) { GameObject auraObject = new("Support Aura Art"); auraObject.transform.SetParent(transform, false); supportAuraRenderer = auraObject.AddComponent(); } string motionKey = bodyAttacking && !string.IsNullOrEmpty(bodyAttackKey) ? bodyAttackKey : bodyIdleKey; string key = motionKey.Replace("Bodies/", "SupportMasks/"); if (supportAuraKey != key) { supportAuraKey = key; supportAuraFrames = DefenseTowerArt.Load(key); } int maskFrame = 0; if (bodyRenderer != null && supportAuraFrames != null) { for (int i = 0; i < supportAuraFrames.Length; i++) if (supportAuraFrames[i].name == bodyRenderer.sprite.name) { maskFrame = i; break; } } supportAuraRenderer.sprite = supportAuraFrames != null && supportAuraFrames.Length > maskFrame ? supportAuraFrames[maskFrame] : null; supportAuraRenderer.flipX = bodyRenderer != null && bodyRenderer.flipX; supportAuraRenderer.transform.localPosition = bodyRenderer != null ? bodyRenderer.transform.localPosition : Vector3.zero; supportAuraRenderer.transform.localScale = bodyRenderer != null ? bodyRenderer.transform.localScale : Vector3.one; supportAuraRenderer.sortingOrder = bodyRenderer != null ? bodyRenderer.sortingOrder + 1 : 9; Color[] colors = tuning.SupportTierColors; supportAuraRenderer.color = colors != null && colors.Length > 0 ? colors[Mathf.Clamp(support.Tier - 1, 0, colors.Length - 1)] : new Color32(151, 169, 119, 102); supportAuraRenderer.enabled = supportAuraFrames != null && supportAuraFrames.Length > 0 && supportAuraRenderer.sprite != null; } private void UpdateSupportRangeAnimation() { if (supportRangeRenderer == null || supportRangeFrames == null || supportRangeFrames.Length == 0) return; supportRangeFrameTime += Time.deltaTime; while (supportRangeFrameTime >= 0.14f) { supportRangeFrameTime -= 0.14f; supportRangeFrame = (supportRangeFrame + 1) % supportRangeFrames.Length; supportRangeRenderer.sprite = supportRangeFrames[supportRangeFrame]; UpdateEffectRange(); } } private Color GetTowerColor() { return Kind switch { DefenseTowerKind.Direct => new Color32(240, 96, 65, 255), DefenseTowerKind.Summon => new Color32(244, 154, 74, 255), DefenseTowerKind.Lightning => new Color32(238, 218, 83, 255), DefenseTowerKind.Cold => new Color32(65, 211, 240, 255), DefenseTowerKind.Seismic => new Color32(175, 141, 100, 255), _ => new Color32(148, 228, 122, 255), }; } private void UpdateLabel() { if (label == null) { return; } string family = Kind == DefenseTowerKind.Direct ? "FIRE" : Kind.ToString().ToUpperInvariant(); string branchName = Tier == 4 ? GetBranchName(Kind, Branch) : string.Empty; label.text = Tier == 4 ? $"{family} IV\n{branchName}" : $"{family} {Tier}"; } public static string GetBranchName(DefenseTowerKind kind, DefenseTowerBranch branch) { bool first = branch == DefenseTowerBranch.First; return kind switch { DefenseTowerKind.Direct => first ? "METEOR" : "FLAMETHROWER", DefenseTowerKind.Lightning => first ? "ARC" : "LIGHTNING STORM", DefenseTowerKind.Cold => first ? "FREEZEBOLT" : "BLIZZARD", DefenseTowerKind.Seismic => first ? "STONE GAZE" : "TEMPORAL", DefenseTowerKind.Summon => first ? "SENTINEL" : "SCOUT", _ => first ? "IMBUING" : "SMOTHERING", }; } } }