2392 lines
89 KiB
C#
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;
|
|
}
|
|
}
|
|
}
|