Files
tiny_tackle_heroes/Assets/_Project/Scripts/Combat/CombatFeedback.cs
T

2392 lines
89 KiB
C#

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<GameObject> transientObjects = new();
private readonly List<GameObject> heavyEffects = new();
private readonly Stack<GameObject> lineTransientPool = new();
private readonly HashSet<int> destroyPendingIds = new();
private readonly HashSet<int> pooledLineTransientIds = new();
private readonly HashSet<int> activeTransientIds = new();
private readonly Dictionary<GameObject, GameObject> statusVisuals = new();
private readonly Dictionary<GameObject, GameObject> staggerVisuals = new();
private readonly Dictionary<GameObject, Coroutine> statusRoutines = new();
private readonly Dictionary<GameObject, Color> stunOriginalColors = new();
private readonly Dictionary<EnemyController, int> pulseLeaseCounts = new();
private readonly List<GameObject> cycloneAfterimages = new();
private readonly HashSet<GameObject> bumpImpactVisuals = new();
private readonly HashSet<GameObject> blockedBumpVisuals = new();
private readonly HashSet<GameObject> guardBlockImpactVisuals = new();
private readonly HashSet<GameObject> artifactImpactVisuals = new();
private readonly HashSet<GameObject> playerHurtImpactVisuals = new();
private readonly Dictionary<string, Sprite[]> 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<SpriteRenderer>();
playerStats = GetComponent<PlayerStats>();
playerHealth = GetComponent<PlayerHealth>();
activeArtifactController = GetComponent<ActiveArtifactController>();
worldCamera = Camera.main;
cameraFollow = worldCamera != null
? worldCamera.GetComponent<ArenaCameraFollow>()
: null;
originalColor = playerRenderer.color;
lastCycloneSamplePosition = transform.position;
hasCycloneSamplePosition = true;
if (worldCamera != null)
{
cameraRestPosition = worldCamera.transform.position;
}
}
private void Start()
{
if (activeArtifactController == null)
{
activeArtifactController = GetComponent<ActiveArtifactController>();
}
}
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<EnemyController, int> 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<GameObject, Color> pair in stunOriginalColors)
{
if (pair.Key != null)
{
SpriteRenderer renderer = pair.Key.GetComponent<SpriteRenderer>();
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<CombatFeedback>();
}
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<SpriteRenderer>();
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<EnemyController>();
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<SpriteRenderer>();
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<SpriteRenderer>();
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<SpriteRenderer>();
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<EnemyController>()
: null;
enemy?.AcquireSpriteVisualLease("Pulse");
if (enemy != null)
{
pulseLeaseCounts.TryGetValue(enemy, out int leaseCount);
pulseLeaseCounts[enemy] = leaseCount + 1;
}
SpriteRenderer source = target != null
? target.GetComponent<SpriteRenderer>()
: 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<SpriteRenderer>() : 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<SpriteRenderer>();
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<SpriteRenderer>();
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<SpriteRenderer>();
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<SpriteRenderer>();
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<EnemyController>();
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<SpriteRenderer>();
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<TextMesh>();
MeshRenderer meshRenderer = root.GetComponent<MeshRenderer>();
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<LineRenderer>();
if (line == null)
{
line = root.AddComponent<LineRenderer>();
}
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<LineRenderer>();
if (line == null)
{
line = root.AddComponent<LineRenderer>();
}
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<LineRenderer>();
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<LineRenderer>();
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<SpriteRenderer>();
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<ActiveArtifactController>()?.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<SpriteRenderer>();
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<SpriteRenderer>();
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<SpriteRenderer>();
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<Collider2D>();
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<SpriteRenderer>();
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<Sprite>(GuardBlockImpactResource);
guardBlockImpactSpriteLoaded = true;
}
return guardBlockImpactSprite;
}
private static Sprite GetBlockedBumpSprite()
{
if (!blockedBumpSpriteLoaded)
{
blockedBumpSprite = Resources.Load<Sprite>(BlockedBumpResource);
blockedBumpSpriteLoaded = true;
}
return blockedBumpSprite;
}
private Sprite[] GetBumpImpactFrames(HitSide side)
{
ActiveArtifactDefinition selected = playerRenderer != null
? playerRenderer.GetComponentInParent<ActiveArtifactController>()?.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<Sprite>(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<SpriteRenderer>();
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<SpriteRenderer>();
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<SpriteRenderer>();
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<SpriteRenderer>();
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<SpriteRenderer>();
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;
}
}
}