using System.Collections; using System.Collections.Generic; using BumpCombat.Core; using BumpCombat.Constants; using BumpCombat.Enemies; using BumpCombat.Player; using BumpCombat.UI; using UnityEngine; namespace BumpCombat.Combat { [RequireComponent(typeof(SpriteRenderer))] public sealed class CombatFeedback : MonoBehaviour { private float normalHitStopDuration => GameplayConstants.Current.CombatFeedback.NormalHitStopDuration; private float backHitStopDuration => GameplayConstants.Current.CombatFeedback.BackHitStopDuration; private float dashHitStopDuration => GameplayConstants.Current.CombatFeedback.DashHitStopDuration; private float strongDashHitStopDuration => GameplayConstants.Current.CombatFeedback.StrongDashHitStopDuration; private float damageHitStopDuration => GameplayConstants.Current.CombatFeedback.DamageHitStopDuration; private float flashDuration => GameplayConstants.Current.CombatFeedback.FlashDuration; private float damageFlashDuration => GameplayConstants.Current.CombatFeedback.DamageFlashDuration; private float impactDuration => GameplayConstants.Current.CombatFeedback.ImpactDuration; private float slashDuration => GameplayConstants.Current.CombatFeedback.SlashDuration; private float afterimageDuration => GameplayConstants.Current.CombatFeedback.AfterimageDuration; private float invulnerabilityBlinkInterval => GameplayConstants.Current.CombatFeedback.InvulnerabilityBlinkInterval; private float invulnerabilityBlinkAlpha => GameplayConstants.Current.CombatFeedback.InvulnerabilityBlinkAlpha; private float cameraShakeDuration => GameplayConstants.Current.CombatFeedback.CameraShakeDuration; private float cameraShakePixels => GameplayConstants.Current.CombatFeedback.CameraShakePixels; private float heavyCameraShakePixels => GameplayConstants.Current.CombatFeedback.HeavyCameraShakePixels; private float heavyKnockbackThreshold => GameplayConstants.Current.CombatFeedback.HeavyKnockbackThreshold; [SerializeField, Min(1f)] private float assetsPixelsPerUnit = 32f; private static Sprite whiteSprite; private static CombatFeedback activeInstance; private const string BumpImpactWeakResource = "Combat/BumpImpact-Weak-v1"; private const string BumpImpactMediumResource = "Combat/BumpImpact-Medium-v1"; private const string BumpImpactStrongResource = "Combat/BumpImpact-Strong-v1"; private const string BlockedBumpResource = "Combat/BlockedBump-Wizard-v1"; private const string GuardBlockImpactResource = "Combat/GuardBlock-v1"; private const string ArtifactImpactResourcePrefix = "Artifacts/ThreeColor-v1/Combat/ArtifactImpact-"; private const string PlayerHurtImpactResource = "Combat/PlayerHurt-Dot-v2"; private static readonly float[] BumpImpactFrameDurations = { 0.03f, 0.04f, 0.04f, 0.03f }; private static readonly float[] PlayerHurtImpactFrameDurations = { 0.03f, 0.04f, 0.03f }; private static float BlockedBumpHoldDuration => GameplayConstants.Current.CombatFeedback.BlockedBumpHoldDuration; private static float BlockedBumpFadeDuration => GameplayConstants.Current.CombatFeedback.BlockedBumpFadeDuration; private static float BlockedBumpVerticalOffset => GameplayConstants.Current.CombatFeedback.BlockedBumpVerticalOffset; private static float GuardBlockImpactHoldDuration => GameplayConstants.Current.CombatFeedback.GuardBlockImpactHoldDuration; private static float GuardBlockImpactFadeDuration => GameplayConstants.Current.CombatFeedback.GuardBlockImpactFadeDuration; private static float GuardBlockImpactInterval => GameplayConstants.Current.CombatFeedback.GuardBlockImpactInterval; private static float GuardBlockImpactOffset => GameplayConstants.Current.CombatFeedback.GuardBlockImpactOffset; public static int MaxConcurrentHeavyEffects => Mathf.Max(1, GameplayConstants.Current.CombatFeedback.MaxConcurrentHeavyEffects); public const int BumpImpactFrameCount = 4; public const int ArtifactImpactFrameCount = 4; public const int PlayerHurtImpactFrameCount = 3; private readonly List transientObjects = new(); private readonly List heavyEffects = new(); private readonly Stack lineTransientPool = new(); private readonly HashSet destroyPendingIds = new(); private readonly HashSet pooledLineTransientIds = new(); private readonly HashSet activeTransientIds = new(); private readonly Dictionary statusVisuals = new(); private readonly Dictionary staggerVisuals = new(); private readonly Dictionary statusRoutines = new(); private readonly Dictionary stunOriginalColors = new(); private readonly Dictionary pulseLeaseCounts = new(); private readonly List cycloneAfterimages = new(); private readonly HashSet bumpImpactVisuals = new(); private readonly HashSet blockedBumpVisuals = new(); private readonly HashSet guardBlockImpactVisuals = new(); private readonly HashSet artifactImpactVisuals = new(); private readonly HashSet playerHurtImpactVisuals = new(); private readonly Dictionary artifactImpactFrameCache = new(); private SpriteRenderer playerRenderer; private PlayerStats playerStats; private PlayerHealth playerHealth; private ActiveArtifactController activeArtifactController; private Camera worldCamera; private ArenaCameraFollow cameraFollow; private Color originalColor; private Color currentFlashColor; private Coroutine hitStopCoroutine; private Coroutine playerVisualCoroutine; private Coroutine cameraShakeCoroutine; private Coroutine destroyPendingCleanupCoroutine; private float hitStopUntil; private float flashUntil; private float blinkUntil; private Vector3 cameraRestPosition; private Material effectMaterial; private Vector3 lastCycloneSamplePosition; private Vector2 lastDashAfterimagePosition; private float nextCycloneAfterimageAt; private float lastDashAfterimageAt = float.NegativeInfinity; private float nextGuardBlockImpactAt = float.NegativeInfinity; private bool hasCycloneSamplePosition; private bool cycloneBuffWasActive; private Sprite[] bumpImpactWeakFrames; private Sprite[] bumpImpactMediumFrames; private Sprite[] bumpImpactStrongFrames; private Sprite[] playerHurtImpactFrames; private static Sprite blockedBumpSprite; private static bool blockedBumpSpriteLoaded; private static Sprite guardBlockImpactSprite; private static bool guardBlockImpactSpriteLoaded; private const float StunMarkerGap = 4f / 32f; private const int StunMarkerSortingOffset = 100; public static float CycloneAfterimageInterval => Mathf.Max(0.001f, GameplayConstants.Current.CombatFeedback.CycloneAfterimageInterval); public static float CycloneAfterimageDuration => GameplayConstants.Current.CombatFeedback.CycloneAfterimageDuration; public static int MaxCycloneAfterimages => Mathf.Max(1, GameplayConstants.Current.CombatFeedback.MaxCycloneAfterimages); public int ActiveHeavyEffectCount => heavyEffects.Count; public int HeavyEffectLimit => MaxConcurrentHeavyEffects; public int ActiveBumpImpactVisualCount => bumpImpactVisuals.Count; public int ActiveBlockedBumpVisualCount => blockedBumpVisuals.Count; public int ActiveGuardBlockImpactVisualCount => guardBlockImpactVisuals.Count; public int ActiveArtifactImpactVisualCount => artifactImpactVisuals.Count; public int ActivePlayerHurtImpactVisualCount => playerHurtImpactVisuals.Count; public static string GetPlayerHurtImpactResourcePath() { return PlayerHurtImpactResource; } public static string GetGuardBlockImpactResourcePath() { return GuardBlockImpactResource; } public static float GetPlayerHurtImpactFrameDuration(int frameIndex) { return frameIndex >= 0 && frameIndex < PlayerHurtImpactFrameDurations.Length ? PlayerHurtImpactFrameDurations[frameIndex] : 0f; } public static string GetBumpImpactResourcePath(HitSide side) { return side switch { HitSide.Back => BumpImpactStrongResource, HitSide.Side => BumpImpactMediumResource, _ => BumpImpactWeakResource, }; } public static float GetBumpImpactFrameDuration(int frameIndex) { return frameIndex >= 0 && frameIndex < BumpImpactFrameDurations.Length ? BumpImpactFrameDurations[frameIndex] : 0f; } public static string GetArtifactImpactResourcePath( ActiveArtifactEffect effect, bool charged) { string resourceName = effect switch { ActiveArtifactEffect.Dash => "Dash", ActiveArtifactEffect.Pulse => "Pulse", ActiveArtifactEffect.Phoenix => "SearingRay", ActiveArtifactEffect.Cyclone => "Cyclone", ActiveArtifactEffect.ThunderCrash => "ThunderCrash", ActiveArtifactEffect.ChainLightning => "Arc", _ => null, }; if (string.IsNullOrEmpty(resourceName)) { return null; } return $"{ArtifactImpactResourcePrefix}{resourceName}-" + (charged ? "Charged" : "Normal") + "-v1"; } public static float GetArtifactImpactFrameDuration(int frameIndex) { return GetBumpImpactFrameDuration(frameIndex); } public static bool CanRecycleLineTransient( bool destroyPending, bool activeRegistrationRemoved, bool alreadyPooled) { return !destroyPending && activeRegistrationRemoved && !alreadyPooled; } public static bool CanAcquireLineTransient( bool unityNull, bool destroyPending, bool hasLineRenderer) { return !unityNull && !destroyPending && hasLineRenderer; } public static void ConfigureWorldStreakRenderer( LineRenderer line, Material material, Color color, float width, Vector2 start, Vector2 end) { if (line == null) { return; } line.enabled = true; line.loop = false; line.useWorldSpace = true; line.positionCount = 2; line.startWidth = 0f; line.endWidth = 0f; line.sharedMaterial = material; line.startColor = color; line.endColor = color; line.widthMultiplier = width; line.numCornerVertices = 0; line.numCapVertices = 2; line.sortingOrder = 211; line.SetPosition(0, start); line.SetPosition(1, end); } public static bool ShouldShowPulseStreak(float distance) { return distance >= 0.1f; } public static bool ShouldShowKnockbackDust(float distance) { return distance >= 0.5f; } public static Vector3 CalculateStunMarkerLocalPosition( Transform target, SpriteRenderer targetRenderer, float gap = StunMarkerGap) { if (target == null || targetRenderer == null) { return Vector3.zero; } Vector3 worldAnchor = CrowdControlMarkerArt.MarkerWorldPosition(targetRenderer, gap); return target.InverseTransformPoint(worldAnchor); } public static Vector3 CalculateStunMarkerBaseLocalScale(Vector3 targetLossyScale) { return new Vector3( CrowdControlMarkerArt.MarkerScale, CrowdControlMarkerArt.MarkerScale, SafeScaleInverse(targetLossyScale.z)); } public static int CalculateStunMarkerSortingOrder(int targetSortingOrder) { return targetSortingOrder + StunMarkerSortingOffset; } private static float SafeScaleInverse(float value) { return Mathf.Abs(value) > 0.0001f ? 1f / value : 1f; } private void Awake() { playerRenderer = GetComponent(); playerStats = GetComponent(); playerHealth = GetComponent(); activeArtifactController = GetComponent(); worldCamera = Camera.main; cameraFollow = worldCamera != null ? worldCamera.GetComponent() : null; originalColor = playerRenderer.color; lastCycloneSamplePosition = transform.position; hasCycloneSamplePosition = true; if (worldCamera != null) { cameraRestPosition = worldCamera.transform.position; } } private void Start() { if (activeArtifactController == null) { activeArtifactController = GetComponent(); } } private void OnEnable() { activeInstance = this; CombatEvents.OnValidHit += HandleValidHit; CombatEvents.OnStunApplied += HandleStunApplied; CombatEvents.OnAttackCancelled += HandleAttackCancelled; CombatEvents.OnEnemyStagger += HandleEnemyStagger; CombatEvents.OnEnemyStaggerCleared += HandleEnemyStaggerCleared; CombatEvents.OnLaunchResisted += HandleLaunchResisted; CombatEvents.OnKnockbackCompleted += HandleKnockbackCompleted; CombatEvents.OnEnemyRetired += HandleEnemyRetired; CombatEvents.OnEnemyDied += HandleEnemyDied; CombatEvents.OnThunderShield += HandleThunderShield; if (playerStats != null) { playerStats.OnStatChanged += HandlePlayerStatChanged; } if (playerHealth != null) { playerHealth.OnDamaged += HandlePlayerDamaged; playerHealth.OnGuardImpact += HandlePlayerGuardImpact; playerHealth.OnDied += HandlePlayerDied; } } private void OnDisable() { if (activeInstance == this) { activeInstance = null; } CombatEvents.OnValidHit -= HandleValidHit; CombatEvents.OnStunApplied -= HandleStunApplied; CombatEvents.OnAttackCancelled -= HandleAttackCancelled; CombatEvents.OnEnemyStagger -= HandleEnemyStagger; CombatEvents.OnEnemyStaggerCleared -= HandleEnemyStaggerCleared; CombatEvents.OnLaunchResisted -= HandleLaunchResisted; CombatEvents.OnKnockbackCompleted -= HandleKnockbackCompleted; CombatEvents.OnEnemyRetired -= HandleEnemyRetired; CombatEvents.OnEnemyDied -= HandleEnemyDied; CombatEvents.OnThunderShield -= HandleThunderShield; if (playerStats != null) { playerStats.OnStatChanged -= HandlePlayerStatChanged; } if (playerHealth != null) { playerHealth.OnDamaged -= HandlePlayerDamaged; playerHealth.OnGuardImpact -= HandlePlayerGuardImpact; playerHealth.OnDied -= HandlePlayerDied; } if (hitStopCoroutine != null) { StopCoroutine(hitStopCoroutine); hitStopCoroutine = null; RestoreGameplayTimeScale(); } if (playerVisualCoroutine != null) { StopCoroutine(playerVisualCoroutine); playerVisualCoroutine = null; playerRenderer.color = originalColor; } if (destroyPendingCleanupCoroutine != null) { StopCoroutine(destroyPendingCleanupCoroutine); destroyPendingCleanupCoroutine = null; } RestoreCameraPosition(); ClearCycloneAfterimages(); ClearBlockedBumpVisuals(); ClearGuardBlockImpactVisuals(); ClearPlayerHurtImpactVisuals(); foreach (GameObject transientObject in transientObjects) { if (transientObject != null) { Destroy(transientObject); } } transientObjects.Clear(); foreach (KeyValuePair pair in pulseLeaseCounts) { if (pair.Key != null) { for (int i = 0; i < pair.Value; i++) { pair.Key.ReleaseSpriteVisualLease("Pulse"); } } } pulseLeaseCounts.Clear(); heavyEffects.Clear(); while (lineTransientPool.Count > 0) { GameObject pooled = lineTransientPool.Pop(); if (pooled != null) { Destroy(pooled); } } pooledLineTransientIds.Clear(); destroyPendingIds.Clear(); activeTransientIds.Clear(); foreach (KeyValuePair pair in stunOriginalColors) { if (pair.Key != null) { SpriteRenderer renderer = pair.Key.GetComponent(); if (renderer != null) { renderer.color = pair.Value; } } } statusVisuals.Clear(); staggerVisuals.Clear(); statusRoutines.Clear(); stunOriginalColors.Clear(); cycloneAfterimages.Clear(); bumpImpactVisuals.Clear(); blockedBumpVisuals.Clear(); artifactImpactVisuals.Clear(); cycloneBuffWasActive = false; } private void Update() { UpdateCycloneAfterimages(); } public static void ShowPulseGather( GameObject target, Vector2 previousPosition, Vector2 finalPosition) { GetActive()?.CreatePulseGather(target, previousPosition, finalPosition); } public static void ShowArtifactImpact(CombatHitResult result) { CombatFeedback feedback = GetActive(); if (feedback == null || result.Attacker != feedback.gameObject) { return; } feedback.CreateArtifactImpact(result); } public static void ShowBlockedBump( GameObject attacker, Vector2 hitPosition, Vector2 approachDirection) { CombatFeedback feedback = GetActive(); if (feedback == null || !feedback.isActiveAndEnabled || attacker == null || attacker != feedback.gameObject) { return; } feedback.CreateBlockedBump(hitPosition, approachDirection); } public static void ShowLaunchLanding(Vector2 position) { GetActive()?.CreateDust(position, new Color(0.75f, 0.85f, 1f, 1f)); } public static void ShowLaunchTakeoff(Vector2 position) { GetActive()?.CreateRing(position, 0.28f, new Color(0.9f, 0.65f, 1f, 1f), 0.1f, true); } private static CombatFeedback GetActive() { if (activeInstance == null) { activeInstance = FindAnyObjectByType(); } return activeInstance; } private void HandlePlayerStatChanged(CharacterStat stat) { if (stat != CharacterStat.MoveSpeed) { return; } bool active = HasCycloneMoveSpeedBuff(); if (!active) { ClearCycloneAfterimages(); } else if (!cycloneBuffWasActive) { nextCycloneAfterimageAt = Time.time; } cycloneBuffWasActive = active; } private void HandlePlayerDied() { ClearBumpImpactVisuals(); ClearBlockedBumpVisuals(); ClearGuardBlockImpactVisuals(); ClearArtifactImpactVisuals(); ClearPlayerHurtImpactVisuals(); ClearCycloneAfterimages(); } private bool HasCycloneMoveSpeedBuff() { return activeArtifactController != null && activeArtifactController.HasCycloneMoveSpeedBuff; } private void UpdateCycloneAfterimages() { if (playerHealth != null && playerHealth.CurrentHealth <= 0f) { ClearCycloneAfterimages(); return; } Vector3 currentPosition = transform.position; bool moved = hasCycloneSamplePosition && (currentPosition - lastCycloneSamplePosition).sqrMagnitude > 0.000001f; lastCycloneSamplePosition = currentPosition; hasCycloneSamplePosition = true; bool active = HasCycloneMoveSpeedBuff(); if (active != cycloneBuffWasActive) { cycloneBuffWasActive = active; if (active) { nextCycloneAfterimageAt = Time.time; } else { ClearCycloneAfterimages(); } } if (!active || !moved || Time.timeScale <= 0f || Time.deltaTime <= 0f || Time.time < nextCycloneAfterimageAt || playerRenderer == null || playerRenderer.sprite == null) { return; } if (Time.time - lastDashAfterimageAt < CycloneAfterimageInterval && Vector2.Distance( currentPosition, lastDashAfterimagePosition) < 0.1f) { nextCycloneAfterimageAt = Time.time + CycloneAfterimageInterval; return; } CreateCycloneAfterimage(currentPosition); nextCycloneAfterimageAt = Time.time + CycloneAfterimageInterval; } private void CreateCycloneAfterimage(Vector2 position) { while (cycloneAfterimages.Count > 0 && cycloneAfterimages[0] == null) { cycloneAfterimages.RemoveAt(0); } while (cycloneAfterimages.Count >= MaxCycloneAfterimages) { GameObject oldest = cycloneAfterimages[0]; cycloneAfterimages.RemoveAt(0); DestroyTransient(oldest); } GameObject afterimage = new("Cyclone Move Afterimage"); afterimage.transform.SetPositionAndRotation( position, transform.rotation); afterimage.transform.localScale = transform.lossyScale; SpriteRenderer renderer = afterimage.AddComponent(); renderer.sprite = playerRenderer.sprite; renderer.flipX = playerRenderer.flipX; renderer.flipY = playerRenderer.flipY; renderer.sortingLayerID = playerRenderer.sortingLayerID; renderer.sortingOrder = playerRenderer.sortingOrder - 1; renderer.color = new Color(103f / 255f, 231f / 255f, 178f / 255f, 0.32f); RegisterTransient(afterimage, false); cycloneAfterimages.Add(afterimage); StartCoroutine(FadeCycloneAfterimage(afterimage, renderer)); } private IEnumerator FadeCycloneAfterimage( GameObject afterimage, SpriteRenderer renderer) { if (renderer == null) { DestroyTransient(afterimage); yield break; } float elapsed = 0f; Color startColor = renderer.color; while (afterimage != null && renderer != null && elapsed < CycloneAfterimageDuration) { elapsed += Time.deltaTime; Color color = startColor; color.a = startColor.a * Mathf.Clamp01( 1f - elapsed / CycloneAfterimageDuration); renderer.color = color; yield return null; } DestroyTransient(afterimage); } private void ClearCycloneAfterimages() { for (int i = cycloneAfterimages.Count - 1; i >= 0; i--) { DestroyTransient(cycloneAfterimages[i]); } cycloneAfterimages.Clear(); } private void HandleStunApplied(GameObject target, float duration) { if (target == null) { return; } CreateStunMarker(target, duration); } private void HandleAttackCancelled(GameObject target, bool fromStun) { // AttackWarningVisual owns the 0.08 second collapse. Keeping this hook // here makes cancellation a distinct interaction event for future art. } private void HandleEnemyStagger( GameObject target, int current, int maximum, bool broke) { if (target == null) { return; } // Color shield labels already show progress; the old orange pips duplicate it // and otherwise remain above the head for the entire groggy window. EnemyController enemy = target.GetComponent(); if (enemy != null && enemy.IsGroggyTierGated) { HandleEnemyStaggerCleared(target); return; } CreateStaggerMarker(target, current, maximum); if (broke) { CreateStatusText(target.transform.position + Vector3.up * 0.55f, "BREAK", new Color(1f, 0.45f, 0.12f, 1f)); } } private void HandleEnemyStaggerCleared(GameObject target) { if (target != null && staggerVisuals.TryGetValue(target, out GameObject marker)) { DestroyTransient(marker); staggerVisuals.Remove(target); } } private void HandleLaunchResisted(GameObject target) { if (target == null) { return; } CreateStatusText(target.transform.position + Vector3.up * 0.55f, "RESIST", new Color(0.78f, 0.78f, 0.86f, 1f)); CreateRing(target.transform.position, 0.38f, new Color(0.76f, 0.72f, 0.9f, 1f), 0.16f, true); } private void HandleKnockbackCompleted(GameObject target, Vector2 start, Vector2 end) { float distance = Vector2.Distance(start, end); if (!ShouldShowKnockbackDust(distance)) { return; } CreateDust(end, new Color(0.9f, 0.8f, 1f, 1f)); } private void HandleEnemyRetired(GameObject target) { if (target == null) { return; } SpriteRenderer source = target.GetComponent(); if (source == null || source.sprite == null) { return; } GameObject ghost = new("Retiring Enemy Visual"); ghost.transform.SetPositionAndRotation(target.transform.position, target.transform.rotation); ghost.transform.localScale = target.transform.lossyScale; SpriteRenderer renderer = ghost.AddComponent(); renderer.sprite = source.sprite; renderer.flipX = source.flipX; renderer.flipY = source.flipY; renderer.sortingLayerID = source.sortingLayerID; renderer.sortingOrder = source.sortingOrder + 2; renderer.color = Color.gray; RegisterTransient(ghost, true); StartCoroutine(FadeRetire(ghost, renderer)); } private void HandleEnemyDied(GameObject target) { if (target == null) { return; } if (statusVisuals.TryGetValue(target, out GameObject marker)) { DestroyTransient(marker); statusVisuals.Remove(target); } if (staggerVisuals.TryGetValue(target, out GameObject staggerMarker)) { DestroyTransient(staggerMarker); staggerVisuals.Remove(target); } if (statusRoutines.TryGetValue(target, out Coroutine routine) && routine != null) { StopCoroutine(routine); } statusRoutines.Remove(target); if (stunOriginalColors.TryGetValue(target, out Color originalColor)) { SpriteRenderer renderer = target.GetComponent(); if (renderer != null) { renderer.color = originalColor; } stunOriginalColors.Remove(target); } } private void HandleThunderShield(Vector2 center, float duration) { CreateRing( center, 0.6f, new Color(0.15f, 0.95f, 0.95f, 1f), duration, true, true); } private void CreatePulseGather( GameObject target, Vector2 previousPosition, Vector2 finalPosition) { float distance = Vector2.Distance(previousPosition, finalPosition); if (!ShouldShowPulseStreak(distance)) { CreateRing(finalPosition, 0.26f, new Color(1f, 0.39f, 0.39f, 1f), 0.12f, true); return; } EnemyController enemy = target != null ? target.GetComponent() : null; enemy?.AcquireSpriteVisualLease("Pulse"); if (enemy != null) { pulseLeaseCounts.TryGetValue(enemy, out int leaseCount); pulseLeaseCounts[enemy] = leaseCount + 1; } SpriteRenderer source = target != null ? target.GetComponent() : null; GameObject root = new("Pulse Gather Afterimage"); root.transform.position = previousPosition; if (target != null) { root.transform.localScale = target.transform.lossyScale; } SpriteRenderer ghost = source != null ? root.AddComponent() : null; SpriteRenderer secondGhost = null; if (ghost != null) { ghost.sprite = source.sprite; ghost.flipX = source.flipX; ghost.flipY = source.flipY; ghost.sortingLayerID = source.sortingLayerID; ghost.sortingOrder = source.sortingOrder + 2; ghost.color = new Color(1f, 0.39f, 0.39f, 0.65f); GameObject second = new("Pulse Gather Afterimage 2"); second.transform.SetParent(root.transform, false); second.transform.localPosition = Vector3.left * 0.08f; secondGhost = second.AddComponent(); secondGhost.sprite = source.sprite; secondGhost.flipX = source.flipX; secondGhost.flipY = source.flipY; secondGhost.sortingLayerID = source.sortingLayerID; secondGhost.sortingOrder = source.sortingOrder + 1; secondGhost.color = new Color(1f, 0.65f, 0.65f, 0.4f); } LineRenderer line = CreateWorldLine( new Color(1f, 0.39f, 0.39f, 0.8f), 0.055f, previousPosition, finalPosition); RegisterTransient(root, true); RegisterTransient(line.gameObject, true); StartCoroutine(PulseGatherRoutine( root, ghost, secondGhost, line, enemy, finalPosition)); } private IEnumerator PulseGatherRoutine( GameObject root, SpriteRenderer ghost, SpriteRenderer secondGhost, LineRenderer line, EnemyController enemy, Vector2 finalPosition) { float startedAt = Time.unscaledTime; Vector2 start = root.transform.position; const float duration = 0.1f; while (root != null && line != null && Time.unscaledTime - startedAt < duration) { float progress = (Time.unscaledTime - startedAt) / duration; root.transform.position = Vector2.Lerp(start, finalPosition, progress); if (ghost != null) { Color color = ghost.color; color.a = 0.65f * (1f - progress); ghost.color = color; } if (secondGhost != null) { Color color = secondGhost.color; color.a = 0.4f * (1f - progress); secondGhost.color = color; } Color lineColor = line.startColor; lineColor.a = 0.8f * (1f - progress); line.startColor = lineColor; line.endColor = lineColor; yield return null; } if (enemy != null) { enemy.ReleaseSpriteVisualLease("Pulse"); if (pulseLeaseCounts.TryGetValue(enemy, out int leaseCount)) { if (leaseCount <= 1) { pulseLeaseCounts.Remove(enemy); } else { pulseLeaseCounts[enemy] = leaseCount - 1; } } } CreateRing(finalPosition, 0.26f, new Color(1f, 0.39f, 0.39f, 1f), 0.12f, true); DestroyTransient(root); if (line != null) { RecycleLineTransient(line.gameObject); } } private void CreateStunMarker(GameObject target, float duration) { if (target == null) { return; } SpriteRenderer targetRenderer = target.GetComponent(); if (targetRenderer == null) { return; } if (statusVisuals.TryGetValue(target, out GameObject oldVisual) && oldVisual != null) { DestroyTransient(oldVisual); } if (statusRoutines.TryGetValue(target, out Coroutine oldRoutine) && oldRoutine != null) { StopCoroutine(oldRoutine); statusRoutines.Remove(target); if (stunOriginalColors.TryGetValue(target, out Color savedColor)) { SpriteRenderer renderer = target.GetComponent(); if (renderer != null) { renderer.color = savedColor; } } } GameObject root = new("Stun Runes"); root.transform.SetParent(target.transform, false); root.transform.localPosition = CalculateStunMarkerLocalPosition(target.transform, targetRenderer); Vector3 baseLocalScale = CalculateStunMarkerBaseLocalScale(target.transform.lossyScale); root.transform.localScale = baseLocalScale; SpriteRenderer spiral = root.AddComponent(); spiral.sprite = CrowdControlMarkerArt.Frame(0f, false); spiral.color = Color.white; SpriteRenderer[] runeRenderers = { spiral }; statusVisuals[target] = root; RegisterTransient(root, false); Coroutine routine = StartCoroutine(StunMarkerRoutine( target, targetRenderer, root, runeRenderers, duration)); statusRoutines[target] = routine; } private IEnumerator StunMarkerRoutine( GameObject target, SpriteRenderer targetRenderer, GameObject root, SpriteRenderer[] runeRenderers, float duration) { float elapsed = 0f; Color original = targetRenderer != null ? targetRenderer.color : Color.white; stunOriginalColors[target] = original; if (targetRenderer != null) { targetRenderer.color = Color.Lerp(original, new Color(0.72f, 0.42f, 1f), 0.3f); } while (elapsed < duration && root != null && target != null && target.activeInHierarchy) { elapsed += Time.deltaTime; root.transform.localScale = CalculateStunMarkerBaseLocalScale(target.transform.lossyScale); if (targetRenderer != null) root.transform.localPosition = CalculateStunMarkerLocalPosition(target.transform, targetRenderer); if (targetRenderer != null && runeRenderers != null) { int sortingOrder = CalculateStunMarkerSortingOrder(targetRenderer.sortingOrder); foreach (SpriteRenderer runeRenderer in runeRenderers) { if (runeRenderer != null) { runeRenderer.sprite = CrowdControlMarkerArt.Frame(elapsed, false); EnemyController enemy = target.GetComponent(); runeRenderer.enabled = enemy == null || (enemy.isActiveAndEnabled && !enemy.IsDead); runeRenderer.sortingLayerID = targetRenderer.sortingLayerID; runeRenderer.sortingOrder = sortingOrder; } } } yield return null; } bool isCurrentMarker = statusVisuals.TryGetValue(target, out GameObject current) && current == root; if (isCurrentMarker && targetRenderer != null) { targetRenderer.color = original; } if (isCurrentMarker) { statusVisuals.Remove(target); stunOriginalColors.Remove(target); statusRoutines.Remove(target); } DestroyTransient(root); } private void CreateStaggerMarker(GameObject target, int current, int maximum) { if (target == null || maximum <= 0) { return; } if (staggerVisuals.TryGetValue(target, out GameObject oldVisual) && oldVisual != null) { DestroyTransient(oldVisual); } GameObject root = new("Stagger Pips"); root.transform.SetParent(target.transform, false); root.transform.localPosition = Vector3.up * 0.72f; for (int i = 0; i < maximum; i++) { GameObject pip = new($"Pip {i + 1}"); pip.transform.SetParent(root.transform, false); pip.transform.localPosition = new Vector3((i - (maximum - 1) * 0.5f) * 0.11f, 0f); pip.transform.localScale = new Vector3(0.08f, 0.08f, 1f); SpriteRenderer renderer = pip.AddComponent(); renderer.sprite = GetWhiteSprite(); renderer.color = i < current ? new Color(1f, 0.45f, 0.12f, 1f) : new Color(0.25f, 0.12f, 0.1f, 0.8f); renderer.sortingOrder = 121; } staggerVisuals[target] = root; RegisterTransient(root, false); } private void CreateStatusText(Vector3 position, string text, Color color) { // Create the mesh atomically with the root. Status text can be raised // during transient cleanup, so a separately-added component could be // observed before it is available. GameObject root = new($"Status {text}", typeof(TextMesh)); root.name = $"Status {text}"; root.transform.position = position; TextMesh mesh = root.GetComponent(); MeshRenderer meshRenderer = root.GetComponent(); if (meshRenderer != null) { meshRenderer.enabled = true; } mesh.text = text; mesh.fontSize = 28; mesh.characterSize = 0.035f; mesh.anchor = TextAnchor.MiddleCenter; mesh.alignment = TextAlignment.Center; mesh.color = color; mesh.font = PresentationUiStyle.GetGalmuriFont(); mesh.fontStyle = FontStyle.Normal; RegisterTransient(root, true); StartCoroutine(FadeStatusText(root, mesh, 0.42f)); } private IEnumerator FadeStatusText(GameObject root, TextMesh mesh, float duration) { float elapsed = 0f; while (root != null && mesh != null && elapsed < duration) { elapsed += Time.unscaledDeltaTime; root.transform.position += Vector3.up * (0.22f * Time.unscaledDeltaTime); Color color = mesh.color; color.a = 1f - elapsed / duration; mesh.color = color; yield return null; } DestroyTransient(root); } private void CreateRing( Vector2 position, float radius, Color color, float duration, bool heavy, bool useScaledTime = false) { GameObject root = AcquireLineTransient("Interaction Ring"); root.transform.position = position; LineRenderer line = root.GetComponent(); if (line == null) { line = root.AddComponent(); } line.enabled = true; line.loop = true; line.useWorldSpace = false; line.positionCount = 24; line.startWidth = 0f; line.endWidth = 0f; line.sharedMaterial = GetEffectMaterial(); line.widthMultiplier = 0.045f; line.numCornerVertices = 0; line.numCapVertices = 2; line.startColor = color; line.endColor = color; line.sortingOrder = 210; for (int i = 0; i < line.positionCount; i++) { float angle = i * Mathf.PI * 2f / line.positionCount; line.SetPosition(i, new Vector3(Mathf.Cos(angle) * radius, Mathf.Sin(angle) * radius)); } RegisterTransient(root, heavy); StartCoroutine(FadeRing(root, line, duration, useScaledTime)); } private IEnumerator FadeRing( GameObject root, LineRenderer line, float duration, bool useScaledTime) { float elapsed = 0f; while (root != null && line != null && elapsed < duration) { elapsed += useScaledTime ? Time.deltaTime : Time.unscaledDeltaTime; float progress = Mathf.Clamp01(elapsed / duration); root.transform.localScale = Vector3.one * Mathf.Lerp(0.72f, 1.2f, progress); Color color = line.startColor; color.a = 1f - progress; line.startColor = color; line.endColor = color; yield return null; } RecycleLineTransient(root); } private void CreateFloorStreak(Vector2 position, Vector2 direction, Color color) { Vector2 end = position + direction.normalized * 0.34f; LineRenderer line = CreateWorldLine(color, 0.04f, position, end); RegisterTransient(line.gameObject, true); StartCoroutine(FadeLine(line.gameObject, line, 0.11f)); } private void CreateDust(Vector2 position, Color color) { CreateRing(position, 0.2f, color, 0.13f, true); } private IEnumerator FadeRetire(GameObject root, SpriteRenderer renderer) { float elapsed = 0f; const float duration = 0.18f; Vector3 startScale = root != null ? root.transform.localScale : Vector3.one; while (root != null && elapsed < duration) { elapsed += Time.unscaledDeltaTime; float progress = Mathf.Clamp01(elapsed / duration); root.transform.position += Vector3.up * (0.12f * Time.unscaledDeltaTime); root.transform.localScale = startScale * (1f - progress * 0.2f); Color color = renderer.color; color.a = 1f - progress; renderer.color = color; yield return null; } DestroyTransient(root); } private LineRenderer CreateWorldLine(Color color, float width, Vector2 start, Vector2 end) { GameObject root = AcquireLineTransient("Interaction Streak"); LineRenderer line = root.GetComponent(); if (line == null) { line = root.AddComponent(); } ConfigureWorldStreakRenderer( line, GetEffectMaterial(), color, width, start, end); return line; } private IEnumerator FadeLine(GameObject root, LineRenderer line, float duration) { float elapsed = 0f; while (root != null && line != null && elapsed < duration) { elapsed += Time.unscaledDeltaTime; Color color = line.startColor; color.a = 1f - elapsed / duration; line.startColor = color; line.endColor = color; yield return null; } RecycleLineTransient(root); } private void RegisterTransient(GameObject transientObject, bool heavy) { if (transientObject == null) { return; } if (!activeTransientIds.Add(transientObject.GetInstanceID())) { return; } transientObjects.Add(transientObject); if (!heavy) { return; } while (heavyEffects.Count >= MaxConcurrentHeavyEffects) { GameObject oldest = heavyEffects[0]; if (oldest == null) { heavyEffects.RemoveAt(0); continue; } DestroyTransient(oldest); } heavyEffects.Add(transientObject); } private GameObject AcquireLineTransient(string name) { while (lineTransientPool.Count > 0) { GameObject pooled = lineTransientPool.Pop(); if (pooled != null) { int instanceId = pooled.GetInstanceID(); pooledLineTransientIds.Remove(instanceId); LineRenderer pooledLine = pooled.GetComponent(); if (!CanAcquireLineTransient( pooled == null, destroyPendingIds.Contains(instanceId), pooledLine != null)) { DestroyTransient(pooled); continue; } pooled.name = name; pooled.transform.SetParent(null, false); pooled.transform.SetPositionAndRotation(Vector3.zero, Quaternion.identity); pooled.transform.localScale = Vector3.one; pooledLine.enabled = false; pooled.SetActive(true); return pooled; } } return new GameObject(name); } private void RecycleLineTransient(GameObject transientObject) { if (transientObject == null) { return; } int instanceId = transientObject.GetInstanceID(); bool activeRegistrationRemoved = activeTransientIds.Remove(instanceId) && transientObjects.Remove(transientObject); bool alreadyPooled = pooledLineTransientIds.Contains(instanceId); if (!CanRecycleLineTransient( destroyPendingIds.Contains(instanceId), activeRegistrationRemoved, alreadyPooled)) { return; } LineRenderer line = transientObject.GetComponent(); if (line == null) { DestroyTransient(transientObject); return; } heavyEffects.Remove(transientObject); if (!pooledLineTransientIds.Add(instanceId)) { return; } transientObject.SetActive(false); lineTransientPool.Push(transientObject); } public void EmitDashAfterimage( Vector2 position, bool isStrongDash = false) { if (playerRenderer.sprite == null) { return; } lastDashAfterimageAt = Time.time; lastDashAfterimagePosition = position; GameObject afterimage = new("Dash Afterimage"); afterimage.transform.SetPositionAndRotation(position, transform.rotation); afterimage.transform.localScale = transform.lossyScale; SpriteRenderer renderer = afterimage.AddComponent(); renderer.sprite = playerRenderer.sprite; renderer.flipX = playerRenderer.flipX; renderer.flipY = playerRenderer.flipY; renderer.sortingLayerID = playerRenderer.sortingLayerID; renderer.sortingOrder = playerRenderer.sortingOrder - 1; renderer.color = new Color(103f / 255f, 231f / 255f, 178f / 255f, isStrongDash ? 0.42f : 0.32f); transientObjects.Add(afterimage); StartCoroutine(FadeAndDestroy(afterimage, renderer, afterimageDuration)); } private void HandleValidHit(CombatHitResult result) { if (result.Attacker != gameObject) { return; } // Artifact impact sheets are per-contact visuals. They remain // visible when a batched contact suppresses the broader impact, // slash, flash, and hit-stop feedback below. bool createdArtifactImpact = result.IsArtifactHit && CreateArtifactImpact(result); if (!result.ShowsHitFeedback) { return; } Color color; float strength; int rayCount; float hitStopDuration; if (result.IsStrongDash) { color = new Color(0.95f, 0.5f, 1f, 1f); strength = 2.25f; rayCount = 12; hitStopDuration = strongDashHitStopDuration; } else if (result.IsDash) { color = new Color(0.2f, 0.95f, 1f, 1f); strength = result.HasBackBonus ? 1.9f : 1.65f; rayCount = 10; hitStopDuration = dashHitStopDuration; } else if (result.HasBackBonus) { color = new Color(1f, 0.55f, 0.12f, 1f); strength = 1.45f; rayCount = 8; hitStopDuration = backHitStopDuration; } else if (result.HasPositionBonus) { color = new Color(0.65f, 1f, 0.3f, 1f); strength = 1.25f; rayCount = 7; hitStopDuration = normalHitStopDuration; } else { color = new Color(0.78f, 0.72f, 1f, 1f); strength = 1.1f; rayCount = 6; hitStopDuration = normalHitStopDuration; } ActiveArtifactDefinition selected = GetComponent()?.CurrentArtifact; if (result.IsArtifactHit || selected != null) { ArtifactColor group = result.IsArtifactHit ? ActiveArtifactDefinition.GetArtifactColor(result.ArtifactEffect) : selected.ArtifactColor; color = ActiveArtifactDefinition.GetArtifactPaletteColor(group); } bool usedArtifactImpactSheet = createdArtifactImpact; bool usedBumpImpactSheet = !usedArtifactImpactSheet && result.IsOrdinaryBump && CreateBumpImpact(result); if (!usedArtifactImpactSheet && !usedBumpImpactSheet) { CreateImpact(result.HitPosition, color, strength, rayCount); CreateSlash( result.HitPosition, result.KnockbackDirection, color, result.IsDash ? strength * 1.15f : strength); } if (result.KnockbackForce > 0f) { CreateFloorStreak( result.HitPosition, result.KnockbackDirection, color); } RequestFlash(color, flashDuration); RequestHitStop(hitStopDuration); if (result.IsStrongDash) { RequestCameraShake(heavyCameraShakePixels); } } private bool CreateArtifactImpact(CombatHitResult result) { Sprite[] frames = GetArtifactImpactFrames( result.ArtifactEffect, result.IsChargedArtifact); if (frames == null || frames.Length != ArtifactImpactFrameCount) { return false; } Vector2 direction = result.KnockbackDirection.sqrMagnitude > 0f ? result.KnockbackDirection.normalized : Vector2.right; string effectName = GetArtifactImpactEffectName( result.ArtifactEffect); string chargeName = result.IsChargedArtifact ? "Charged" : "Normal"; GameObject root = new($"Artifact Impact {effectName} {chargeName}"); root.transform.localScale = Vector3.one * 2f; root.transform.SetPositionAndRotation( result.HitPosition, Quaternion.Euler( 0f, 0f, Mathf.Atan2(direction.y, direction.x) * Mathf.Rad2Deg)); SpriteRenderer renderer = root.AddComponent(); renderer.sprite = frames[0]; renderer.color = Color.white; renderer.sortingLayerID = playerRenderer.sortingLayerID; renderer.sortingOrder = playerRenderer.sortingOrder + 102; RegisterTransient(root, false); artifactImpactVisuals.Add(root); StartCoroutine(AnimateArtifactImpact(root, renderer, frames)); return true; } private Sprite[] GetArtifactImpactFrames( ActiveArtifactEffect effect, bool charged) { string resourcePath = GetArtifactImpactResourcePath(effect, charged); if (string.IsNullOrEmpty(resourcePath)) { return null; } if (!artifactImpactFrameCache.TryGetValue( resourcePath, out Sprite[] frames)) { frames = LoadBumpImpactFrames(resourcePath); artifactImpactFrameCache.Add(resourcePath, frames); } return frames; } private static string GetArtifactImpactEffectName( ActiveArtifactEffect effect) { return effect switch { ActiveArtifactEffect.Phoenix => "SearingRay", ActiveArtifactEffect.ChainLightning => "Arc", _ => effect.ToString(), }; } private bool CreateBumpImpact(CombatHitResult result) { Sprite[] frames = GetBumpImpactFrames(result.Side); if (frames == null || frames.Length != BumpImpactFrameCount) { return false; } Vector2 direction = result.KnockbackDirection.sqrMagnitude > 0f ? result.KnockbackDirection.normalized : Vector2.right; string grade = result.Side switch { HitSide.Back => "Strong", HitSide.Side => "Medium", _ => "Weak", }; GameObject root = new($"Bump Impact {grade}"); root.transform.localScale = Vector3.one * 2f; root.transform.SetPositionAndRotation( result.HitPosition, Quaternion.Euler( 0f, 0f, Mathf.Atan2(direction.y, direction.x) * Mathf.Rad2Deg)); SpriteRenderer renderer = root.AddComponent(); renderer.sprite = frames[0]; renderer.color = Color.white; renderer.sortingLayerID = playerRenderer.sortingLayerID; renderer.sortingOrder = playerRenderer.sortingOrder + 102; RegisterTransient(root, false); bumpImpactVisuals.Add(root); StartCoroutine(AnimateBumpImpact(root, renderer, frames)); return true; } private void CreateBlockedBump( Vector2 hitPosition, Vector2 approachDirection) { Sprite sprite = GetBlockedBumpSprite(); if (sprite == null || playerRenderer == null) { return; } GameObject root = new("Blocked Bump"); root.transform.position = hitPosition + Vector2.up * BlockedBumpVerticalOffset; root.transform.rotation = Quaternion.identity; root.transform.localScale = Vector3.one; SpriteRenderer renderer = root.AddComponent(); renderer.sprite = sprite; renderer.flipX = approachDirection.x < 0f; renderer.color = Color.white; renderer.sortingLayerID = playerRenderer.sortingLayerID; renderer.sortingOrder = playerRenderer.sortingOrder + 102; RegisterTransient(root, false); blockedBumpVisuals.Add(root); StartCoroutine(AnimateBlockedBump(root, renderer)); } private void HandlePlayerGuardImpact(Vector2 incomingDirection) { if (playerHealth == null || playerRenderer == null || Time.time < nextGuardBlockImpactAt) { return; } Sprite sprite = GetGuardBlockImpactSprite(); if (sprite == null) { return; } nextGuardBlockImpactAt = Time.time + GuardBlockImpactInterval; Vector2 direction = incomingDirection.sqrMagnitude > 0f ? incomingDirection.normalized : Vector2.zero; Collider2D playerCollider = playerHealth.GetComponent(); Vector2 bodyCenter = playerCollider != null ? playerCollider.bounds.center : (Vector2)playerRenderer.bounds.center; Vector2 impactPosition = bodyCenter - direction * GuardBlockImpactOffset; GameObject root = new("Player Guard Block Impact"); root.transform.SetParent(transform, false); root.transform.localPosition = transform.InverseTransformPoint(impactPosition); root.transform.localRotation = Quaternion.identity; root.transform.localScale = new Vector3( SafeScaleInverse(transform.lossyScale.x), SafeScaleInverse(transform.lossyScale.y), SafeScaleInverse(transform.lossyScale.z)); SpriteRenderer renderer = root.AddComponent(); renderer.sprite = sprite; renderer.color = Color.white; renderer.sortingLayerID = playerRenderer.sortingLayerID; renderer.sortingOrder = playerRenderer.sortingOrder + 20; RegisterTransient(root, false); guardBlockImpactVisuals.Add(root); StartCoroutine(AnimateGuardBlockImpact(root, renderer)); } private static Sprite GetGuardBlockImpactSprite() { if (!guardBlockImpactSpriteLoaded) { guardBlockImpactSprite = Resources.Load(GuardBlockImpactResource); guardBlockImpactSpriteLoaded = true; } return guardBlockImpactSprite; } private static Sprite GetBlockedBumpSprite() { if (!blockedBumpSpriteLoaded) { blockedBumpSprite = Resources.Load(BlockedBumpResource); blockedBumpSpriteLoaded = true; } return blockedBumpSprite; } private Sprite[] GetBumpImpactFrames(HitSide side) { ActiveArtifactDefinition selected = playerRenderer != null ? playerRenderer.GetComponentInParent()?.CurrentArtifact : null; if (selected != null) { string grade = side == HitSide.Back ? "Strong" : side == HitSide.Side ? "Medium" : "Weak"; string path = "Artifacts/ThreeColor-v1/Combat/BumpImpact-" + grade + "-" + selected.ArtifactColor + "-v1"; if (!artifactImpactFrameCache.TryGetValue(path, out Sprite[] colored)) { colored = LoadBumpImpactFrames(path); artifactImpactFrameCache[path] = colored; } if (colored.Length == BumpImpactFrameCount) return colored; } switch (side) { case HitSide.Back: if (bumpImpactStrongFrames == null) { bumpImpactStrongFrames = LoadBumpImpactFrames( BumpImpactStrongResource); } return bumpImpactStrongFrames; case HitSide.Side: if (bumpImpactMediumFrames == null) { bumpImpactMediumFrames = LoadBumpImpactFrames( BumpImpactMediumResource); } return bumpImpactMediumFrames; default: if (bumpImpactWeakFrames == null) { bumpImpactWeakFrames = LoadBumpImpactFrames( BumpImpactWeakResource); } return bumpImpactWeakFrames; } } private static Sprite[] LoadBumpImpactFrames(string resourcePath) { Sprite[] frames = Resources.LoadAll(resourcePath); System.Array.Sort( frames, (left, right) => System.String.CompareOrdinal(left.name, right.name)); return frames; } private IEnumerator AnimateBumpImpact( GameObject root, SpriteRenderer renderer, Sprite[] frames) { for (int i = 0; i < frames.Length; i++) { if (root == null || renderer == null) { yield break; } renderer.sprite = frames[i]; float elapsed = 0f; float duration = BumpImpactFrameDurations[i]; while (elapsed < duration) { if (root == null || renderer == null) { yield break; } elapsed += Time.deltaTime; yield return null; } } DestroyTransient(root); } private IEnumerator AnimateBlockedBump( GameObject root, SpriteRenderer renderer) { float elapsed = 0f; float totalDuration = BlockedBumpHoldDuration + BlockedBumpFadeDuration; while (root != null && renderer != null && elapsed < totalDuration) { Color color = renderer.color; color.a = elapsed <= BlockedBumpHoldDuration ? 1f : 1f - Mathf.Clamp01( (elapsed - BlockedBumpHoldDuration) / BlockedBumpFadeDuration); renderer.color = color; elapsed += Time.deltaTime; yield return null; } DestroyTransient(root); } private IEnumerator AnimateGuardBlockImpact( GameObject root, SpriteRenderer renderer) { float elapsed = 0f; float totalDuration = GuardBlockImpactHoldDuration + GuardBlockImpactFadeDuration; while (root != null && renderer != null && elapsed < totalDuration) { renderer.sortingOrder = playerRenderer.sortingOrder + 20; Color color = renderer.color; color.a = elapsed <= GuardBlockImpactHoldDuration ? 1f : 1f - Mathf.Clamp01( (elapsed - GuardBlockImpactHoldDuration) / GuardBlockImpactFadeDuration); renderer.color = color; elapsed += Time.deltaTime; yield return null; } DestroyTransient(root); } private IEnumerator AnimateArtifactImpact( GameObject root, SpriteRenderer renderer, Sprite[] frames) { for (int i = 0; i < frames.Length; i++) { if (root == null || renderer == null) { yield break; } renderer.sprite = frames[i]; float elapsed = 0f; float duration = BumpImpactFrameDurations[i]; while (elapsed < duration) { if (root == null || renderer == null) { yield break; } elapsed += Time.deltaTime; yield return null; } } DestroyTransient(root); } private IEnumerator AnimatePlayerHurtImpact( GameObject root, SpriteRenderer renderer, Sprite[] frames) { for (int i = 0; i < frames.Length; i++) { if (root == null || renderer == null) { yield break; } renderer.sprite = frames[i]; float elapsed = 0f; float duration = PlayerHurtImpactFrameDurations[i]; while (elapsed < duration) { if (root == null || renderer == null) { yield break; } elapsed += Time.deltaTime; yield return null; } } DestroyTransient(root); } private void ClearBumpImpactVisuals() { if (bumpImpactVisuals.Count == 0) { return; } GameObject[] visuals = new GameObject[bumpImpactVisuals.Count]; bumpImpactVisuals.CopyTo(visuals); foreach (GameObject visual in visuals) { DestroyTransient(visual); } bumpImpactVisuals.Clear(); } private void ClearBlockedBumpVisuals() { if (blockedBumpVisuals.Count == 0) { return; } GameObject[] visuals = new GameObject[blockedBumpVisuals.Count]; blockedBumpVisuals.CopyTo(visuals); foreach (GameObject visual in visuals) { DestroyTransient(visual); } blockedBumpVisuals.Clear(); } private void ClearGuardBlockImpactVisuals() { if (guardBlockImpactVisuals.Count == 0) { return; } GameObject[] visuals = new GameObject[guardBlockImpactVisuals.Count]; guardBlockImpactVisuals.CopyTo(visuals); foreach (GameObject visual in visuals) { DestroyTransient(visual); } guardBlockImpactVisuals.Clear(); } private void ClearArtifactImpactVisuals() { if (artifactImpactVisuals.Count == 0) { return; } GameObject[] visuals = new GameObject[artifactImpactVisuals.Count]; artifactImpactVisuals.CopyTo(visuals); foreach (GameObject visual in visuals) { DestroyTransient(visual); } artifactImpactVisuals.Clear(); } private void ClearPlayerHurtImpactVisuals() { if (playerHurtImpactVisuals.Count == 0) { return; } GameObject[] visuals = new GameObject[playerHurtImpactVisuals.Count]; playerHurtImpactVisuals.CopyTo(visuals); foreach (GameObject visual in visuals) { DestroyTransient(visual); } playerHurtImpactVisuals.Clear(); } private void HandlePlayerDamaged(Vector2 knockbackDirection, float knockbackDistance) { Color damageColor = new(1f, 0.18f, 0.12f, 1f); bool isHeavy = knockbackDistance >= heavyKnockbackThreshold; Vector2 incomingDirection = knockbackDirection.sqrMagnitude > 0f ? -knockbackDirection.normalized : Vector2.zero; Vector2 impactPosition = (Vector2)transform.position + incomingDirection * 0.18f; if (!CreatePlayerHurtImpact(impactPosition)) { CreateImpact( impactPosition, damageColor, isHeavy ? 1.65f : 1.35f, isHeavy ? 8 : 6, "Player Hurt Impact"); } RequestFlash(damageColor, damageFlashDuration); RequestInvulnerabilityBlink(playerHealth.InvulnerabilityDuration); RequestHitStop(damageHitStopDuration); RequestCameraShake( isHeavy ? heavyCameraShakePixels : cameraShakePixels); } private bool CreatePlayerHurtImpact(Vector2 position) { Sprite[] frames = GetPlayerHurtImpactFrames(); if (frames == null || frames.Length != PlayerHurtImpactFrameCount) { return false; } GameObject root = new("Player Hurt Impact"); root.transform.SetPositionAndRotation(position, Quaternion.identity); root.transform.localScale = Vector3.one; SpriteRenderer renderer = root.AddComponent(); renderer.sprite = frames[0]; renderer.color = Color.white; renderer.sortingLayerID = playerRenderer.sortingLayerID; renderer.sortingOrder = playerRenderer.sortingOrder + 102; RegisterTransient(root, false); playerHurtImpactVisuals.Add(root); StartCoroutine(AnimatePlayerHurtImpact(root, renderer, frames)); return true; } private Sprite[] GetPlayerHurtImpactFrames() { if (playerHurtImpactFrames == null) { playerHurtImpactFrames = LoadBumpImpactFrames( PlayerHurtImpactResource); } return playerHurtImpactFrames; } private void CreateImpact( Vector2 position, Color color, float strength, int rayCount, string objectName = "Hit Impact") { GameObject root = new(objectName); root.transform.position = position; transientObjects.Add(root); var renderers = new SpriteRenderer[rayCount + 2]; for (int i = 0; i < rayCount; i++) { float angle = 360f * i / rayCount; Vector2 direction = Quaternion.Euler(0f, 0f, angle) * Vector2.right; GameObject ray = new($"Ray {i + 1}"); ray.transform.SetParent(root.transform, false); ray.transform.localPosition = direction * (0.1f * strength); ray.transform.localRotation = Quaternion.Euler(0f, 0f, angle); ray.transform.localScale = new Vector3( 0.42f * strength, 0.045f * strength, 1f); SpriteRenderer renderer = ray.AddComponent(); renderer.sprite = GetWhiteSprite(); renderer.color = i % 2 == 0 ? Color.Lerp(color, Color.white, 0.7f) : color; renderer.sortingLayerID = playerRenderer.sortingLayerID; renderer.sortingOrder = playerRenderer.sortingOrder + 100; renderers[i] = renderer; } GameObject core = new("White Core"); core.transform.SetParent(root.transform, false); core.transform.localRotation = Quaternion.Euler(0f, 0f, 45f); core.transform.localScale = new Vector3( 0.22f * strength, 0.22f * strength, 1f); SpriteRenderer coreRenderer = core.AddComponent(); coreRenderer.sprite = GetWhiteSprite(); coreRenderer.color = Color.white; coreRenderer.sortingLayerID = playerRenderer.sortingLayerID; coreRenderer.sortingOrder = playerRenderer.sortingOrder + 102; renderers[rayCount] = coreRenderer; GameObject crossFlare = new("Cross Flare"); crossFlare.transform.SetParent(root.transform, false); crossFlare.transform.localRotation = Quaternion.Euler(0f, 0f, -45f); crossFlare.transform.localScale = new Vector3( 0.48f * strength, 0.055f * strength, 1f); SpriteRenderer crossRenderer = crossFlare.AddComponent(); crossRenderer.sprite = GetWhiteSprite(); crossRenderer.color = Color.Lerp(color, Color.white, 0.78f); crossRenderer.sortingLayerID = playerRenderer.sortingLayerID; crossRenderer.sortingOrder = playerRenderer.sortingOrder + 101; renderers[rayCount + 1] = crossRenderer; StartCoroutine(AnimateImpact(root, renderers)); } private void CreateSlash( Vector2 position, Vector2 direction, Color color, float strength) { if (direction.sqrMagnitude <= 0f) { return; } Vector2 normalizedDirection = direction.normalized; GameObject root = new("Attack Slash"); root.transform.SetPositionAndRotation( position - normalizedDirection * 0.08f, Quaternion.Euler( 0f, 0f, Mathf.Atan2(normalizedDirection.y, normalizedDirection.x) * Mathf.Rad2Deg)); transientObjects.Add(root); var renderers = new SpriteRenderer[5]; for (int i = 0; i < renderers.Length; i++) { float lane = i - 2f; float laneDistance = Mathf.Abs(lane); GameObject streak = new($"Streak {i + 1}"); streak.transform.SetParent(root.transform, false); streak.transform.localPosition = new Vector2(-0.06f, lane * 0.045f * strength); streak.transform.localRotation = Quaternion.Euler(0f, 0f, lane * 9f); streak.transform.localScale = new Vector3( (0.56f - laneDistance * 0.08f) * strength, (0.06f - laneDistance * 0.008f) * strength, 1f); SpriteRenderer renderer = streak.AddComponent(); renderer.sprite = GetWhiteSprite(); renderer.color = laneDistance <= 0f ? Color.white : Color.Lerp(color, Color.white, laneDistance == 1f ? 0.55f : 0.15f); renderer.sortingLayerID = playerRenderer.sortingLayerID; renderer.sortingOrder = playerRenderer.sortingOrder + 99; renderers[i] = renderer; } StartCoroutine(AnimateSlash(root, renderers, normalizedDirection)); } private IEnumerator AnimateSlash( GameObject root, SpriteRenderer[] renderers, Vector2 direction) { float startedAt = Time.unscaledTime; Vector2 startPosition = root.transform.position; while (root != null) { float elapsed = Time.unscaledTime - startedAt; float progress = slashDuration > 0f ? Mathf.Clamp01(elapsed / slashDuration) : 1f; root.transform.position = startPosition + direction * Mathf.Lerp(0f, 0.24f, progress); root.transform.localScale = Vector3.one * Mathf.Lerp(0.7f, 1.15f, progress); foreach (SpriteRenderer renderer in renderers) { Color streakColor = renderer.color; streakColor.a = 1f - progress; renderer.color = streakColor; } if (progress >= 1f) { break; } yield return null; } DestroyTransient(root); } private IEnumerator AnimateImpact(GameObject root, SpriteRenderer[] renderers) { float startedAt = Time.unscaledTime; while (root != null) { float elapsed = Time.unscaledTime - startedAt; float progress = impactDuration > 0f ? Mathf.Clamp01(elapsed / impactDuration) : 1f; root.transform.localScale = Vector3.one * Mathf.Lerp(0.6f, 1.5f, progress); foreach (SpriteRenderer renderer in renderers) { Color color = renderer.color; color.a = 1f - progress; renderer.color = color; } if (progress >= 1f) { break; } yield return null; } DestroyTransient(root); } private IEnumerator FadeAndDestroy( GameObject transientObject, SpriteRenderer renderer, float duration) { float startedAt = Time.unscaledTime; Color startColor = renderer.color; while (transientObject != null) { float elapsed = Time.unscaledTime - startedAt; float progress = duration > 0f ? Mathf.Clamp01(elapsed / duration) : 1f; Color color = startColor; color.a = startColor.a * (1f - progress); renderer.color = color; if (progress >= 1f) { break; } yield return null; } DestroyTransient(transientObject); } private void RequestFlash(Color color, float duration) { currentFlashColor = Color.Lerp(Color.white, color, 0.55f); flashUntil = Mathf.Max(flashUntil, Time.unscaledTime + duration); EnsurePlayerVisualRoutine(); } private void RequestInvulnerabilityBlink(float duration) { blinkUntil = Mathf.Max(blinkUntil, Time.time + duration); EnsurePlayerVisualRoutine(); } private void EnsurePlayerVisualRoutine() { if (playerVisualCoroutine == null) { playerVisualCoroutine = StartCoroutine(PlayerVisualRoutine()); } } private IEnumerator PlayerVisualRoutine() { while (Time.unscaledTime < flashUntil || Time.time < blinkUntil) { if (Time.unscaledTime < flashUntil) { playerRenderer.color = currentFlashColor; } else { Color blinkColor = originalColor; bool dimmed = invulnerabilityBlinkInterval > 0f && Mathf.FloorToInt( Time.time / invulnerabilityBlinkInterval) % 2 == 0; if (dimmed) { blinkColor.a *= invulnerabilityBlinkAlpha; } playerRenderer.color = blinkColor; } yield return null; } playerRenderer.color = originalColor; playerVisualCoroutine = null; } private void RequestCameraShake(float pixels) { if (worldCamera == null || pixels <= 0f || cameraShakeDuration <= 0f) { return; } RestoreCameraPosition(); if (cameraFollow == null) { cameraRestPosition = worldCamera.transform.position; } cameraShakeCoroutine = StartCoroutine(CameraShakeRoutine(pixels)); } private IEnumerator CameraShakeRoutine(float pixels) { float startedAt = Time.unscaledTime; while (worldCamera != null) { float progress = Mathf.Clamp01( (Time.unscaledTime - startedAt) / cameraShakeDuration); Vector2 randomOffset = Random.insideUnitCircle * (pixels * (1f - progress)); randomOffset.x = Mathf.Round(randomOffset.x); randomOffset.y = Mathf.Round(randomOffset.y); Vector2 worldOffset = randomOffset / assetsPixelsPerUnit; if (cameraFollow != null) { // ArenaCameraFollow owns the clamped base center. Keep // shake as a transient offset so LateUpdate cannot fight // this coroutine or restore a jittered position. cameraFollow.SetExternalShakeOffset(worldOffset); } else { worldCamera.transform.position = cameraRestPosition + (Vector3)worldOffset; } if (progress >= 1f) { break; } yield return null; } if (worldCamera != null) { if (cameraFollow != null) { cameraFollow.ClearExternalShakeOffset(); } else { worldCamera.transform.position = cameraRestPosition; } } cameraShakeCoroutine = null; } private void RestoreCameraPosition() { if (cameraShakeCoroutine != null) { StopCoroutine(cameraShakeCoroutine); cameraShakeCoroutine = null; } if (cameraFollow != null) { cameraFollow.ClearExternalShakeOffset(); } else if (worldCamera != null) { worldCamera.transform.position = cameraRestPosition; } } private void RequestHitStop(float duration) { if (duration <= 0f) { return; } hitStopUntil = Mathf.Max(hitStopUntil, Time.unscaledTime + duration); if (hitStopCoroutine == null) { hitStopCoroutine = StartCoroutine(HitStopRoutine()); } } private IEnumerator HitStopRoutine() { Time.timeScale = 0f; while (Time.unscaledTime < hitStopUntil) { yield return null; } RestoreGameplayTimeScale(); hitStopCoroutine = null; } private static void RestoreGameplayTimeScale() { RunManager runManager = RunManager.Instance; Time.timeScale = runManager != null && (runManager.IsSelectionOpen || runManager.IsGameOver) ? 0f : 1f; } private void DestroyTransient(GameObject transientObject) { if (transientObject == null) { bumpImpactVisuals.RemoveWhere(visual => visual == null); blockedBumpVisuals.RemoveWhere(visual => visual == null); guardBlockImpactVisuals.RemoveWhere(visual => visual == null); artifactImpactVisuals.RemoveWhere(visual => visual == null); playerHurtImpactVisuals.RemoveWhere(visual => visual == null); return; } bumpImpactVisuals.Remove(transientObject); blockedBumpVisuals.Remove(transientObject); guardBlockImpactVisuals.Remove(transientObject); artifactImpactVisuals.Remove(transientObject); playerHurtImpactVisuals.Remove(transientObject); int instanceId = transientObject.GetInstanceID(); activeTransientIds.Remove(instanceId); transientObjects.Remove(transientObject); heavyEffects.Remove(transientObject); pooledLineTransientIds.Remove(instanceId); if (destroyPendingIds.Add(instanceId) && destroyPendingCleanupCoroutine == null) { if (isActiveAndEnabled && gameObject.activeInHierarchy) { destroyPendingCleanupCoroutine = StartCoroutine(ClearDestroyPendingNextFrame()); } else { destroyPendingIds.Remove(instanceId); } } Destroy(transientObject); } private IEnumerator ClearDestroyPendingNextFrame() { yield return null; destroyPendingIds.Clear(); destroyPendingCleanupCoroutine = null; } private Material GetEffectMaterial() { if (effectMaterial == null) { effectMaterial = new Material(Shader.Find("Sprites/Default")) { name = "Interaction Feedback Material", }; } return effectMaterial; } private static Sprite GetWhiteSprite() { if (whiteSprite == null) { whiteSprite = Sprite.Create( Texture2D.whiteTexture, new Rect(0f, 0f, Texture2D.whiteTexture.width, Texture2D.whiteTexture.height), new Vector2(0.5f, 0.5f), Texture2D.whiteTexture.width); whiteSprite.name = "Combat Feedback White Sprite"; whiteSprite.hideFlags = HideFlags.HideAndDontSave; } return whiteSprite; } } }