Files

3187 lines
111 KiB
C#

using System;
using System.Collections;
using System.Collections.Generic;
using BumpCombat.Combat;
using BumpCombat.Constants;
using BumpCombat.Core;
using BumpCombat.Enemies;
using UnityEngine;
using UnityEngine.InputSystem;
namespace BumpCombat.Player
{
[DisallowMultipleComponent]
[RequireComponent(typeof(PlayerStats))]
public sealed class ActiveArtifactController : MonoBehaviour
{
private readonly struct ChainNode
{
public ChainNode(
EnemyController enemy,
Vector2 sourcePosition,
int depth)
{
Enemy = enemy;
SourcePosition = sourcePosition;
Depth = depth;
}
public EnemyController Enemy { get; }
public Vector2 SourcePosition { get; }
public int Depth { get; }
}
private readonly struct ChainStrike
{
public ChainStrike(
EnemyController enemy,
Vector2 start,
Vector2 end,
int depth,
float damage,
float knockback)
{
Enemy = enemy;
Start = start;
End = end;
Depth = depth;
Damage = damage;
Knockback = knockback;
}
public EnemyController Enemy { get; }
public Vector2 Start { get; }
public Vector2 End { get; }
public int Depth { get; }
public float Damage { get; }
public float Knockback { get; }
}
private sealed class TemporaryPlayerBuff
{
public string SourceId;
public ActiveArtifactEffect Effect;
public CharacterStat Stat;
public float Value;
public float ExpiresAt;
}
private const float MinimumDirectionSqrMagnitude = 0.0001f;
private static float PulseRepelBoundaryClearance =>
GameplayConstants.Current.Artifacts.PulseBoundaryClearance;
private static float PulseNormalStunDuration =>
GameplayConstants.Current.Artifacts.PulseNormalStunDuration;
private static float PulseChargedStunDuration =>
GameplayConstants.Current.Artifacts.PulseChargedStunDuration;
private const float PulseRingSpriteAspect = 13f / 32f;
private static float ProjectileAccelerationStrength =>
GameplayConstants.Current.Artifacts.ProjectileAccelerationStrength;
private const string ScorchingRayNormalResource =
"Artifacts/ThreeColor-v1/ScorchingRay/ScorchingRay-Ghostfire-Normal-v1";
private const string ScorchingRayChargedResource =
"Artifacts/ThreeColor-v1/ScorchingRay/ScorchingRay-Ghostfire-Charged-v1";
private const int ScorchingRayFrameCount = 6;
private const int ScorchingRayNormalFrameWidth = 128;
private const int ScorchingRayNormalFrameHeight = 64;
private const int ScorchingRayChargedFrameWidth = 256;
private const int ScorchingRayChargedFrameHeight = 128;
private const float ScorchingRayNormalVisibleWidthPixels = 96f;
private const float ScorchingRayNormalVisibleHeightPixels = 14f;
private const float ScorchingRayChargedVisibleWidthPixels = 192f;
private const float ScorchingRayChargedVisibleHeightPixels = 28f;
private const float ScorchingRayPixelsPerUnit = 32f;
private const float ScorchingRayBurstDuration = 0.15f;
private static readonly float[] ScorchingRayFrameDurations =
{ 0.02f, 0.03f, 0.03f, 0.03f, 0.02f, 0.02f };
private const string PulseRingNormalResource =
"Artifacts/ThreeColor-v1/PulseRing/PulseRing-normal-retouched-v1";
private const string PulseRingChargedResource =
"Artifacts/ThreeColor-v1/PulseRing/PulseRing-charged-retouched-v1";
private const int PulseRingFrameCount = 7;
private const int PulseRingNormalFrameWidth = 160;
private const int PulseRingNormalFrameHeight = 100;
private const int PulseRingChargedFrameWidth = 256;
private const int PulseRingChargedFrameHeight = 104;
private const float PulseRingPixelsPerUnit = 32f;
private const float PulseRingNormalVisibleWidthPixels = 80f;
private const float PulseRingChargedVisibleWidthPixels = 128f;
private const string ChainLightningArcResource =
"Artifacts/ThreeColor-v1/ChainLightning/Ghostfire-Arc-edit-v1";
private const int ChainLightningArcFrameCount = 8;
private const int ChainLightningArcFrameSize = 100;
private const int ChainLightningArcCropX = 8;
private const int ChainLightningArcCropY = 50;
private const int ChainLightningArcCropWidth = 81;
private const int ChainLightningArcCropHeight = 15;
private const float ChainLightningArcAnimationDuration = 0.3f;
private static readonly float[] ChainLightningArcFrameDurations =
{ 0.03f, 0.04f, 0.03f, 0.03f, 0.04f, 0.04f, 0.04f, 0.05f };
private const string ThunderCrashResource =
"Artifacts/ThreeColor-v1/ThunderCrash/ThunderCrash-Combined-v6";
private const int ThunderCrashFrameCount = 6;
private const int ThunderCrashFrameSize = 100;
private const float ThunderCrashPixelsPerUnit = 32f;
private const float ThunderCrashAnimationDuration = 0.16f;
private static readonly float[] ThunderCrashFrameDurations =
{ 0.02f, 0.03f, 0.03f, 0.03f, 0.02f, 0.03f };
private static readonly Vector2[] ThunderCrashDirections =
{
Vector2.right,
new Vector2(1f, 1f).normalized,
Vector2.up,
new Vector2(-1f, 1f).normalized,
Vector2.left,
new Vector2(-1f, -1f).normalized,
Vector2.down,
new Vector2(1f, -1f).normalized,
};
private const string CycloneOrbitNormalBackResource =
"Artifacts/ThreeColor-v1/CycloneOrbit-v3/Cyclone-Normal-Back-v3";
private const string CycloneOrbitNormalFrontResource =
"Artifacts/ThreeColor-v1/CycloneOrbit-v3/Cyclone-Normal-Front-v3";
private const string CycloneOrbitChargedBackResource =
"Artifacts/ThreeColor-v1/CycloneOrbit-v3/Cyclone-Charged-Back-v3";
private const string CycloneOrbitChargedFrontResource =
"Artifacts/ThreeColor-v1/CycloneOrbit-v3/Cyclone-Charged-Front-v3";
private const int CycloneOrbitFrameCount = 8;
private const int CycloneOrbitFrameSize = 100;
private const float CycloneOrbitPixelsPerUnit = 32f;
private const float CycloneOrbitFrameDuration = 0.05f;
private const float CycloneShadowCenterOffsetLocalYPixels = -7.5f;
private const float CycloneOrbitCenterOffsetYPixels = 5f;
private const float CycloneNormalOrbitRadiusXPixels = 38.0625f;
private const float CycloneNormalOrbitRadiusYPixels = 15.03125f;
private const float CycloneChargedOrbitRadiusXPixels = 46f;
private const float CycloneChargedOrbitRadiusYPixels = 18f;
private const float CycloneNormalBaseRange = 0.85f;
private const float CycloneChargedBaseRange = 1.25f;
private const float ThunderCrashGroundRadiusXPixels = 17f;
private const float ThunderCrashGroundRadiusYPixels = 7.25f;
private const float ThunderCrashGroundCenterOffsetXPixels = -0.5f;
private const float ThunderCrashGroundCenterOffsetYPixels = 0.5f;
private const float ThunderCrashOriginLocalY = -0.1f;
private const string PulseDamageReductionSourceSuffix = ".damage-reduction";
private const string CycloneMoveSpeedSourceSuffix = ".move-speed";
[NonSerialized] private ActiveArtifactDefinition[] artifactCatalog;
[SerializeField] private bool grantCatalogAtStart;
// Nonserialized overrides keep focused editor tests able to isolate rules;
// normal gameplay reads the shared artifact settings on each use.
[NonSerialized] private int maxOwnedArtifacts = -1;
[NonSerialized] private int maxArtifactsPerColor = -1;
[NonSerialized] private float maxGauge = -1f;
[NonSerialized] private float startingGauge = -1f;
[NonSerialized] private float normalHitGain = -1f;
[NonSerialized] private float movementGainPerSecond = -1f;
private readonly List<ActiveArtifactDefinition> ownedArtifacts = new();
private readonly HashSet<int> queryTargetIds = new();
private readonly List<Collider2D> circleQueryHits = new(64);
private readonly List<GameObject> transientVisuals = new();
private readonly Dictionary<string, TemporaryPlayerBuff> temporaryPlayerBuffs = new();
private PlayerStats playerStats;
private PlayerController playerController;
private PlayerHealth playerHealth;
private DashController dashController;
private ArtifactChargeVisual artifactChargeVisual;
private Rigidbody2D body;
private ActiveArtifactDefinition chargingArtifact;
private bool useNormalArtifactOnRelease;
private Coroutine executionRoutine;
private Material effectMaterial;
private Sprite[] pulseRingNormalFrames;
private Sprite[] pulseRingChargedFrames;
private Sprite[] chainLightningArcFrames;
private Sprite[] thunderCrashFrames;
private Sprite[] cycloneNormalBackFrames;
private Sprite[] cycloneNormalFrontFrames;
private Sprite[] cycloneChargedBackFrames;
private Sprite[] cycloneChargedFrontFrames;
private Sprite[] scorchingRayNormalFrames;
private Sprite[] scorchingRayChargedFrames;
private ContactFilter2D circleQueryFilter = ContactFilter2D.noFilter;
private float chargeStartedAt;
private Vector2 lastArtifactUsePosition;
private int nextArtifactCastIdentity;
public event Action OnStateChanged;
public event Action<ActiveArtifactDefinition> OnArtifactChargeStarted;
public event Action<ActiveArtifactDefinition> OnArtifactSwitched;
public event Action<ActiveArtifactDefinition, bool> OnArtifactUseSucceeded;
public float CurrentGauge { get; private set; }
public float MaxGauge => ResolveMaxGauge();
public int OwnedArtifactCount => ownedArtifacts.Count;
public int CatalogCount => GetArtifactCatalog()?.Length ?? 0;
public int MaxOwnedArtifacts => ResolveMaximumOwnedArtifacts();
public int MaxArtifactsPerColor => ResolveMaximumArtifactsPerColor();
public int SelectedIndex { get; private set; }
public bool IsCharging => chargingArtifact != null;
private bool IsArtifactChargeUnlocked =>
RunManager.Instance?.IsArtifactChargeUnlocked ?? true;
public bool IsExecutingArtifact { get; private set; }
public bool LocksMovement { get; private set; }
public Vector2 LastArtifactUsePosition => lastArtifactUsePosition;
public ActiveArtifactDefinition CurrentArtifact =>
ownedArtifacts.Count == 0 ? null : ownedArtifacts[SelectedIndex];
public bool HasCycloneMoveSpeedBuff
{
get
{
if (playerStats == null)
{
return false;
}
foreach (TemporaryPlayerBuff buff in temporaryPlayerBuffs.Values)
{
if (buff.Effect != ActiveArtifactEffect.Cyclone
|| buff.Stat != CharacterStat.MoveSpeed
|| buff.ExpiresAt <= Time.time)
{
continue;
}
if (playerStats.GetStackCount(buff.SourceId, buff.Stat) > 0)
{
return true;
}
}
return false;
}
}
public float ChargeProgress =>
!IsCharging
? 0f
: Mathf.Clamp01(
(Time.time - chargeStartedAt)
/ chargingArtifact.ChargeDuration);
public bool IsFullyCharged => IsCharging && ChargeProgress >= 1f;
private void Awake()
{
playerStats = GetComponent<PlayerStats>();
playerController = GetComponent<PlayerController>();
playerHealth = GetComponent<PlayerHealth>();
dashController = GetComponent<DashController>();
artifactChargeVisual = GetComponent<ArtifactChargeVisual>();
if (artifactChargeVisual == null)
{
artifactChargeVisual = gameObject.AddComponent<ArtifactChargeVisual>();
}
artifactChargeVisual.Connect(this);
body = GetComponent<Rigidbody2D>();
if (playerHealth != null)
{
playerHealth.OnDied += HandlePlayerDied;
}
CurrentGauge = Mathf.Clamp(
ResolveStartingGauge(),
0f,
ResolveMaxGauge());
ActiveArtifactDefinition[] catalog = GetArtifactCatalog();
if (!grantCatalogAtStart || catalog == null)
{
return;
}
foreach (ActiveArtifactDefinition definition in catalog)
{
TryAddArtifact(definition, false);
}
}
private void Start()
{
OnStateChanged?.Invoke();
}
private void OnDisable()
{
CancelArtifactInput();
CancelExecution();
StopAllCoroutines();
ClearTemporaryPlayerBuffs();
DestroyTransientVisuals();
artifactChargeVisual?.ClearAllVisuals();
}
private void OnDestroy()
{
if (playerHealth != null)
{
playerHealth.OnDied -= HandlePlayerDied;
}
DestroyPulseRingFrames();
DestroyChainLightningArcFrames();
DestroyThunderCrashFrames();
DestroyCycloneOrbitFrames();
DestroyScorchingRayFrames();
if (effectMaterial != null)
{
DestroyRuntimeObject(effectMaterial);
effectMaterial = null;
}
}
private void HandlePlayerDied()
{
CancelArtifactInput();
CancelExecution();
ClearTemporaryPlayerBuffs();
artifactChargeVisual?.ClearAllVisuals();
}
private void Update()
{
UpdateTemporaryPlayerModifiers();
if (playerHealth != null && playerHealth.IsHurtMovementLocked)
{
CancelArtifactInput();
CancelExecution();
return;
}
if (!RunManager.GameplayInputEnabled || Keyboard.current == null)
{
CancelArtifactInput();
return;
}
Keyboard keyboard = Keyboard.current;
if (keyboard.sKey.wasPressedThisFrame
&& !IsExecutingArtifact
&& (dashController == null || !dashController.IsDashing))
{
SelectNext();
}
if (keyboard.aKey.wasPressedThisFrame)
{
HandleArtifactButtonPressed();
}
if (IsCharging)
{
OnStateChanged?.Invoke();
}
if (keyboard.aKey.wasReleasedThisFrame)
{
HandleArtifactButtonReleased();
}
}
public ActiveArtifactDefinition GetArtifactAt(int index)
{
return index >= 0 && index < ownedArtifacts.Count
? ownedArtifacts[index]
: null;
}
public ActiveArtifactDefinition GetCatalogArtifactAt(int index)
{
ActiveArtifactDefinition[] catalog = GetArtifactCatalog();
return index >= 0 && index < CatalogCount
? catalog[index]
: null;
}
public bool OwnsArtifact(ActiveArtifactDefinition definition)
{
return definition != null && ownedArtifacts.Contains(definition);
}
public bool TryAddArtifact(ActiveArtifactDefinition definition)
{
return TryAddArtifact(definition, true, false);
}
public bool SelectNext()
{
if (ownedArtifacts.Count < 2
|| IsExecutingArtifact
|| (dashController != null && dashController.IsDashing))
{
return false;
}
CancelArtifactInput();
SelectedIndex = (SelectedIndex + 1) % ownedArtifacts.Count;
OnStateChanged?.Invoke();
OnArtifactSwitched?.Invoke(CurrentArtifact);
return true;
}
public void AddMovementCharge(float actualMovementTime)
{
if (actualMovementTime <= 0f
|| IsExecutingArtifact
|| (dashController != null && dashController.IsDashing))
{
return;
}
AddGauge(playerStats.Evaluate(
CharacterStat.ArtifactGaugeGain,
ResolveMovementGainPerSecond() * actualMovementTime));
}
public void AddNormalHitCharge()
{
if (IsExecutingArtifact
|| (dashController != null && dashController.IsDashing))
{
return;
}
AddGauge(playerStats.Evaluate(
CharacterStat.ArtifactGaugeGain,
ResolveNormalHitGain()));
}
public bool TryUseCurrent(bool charged)
{
ActiveArtifactDefinition definition = CurrentArtifact;
if (definition == null
|| IsExecutingArtifact
|| (dashController != null && dashController.IsDashing)
|| (playerHealth != null && playerHealth.IsHurtMovementLocked)
|| (charged && !IsArtifactChargeUnlocked))
{
artifactChargeVisual?.NotifyArtifactUseFailed();
return false;
}
float cost = charged
? definition.ChargedGaugeCost
: definition.NormalGaugeCost;
if (CurrentGauge < cost)
{
artifactChargeVisual?.NotifyArtifactUseFailed();
return false;
}
lastArtifactUsePosition = body != null
? body.position
: (Vector2)transform.position;
int castIdentity = NextArtifactCastIdentity();
bool executed = definition.Effect switch
{
ActiveArtifactEffect.Dash =>
dashController != null
&& dashController.TryArtifactDash(
charged,
definition,
castIdentity),
ActiveArtifactEffect.Pulse =>
ExecutePulse(definition, charged, castIdentity),
ActiveArtifactEffect.Phoenix =>
ExecuteScorchingRay(definition, charged, castIdentity),
ActiveArtifactEffect.Cyclone =>
ExecuteCyclone(definition, charged, castIdentity),
ActiveArtifactEffect.ThunderCrash =>
ExecuteThunderCrash(definition, charged, castIdentity),
ActiveArtifactEffect.ChainLightning =>
ExecuteChainLightning(definition, charged, castIdentity),
_ => false,
};
if (!executed)
{
artifactChargeVisual?.NotifyArtifactUseFailed();
return false;
}
if (definition.Effect != ActiveArtifactEffect.Dash)
{
playerController?.TryPlayArtifactUseAnimation();
}
CurrentGauge = Mathf.Max(0f, CurrentGauge - cost);
ApplyArtifactBuff(definition, charged);
OnArtifactUseSucceeded?.Invoke(definition, charged);
OnStateChanged?.Invoke();
return true;
}
private bool TryAddArtifact(
ActiveArtifactDefinition definition,
bool notify,
bool ignoreColorLimit = false)
{
if (definition == null
|| ownedArtifacts.Count >= ResolveMaximumOwnedArtifacts())
{
return false;
}
foreach (ActiveArtifactDefinition owned in ownedArtifacts)
{
if (owned == definition
|| string.Equals(
owned.ArtifactId,
definition.ArtifactId,
StringComparison.Ordinal))
{
return false;
}
}
if (!ignoreColorLimit
&& GetOwnedArtifactColorCount(definition.ArtifactColor)
>= ResolveMaximumArtifactsPerColor())
{
return false;
}
ownedArtifacts.Add(definition);
if (notify)
{
OnStateChanged?.Invoke();
}
return true;
}
private int GetOwnedArtifactColorCount(ArtifactColor color)
{
int count = 0;
for (int i = 0; i < ownedArtifacts.Count; i++)
{
if (ownedArtifacts[i] != null
&& ownedArtifacts[i].ArtifactColor == color)
{
count++;
}
}
return count;
}
private void ApplyArtifactBuff(
ActiveArtifactDefinition definition,
bool charged)
{
if (!Application.isPlaying || playerStats == null)
{
return;
}
switch (definition.Effect)
{
case ActiveArtifactEffect.Pulse:
ApplyTemporaryPlayerModifier(
definition.ArtifactId + PulseDamageReductionSourceSuffix,
CharacterStat.IncomingDamage,
charged
? definition.ChargedDamageReduction
: definition.NormalDamageReduction,
charged
? definition.ChargedBuffDuration
: definition.NormalBuffDuration,
true,
definition.Effect);
break;
case ActiveArtifactEffect.Cyclone:
ApplyTemporaryPlayerModifier(
definition.ArtifactId + CycloneMoveSpeedSourceSuffix,
CharacterStat.MoveSpeed,
charged
? definition.ChargedMoveSpeedIncrease
: definition.NormalMoveSpeedIncrease,
charged
? definition.ChargedBuffDuration
: definition.NormalBuffDuration,
false,
definition.Effect);
break;
}
}
private void ApplyTemporaryPlayerModifier(
string sourceId,
CharacterStat stat,
float value,
float duration,
bool isDamageReduction,
ActiveArtifactEffect effect)
{
if (string.IsNullOrWhiteSpace(sourceId)
|| value <= 0f
|| duration <= 0f)
{
return;
}
float expiresAt = Time.time + duration;
if (!temporaryPlayerBuffs.TryGetValue(sourceId, out TemporaryPlayerBuff buff))
{
buff = new TemporaryPlayerBuff
{
SourceId = sourceId,
Effect = effect,
Stat = stat,
Value = value,
ExpiresAt = expiresAt,
};
playerStats.AddModifier(CreateTemporaryModifier(
buff,
isDamageReduction));
temporaryPlayerBuffs.Add(sourceId, buff);
}
else
{
float previousValue = buff.Value;
buff.Effect = effect;
buff.Value = Mathf.Max(buff.Value, value);
buff.ExpiresAt = Mathf.Max(buff.ExpiresAt, expiresAt);
if (!Mathf.Approximately(previousValue, buff.Value))
{
playerStats.RemoveModifiersFromSource(sourceId);
playerStats.AddModifier(CreateTemporaryModifier(
buff,
isDamageReduction));
}
}
}
private static StatModifier CreateTemporaryModifier(
TemporaryPlayerBuff buff,
bool isDamageReduction)
{
return new StatModifier(
buff.SourceId,
buff.Stat,
isDamageReduction
? ModifierOperation.More
: ModifierOperation.Increased,
isDamageReduction ? -buff.Value : buff.Value);
}
private void UpdateTemporaryPlayerModifiers()
{
if (temporaryPlayerBuffs.Count == 0)
{
return;
}
List<string> expiredSourceIds = null;
foreach (KeyValuePair<string, TemporaryPlayerBuff> pair
in temporaryPlayerBuffs)
{
if (Time.time < pair.Value.ExpiresAt)
{
continue;
}
expiredSourceIds ??= new List<string>();
expiredSourceIds.Add(pair.Key);
}
if (expiredSourceIds == null)
{
return;
}
for (int i = 0; i < expiredSourceIds.Count; i++)
{
string sourceId = expiredSourceIds[i];
if (!temporaryPlayerBuffs.Remove(sourceId))
{
continue;
}
playerStats.RemoveModifiersFromSource(sourceId);
}
}
private void ClearTemporaryPlayerBuffs()
{
foreach (TemporaryPlayerBuff buff in temporaryPlayerBuffs.Values)
{
playerStats?.RemoveModifiersFromSource(buff.SourceId);
}
temporaryPlayerBuffs.Clear();
}
private void BeginCharge()
{
ActiveArtifactDefinition definition = CurrentArtifact;
if (!IsArtifactChargeUnlocked || !CanBeginCharge(definition))
{
return;
}
chargingArtifact = definition;
chargeStartedAt = Time.time;
OnArtifactChargeStarted?.Invoke(definition);
OnStateChanged?.Invoke();
}
private bool CanBeginCharge(ActiveArtifactDefinition definition)
{
return !IsCharging
&& !IsExecutingArtifact
&& definition != null
&& CurrentGauge >= definition.NormalGaugeCost
&& (dashController == null || !dashController.IsDashing);
}
private void HandleArtifactButtonPressed()
{
if (!IsArtifactChargeUnlocked)
{
useNormalArtifactOnRelease = CanBeginCharge(CurrentArtifact);
return;
}
BeginCharge();
}
private void HandleArtifactButtonReleased()
{
bool shouldUseNormalArtifact = useNormalArtifactOnRelease;
useNormalArtifactOnRelease = false;
if (IsCharging)
{
ReleaseCharge();
return;
}
if (shouldUseNormalArtifact)
{
TryUseCurrent(false);
}
}
private void CancelArtifactInput()
{
useNormalArtifactOnRelease = false;
CancelCharge();
}
private void ReleaseCharge()
{
ActiveArtifactDefinition definition = chargingArtifact;
bool useCharged = IsFullyCharged
&& CurrentGauge >= definition.ChargedGaugeCost;
EndCharge(useCharged);
if (!TryUseCurrent(useCharged))
{
artifactChargeVisual?.NotifyArtifactUseFailed();
}
}
private void CancelCharge()
{
EndCharge(false);
}
private void EndCharge(bool preserveReadyFlash)
{
if (!IsCharging)
{
return;
}
chargingArtifact = null;
artifactChargeVisual?.NotifyChargeEnded(preserveReadyFlash);
OnStateChanged?.Invoke();
}
private bool ExecutePulse(
ActiveArtifactDefinition definition,
bool charged,
int castIdentity)
{
float range = GetRange(definition, charged);
float contactWindow = charged
? definition.ChargedDuration
: definition.NormalDuration;
Vector2 center = body != null
? body.position
: (Vector2)transform.position;
if (Application.isPlaying)
{
StartCoroutine(RepelEnemiesDuringPulse(
center,
range,
contactWindow,
definition,
charged,
castIdentity));
StartCoroutine(ShowCircleEffect(
charged ? "Charged Artifact Pulse" : "Artifact Pulse",
range,
definition.EffectColor,
contactWindow,
false,
true,
charged));
}
return true;
}
private IEnumerator RepelEnemiesDuringPulse(
Vector2 center,
float range,
float activeDuration,
ActiveArtifactDefinition definition,
bool charged,
int castIdentity)
{
HashSet<int> contactedEnemyIds = new();
float elapsed = 0f;
RunManager runManager = RunManager.Instance;
while (elapsed < activeDuration
&& (runManager == null
|| (!runManager.IsGameOver && !runManager.IsTitleScreen))
&& (playerHealth == null || playerHealth.CurrentHealth > 0f))
{
// Pause freezes the ring and its contact window. After resume,
// the next frame checks any enemies that entered meanwhile.
if (Time.deltaTime > 0f)
{
List<EnemyController> enemies = FindNearestTargets(
center,
range,
null);
for (int i = 0; i < enemies.Count; i++)
{
EnemyController enemy = enemies[i];
Vector2 offset = enemy.GroundAnchorPosition - center;
if (GetPulseEllipseDistance(
offset,
range,
range * PulseRingSpriteAspect)
> 1.0001f)
{
continue;
}
if (!contactedEnemyIds.Add(enemy.GetInstanceID()))
{
continue;
}
Vector2 direction;
if (offset.sqrMagnitude >= MinimumDirectionSqrMagnitude)
{
direction = offset.normalized;
}
else
{
float angle = Mathf.PI * 2f * i / enemies.Count;
direction = new Vector2(
Mathf.Cos(angle),
Mathf.Sin(angle));
}
EnemyArtifactContactResult contact =
enemy.RegisterArtifactContact(
definition.ArtifactColor,
castIdentity);
bool shouldShowArtifactImpact = contact
== EnemyArtifactContactResult.Groggy
|| enemy.LastArtifactContactCounted;
if (!shouldShowArtifactImpact)
{
// A shielded contact contributes only to the
// groggy stagger pip and cannot repel the target.
continue;
}
if (contact == EnemyArtifactContactResult.Shielded
&& enemy.LastArtifactContactCounted)
{
CombatEvents.RaiseArtifactEffectiveHit(
enemy.gameObject,
castIdentity,
definition.Effect,
charged,
true);
}
ShowArtifactImpactForContact(
enemy,
definition,
direction,
charged);
if (contact != EnemyArtifactContactResult.Groggy)
{
continue;
}
Vector2 ellipseBoundary = GetPulseEllipseBoundaryOffset(
direction,
range,
range * PulseRingSpriteAspect);
float minimumRepelDistance = charged
? definition.ChargedKnockback
: definition.NormalKnockback;
float finalDistance = Mathf.Max(
offset.magnitude + minimumRepelDistance,
ellipseBoundary.magnitude
+ PulseRepelBoundaryClearance);
Vector2 requestedPosition = center
+ direction * finalDistance;
Vector2 previousPosition = enemy.GroundAnchorPosition;
if (enemy.TryReposition(requestedPosition))
{
Vector2 actualPosition = enemy.GroundAnchorPosition;
CombatFeedback.ShowPulseGather(
enemy.gameObject,
previousPosition,
actualPosition);
enemy.ApplyStun(charged
? PulseChargedStunDuration
: PulseNormalStunDuration);
CombatEvents.RaiseArtifactEffectiveHit(
enemy.gameObject,
castIdentity,
definition.Effect,
charged);
}
}
}
yield return null;
elapsed += Time.deltaTime;
}
}
private bool ExecuteScorchingRay(
ActiveArtifactDefinition definition,
bool charged,
int castIdentity)
{
if (!Application.isPlaying)
{
return false;
}
executionRoutine = StartCoroutine(
ScorchingRayRoutine(definition, charged, castIdentity));
return true;
}
private IEnumerator ScorchingRayRoutine(
ActiveArtifactDefinition definition,
bool charged,
int castIdentity)
{
Vector2 direction = GetUseDirection();
Vector2 origin = body != null
? body.position
: (Vector2)transform.position;
float range = GetRange(definition, charged);
float width = charged
? definition.ChargedWidth
: definition.NormalWidth;
float angle = Mathf.Atan2(direction.y, direction.x) * Mathf.Rad2Deg;
GameObject visual = CreateVisual(
charged ? "Charged Scorching Ray" : "Scorching Ray",
false);
SpriteRenderer sprite = CreateScorchingRaySprite(
visual,
charged,
direction,
range,
width);
LineRenderer fallback = sprite == null
? CreatePathLine(visual, definition.EffectColor, width)
: null;
if (fallback != null)
{
fallback.SetPosition(0, Vector3.zero);
fallback.SetPosition(1, direction * range);
}
float igniteTickDamage = playerStats.CalculateDamage(
charged
? definition.ChargedIgniteTickDamage
: definition.NormalIgniteTickDamage,
definition.DamageTag);
HitEnemiesInScorchingRay(
definition,
origin,
direction,
angle,
range,
width,
GetDamage(definition, charged),
GetKnockback(definition, charged),
charged,
igniteTickDamage,
castIdentity);
float elapsed = 0f;
while (elapsed < ScorchingRayBurstDuration)
{
SetScorchingRayFrame(
sprite,
charged ? scorchingRayChargedFrames : scorchingRayNormalFrames,
elapsed);
yield return null;
elapsed += Time.deltaTime;
}
DestroyVisual(visual);
}
private void HitEnemiesInScorchingRay(
ActiveArtifactDefinition definition,
Vector2 origin,
Vector2 direction,
float angle,
float range,
float width,
float damage,
float knockback,
bool charged,
float igniteTickDamage,
int castIdentity)
{
Vector2 center = origin + direction * (range * 0.5f);
Collider2D[] hits = Physics2D.OverlapBoxAll(
center,
new Vector2(range, width),
angle);
List<EnemyController> enemies = CollectUniqueEnemies(hits);
bool hasShownHitFeedback = false;
foreach (EnemyController enemy in enemies)
{
if (!IsGroundAnchorInsideScorchingRay(
origin,
direction,
enemy.GroundAnchorPosition,
range,
width))
{
continue;
}
if (ApplyArtifactHit(
enemy,
definition,
damage,
direction,
knockback,
!hasShownHitFeedback,
charged,
igniteTickDamage,
castIdentity))
{
hasShownHitFeedback = true;
}
}
}
private static bool IsGroundAnchorInsideScorchingRay(
Vector2 origin,
Vector2 direction,
Vector2 groundAnchor,
float range,
float width)
{
Vector2 forward = direction.sqrMagnitude > MinimumDirectionSqrMagnitude
? direction.normalized
: Vector2.right;
Vector2 offset = groundAnchor - origin;
float forwardDistance = Vector2.Dot(offset, forward);
float lateralDistance = Mathf.Abs(Vector2.Dot(
offset,
new Vector2(-forward.y, forward.x)));
return forwardDistance >= -0.0001f
&& forwardDistance <= range + 0.0001f
&& lateralDistance <= width * 0.5f + 0.0001f;
}
private bool ExecuteCyclone(
ActiveArtifactDefinition definition,
bool charged,
int castIdentity)
{
if (!Application.isPlaying || body == null)
{
return false;
}
executionRoutine = StartCoroutine(
CycloneRoutine(definition, charged, castIdentity));
return true;
}
private IEnumerator CycloneRoutine(
ActiveArtifactDefinition definition,
bool charged,
int castIdentity)
{
BeginExecution(true);
Vector2 direction = GetUseDirection();
Vector2 start = body.position;
float moveDistance = charged
? definition.ChargedMoveDistance
: definition.NormalMoveDistance;
Vector2 end = playerController != null
? playerController.ClampToArena(start + direction * moveDistance)
: start + direction * moveDistance;
float duration = Mathf.Max(
charged
? definition.ChargedDuration
: definition.NormalDuration,
Time.fixedDeltaTime);
float hitInterval = Mathf.Max(
definition.HitInterval,
Time.fixedDeltaTime);
float definitionRange = GetRange(definition, charged);
Vector2 hitCenterOffset = GetCycloneHitCenterOffset(
definitionRange,
charged);
Vector2 hitRadii = GetCycloneHitRadii(
definitionRange,
charged);
float visualScale = GetCycloneVisualScale(
definitionRange,
charged);
float damage = GetDamage(definition, charged);
GameObject visual = CreateVisual(
charged ? "Charged Cyclone" : "Cyclone",
false);
CreateCycloneSpriteLayers(
visual,
charged,
visualScale,
out SpriteRenderer cycloneBackRenderer,
out SpriteRenderer cycloneFrontRenderer);
float elapsed = 0f;
float nextHitAt = 0f;
int hitIndex = 0;
int hitCount = GetScheduledHitCount(duration, hitInterval);
while (elapsed < duration)
{
visual.transform.position = body.position
+ GetCycloneShadowOffsetWorld();
SetCycloneSpriteFrame(
cycloneBackRenderer,
cycloneFrontRenderer,
charged,
elapsed);
UpdateCycloneSorting(
cycloneBackRenderer,
cycloneFrontRenderer);
while (hitIndex < hitCount
&& elapsed + 0.0001f >= nextHitAt)
{
HitEnemiesInEllipse(
definition,
body.position + hitCenterOffset,
hitRadii,
damage,
hitIndex == hitCount - 1
? charged
? definition.ChargedKnockback
: definition.NormalKnockback
: 0f,
charged
? definition.ChargedMaxTargets
: definition.NormalMaxTargets,
true,
charged,
castIdentity,
true);
hitIndex++;
nextHitAt += hitInterval;
}
yield return new WaitForFixedUpdate();
elapsed += Time.fixedDeltaTime;
body.MovePosition(Vector2.Lerp(
start,
end,
Mathf.Clamp01(elapsed / duration)));
visual.transform.position = body.position
+ GetCycloneShadowOffsetWorld();
}
while (hitIndex < hitCount)
{
HitEnemiesInEllipse(
definition,
body.position + hitCenterOffset,
hitRadii,
damage,
hitIndex == hitCount - 1
? charged
? definition.ChargedKnockback
: definition.NormalKnockback
: 0f,
charged
? definition.ChargedMaxTargets
: definition.NormalMaxTargets,
true,
charged,
castIdentity,
true);
hitIndex++;
}
body.position = end;
DestroyVisual(visual);
EndExecution();
}
private bool ExecuteThunderCrash(
ActiveArtifactDefinition definition,
bool charged,
int castIdentity)
{
if (!Application.isPlaying)
{
return false;
}
executionRoutine = StartCoroutine(
ThunderCrashRoutine(definition, charged, castIdentity));
return true;
}
private IEnumerator ThunderCrashRoutine(
ActiveArtifactDefinition definition,
bool charged,
int castIdentity)
{
BeginExecution(true);
playerHealth?.GrantInvulnerability(
charged
? definition.ChargedInvulnerabilityDuration
: definition.NormalInvulnerabilityDuration);
CombatEvents.RaiseThunderShield(
transform.position,
charged
? definition.ChargedInvulnerabilityDuration
: definition.NormalInvulnerabilityDuration);
// Thunder Crash is aimed from the player's remembered facing, not
// from movement input that may still be present while the cast
// locks movement. Its ground ring uses an authored lower-body
// anchor relative to the player root, independent of collider data.
Vector2 direction = GetThunderCrashDirection();
float legacyDefinitionRange = GetRange(definition, charged);
Vector2 groundCenter = GetThunderCrashOrigin()
+ direction * 0.45f;
Vector2 hitCenter = groundCenter;
Vector2 effectCenter = groundCenter - GetThunderCrashHitCenterOffset(
legacyDefinitionRange,
direction);
Vector2 hitRadii = GetThunderCrashHitRadii(
legacyDefinitionRange,
direction);
float duration = Mathf.Max(
charged
? definition.ChargedDuration
: definition.NormalDuration,
Time.fixedDeltaTime);
float hitInterval = Mathf.Max(
definition.HitInterval,
Time.fixedDeltaTime);
GameObject visual = CreateVisual(
charged ? "Charged Thunder Crash" : "Thunder Crash",
false);
visual.transform.position = effectCenter;
TryLoadThunderCrashFrames();
SpriteRenderer thunderRenderer =
CreateThunderCrashSprite(
visual,
thunderCrashFrames,
legacyDefinitionRange);
float visualAge = 0f;
float elapsed = 0f;
float nextHitAt = 0f;
int hitIndex = 0;
int hitCount = GetScheduledHitCount(duration, hitInterval);
while (elapsed < duration)
{
while (hitIndex < hitCount
&& elapsed + 0.0001f >= nextHitAt)
{
HitEnemiesInEllipse(
definition,
hitCenter,
hitRadii,
charged
? definition.ChargedDamage
: definition.NormalDamage,
hitIndex == hitCount - 1
? charged
? definition.ChargedKnockback
: definition.NormalKnockback
: 0f,
charged
? definition.ChargedMaxTargets
: definition.NormalMaxTargets,
true,
charged,
castIdentity,
true);
hitIndex++;
nextHitAt += hitInterval;
visualAge = 0f;
}
SetThunderCrashSpriteFrame(
thunderRenderer,
thunderCrashFrames,
visualAge);
yield return null;
elapsed += Time.deltaTime;
visualAge += Time.deltaTime;
}
while (hitIndex < hitCount)
{
HitEnemiesInEllipse(
definition,
hitCenter,
hitRadii,
charged
? definition.ChargedDamage
: definition.NormalDamage,
hitIndex == hitCount - 1
? charged
? definition.ChargedKnockback
: definition.NormalKnockback
: 0f,
charged
? definition.ChargedMaxTargets
: definition.NormalMaxTargets,
true,
charged,
castIdentity,
true);
hitIndex++;
visualAge = 0f;
}
EndExecution();
// The last six-frame tail is allowed to finish after the lock.
while (visualAge < ThunderCrashAnimationDuration)
{
SetThunderCrashSpriteFrame(
thunderRenderer, thunderCrashFrames, visualAge);
yield return null;
visualAge += Time.deltaTime;
}
SetThunderCrashSpriteFrame(
thunderRenderer,
thunderCrashFrames,
ThunderCrashAnimationDuration);
DestroyVisual(visual);
}
private bool ExecuteChainLightning(
ActiveArtifactDefinition definition,
bool charged,
int castIdentity)
{
if (!Application.isPlaying)
{
return false;
}
Vector2 origin = GetCurrentArtifactOrigin();
EnemyController first = FindInitialChainTarget(
origin,
GetRange(definition, charged));
if (first == null)
{
return false;
}
List<ChainStrike> strikes = charged
? PlanBranchingChain(definition, first, origin)
: PlanArcChain(definition, first, origin);
executionRoutine = StartCoroutine(ChainLightningRoutine(
definition,
strikes,
origin,
charged,
castIdentity));
return true;
}
private List<ChainStrike> PlanArcChain(
ActiveArtifactDefinition definition,
EnemyController first,
Vector2 origin)
{
List<ChainStrike> strikes = new();
HashSet<int> visited = new();
EnemyController current = first;
Vector2 source = origin;
int depth = 0;
int plannedCount = 0;
while (current != null
&& depth < definition.NormalChainDepth
&& plannedCount < definition.NormalMaxTargets)
{
int currentId = current.GetInstanceID();
if (!visited.Add(currentId))
{
break;
}
Vector2 currentPosition = current.GroundAnchorPosition;
strikes.Add(new ChainStrike(
current,
source,
currentPosition,
depth,
DamageCalculator.ApplyRepeatedMultiplier(
definition.NormalDamage,
definition.DamageMultiplierPerChain,
depth),
definition.NormalKnockback));
plannedCount++;
if (plannedCount >= definition.NormalMaxTargets)
{
break;
}
List<EnemyController> candidates = FindNearestTargets(
currentPosition,
definition.ChainRange,
visited);
EnemyController nextMain = candidates.Count > 0
? candidates[0]
: null;
int branchLimit = Mathf.Min(
definition.NormalBranchCount,
candidates.Count - (nextMain != null ? 1 : 0));
for (int i = 0;
i < branchLimit
&& plannedCount < definition.NormalMaxTargets;
i++)
{
EnemyController branch = candidates[i + 1];
if (!visited.Add(branch.GetInstanceID()))
{
continue;
}
Vector2 branchPosition = branch.GroundAnchorPosition;
strikes.Add(new ChainStrike(
branch,
currentPosition,
branchPosition,
depth + 1,
DamageCalculator.ApplyRepeatedMultiplier(
definition.NormalDamage,
definition.DamageMultiplierPerChain,
depth + 1),
definition.NormalKnockback));
plannedCount++;
}
source = currentPosition;
current = nextMain;
depth++;
}
return strikes;
}
private List<ChainStrike> PlanBranchingChain(
ActiveArtifactDefinition definition,
EnemyController first,
Vector2 origin)
{
List<ChainStrike> strikes = new();
HashSet<int> visited = new();
Queue<ChainNode> pending = new();
visited.Add(first.GetInstanceID());
pending.Enqueue(new ChainNode(first, origin, 0));
while (pending.Count > 0
&& visited.Count <= definition.ChargedMaxTargets)
{
ChainNode node = pending.Dequeue();
if (node.Enemy == null || node.Enemy.IsDead)
{
continue;
}
Vector2 targetPosition = node.Enemy.GroundAnchorPosition;
strikes.Add(new ChainStrike(
node.Enemy,
node.SourcePosition,
targetPosition,
node.Depth,
DamageCalculator.ApplyRepeatedMultiplier(
definition.ChargedDamage,
definition.DamageMultiplierPerChain,
node.Depth),
definition.ChargedKnockback));
if (node.Depth + 1 >= definition.ChargedChainDepth)
{
continue;
}
List<EnemyController> candidates = FindNearestTargets(
targetPosition,
definition.ChainRange,
visited);
int childCount = Mathf.Min(
definition.ChargedBranchCount,
candidates.Count);
for (int i = 0; i < childCount; i++)
{
if (visited.Count >= definition.ChargedMaxTargets)
{
break;
}
EnemyController child = candidates[i];
if (!visited.Add(child.GetInstanceID()))
{
continue;
}
pending.Enqueue(new ChainNode(
child,
targetPosition,
node.Depth + 1));
}
}
return strikes;
}
private IEnumerator ChainLightningRoutine(
ActiveArtifactDefinition definition,
List<ChainStrike> strikes,
Vector2 origin,
bool charged,
int castIdentity)
{
GameObject visual = CreateVisual(
charged ? "Charged Chain Lightning" : "Chain Lightning",
false);
visual.transform.position = origin;
List<SpriteRenderer> arcRenderers = new(strikes.Count);
Dictionary<SpriteRenderer, float> arcAges = new();
int maximumDepth = 0;
foreach (ChainStrike strike in strikes)
{
maximumDepth = Mathf.Max(maximumDepth, strike.Depth);
}
float propagationDuration = Mathf.Max(
definition.HitInterval,
Time.fixedDeltaTime);
for (int depth = 0; depth <= maximumDepth; depth++)
{
List<(ChainStrike Strike, SpriteRenderer Renderer)> active = new();
foreach (ChainStrike strike in strikes)
{
if (strike.Depth != depth)
{
continue;
}
GameObject segmentObject = new("Lightning Arc Segment");
segmentObject.transform.SetParent(visual.transform, false);
SpriteRenderer renderer = CreateChainLightningArcVisual(
segmentObject,
strike.Start,
strike.End,
origin,
charged ? 1.35f : 1f);
if (renderer != null)
{
arcRenderers.Add(renderer);
arcAges[renderer] = 0f;
}
active.Add((strike, renderer));
}
float elapsed = 0f;
while (elapsed < propagationDuration)
{
elapsed += Time.deltaTime;
float progress = EvaluateProjectileProgress(
elapsed / propagationDuration);
foreach (SpriteRenderer renderer in arcRenderers)
{
arcAges[renderer] += Time.deltaTime;
renderer.sprite = GetChainLightningArcFrame(
arcAges[renderer]);
}
foreach ((ChainStrike strike, SpriteRenderer renderer) in active)
{
if (renderer != null)
{
SetChainLightningArcProgress(
renderer,
strike.Start,
strike.End,
origin,
progress,
charged ? 1.35f : 1f);
}
}
yield return null;
}
foreach ((ChainStrike strike, SpriteRenderer renderer) in active)
{
if (renderer != null)
{
SetChainLightningArcProgress(
renderer,
strike.Start,
strike.End,
origin,
1f,
charged ? 1.35f : 1f);
}
ApplyArtifactHit(
strike.Enemy,
definition,
strike.Damage,
strike.End - strike.Start,
strike.Knockback,
true,
charged: charged,
igniteTickDamage: 0f,
castIdentity: castIdentity);
}
}
bool arcAnimationActive = true;
while (arcAnimationActive)
{
arcAnimationActive = false;
foreach (SpriteRenderer renderer in arcRenderers)
{
arcAges[renderer] += Time.deltaTime;
renderer.sprite = GetChainLightningArcFrame(
arcAges[renderer]);
arcAnimationActive |= arcAges[renderer]
< ChainLightningArcAnimationDuration;
}
if (arcAnimationActive)
{
yield return null;
}
}
DestroyVisual(visual);
}
private SpriteRenderer CreateChainLightningArcVisual(
GameObject owner,
Vector2 start,
Vector2 end,
Vector2 origin,
float verticalScale)
{
if (!TryLoadChainLightningArcFrames())
{
return null;
}
SpriteRenderer renderer = owner.AddComponent<SpriteRenderer>();
renderer.sprite = chainLightningArcFrames[0];
renderer.color = Color.white;
renderer.sortingOrder = 200;
SetChainLightningArcProgress(
renderer, start, end, origin, 0f, verticalScale);
return renderer;
}
private static void SetChainLightningArcProgress(
SpriteRenderer renderer,
Vector2 start,
Vector2 end,
Vector2 origin,
float progress,
float verticalScale)
{
Vector2 delta = end - start;
float length = delta.magnitude;
if (length <= 0.0001f)
{
renderer.enabled = false;
return;
}
renderer.enabled = true;
renderer.transform.localPosition = start - origin;
renderer.transform.localRotation = Quaternion.Euler(
0f, 0f, Mathf.Atan2(delta.y, delta.x) * Mathf.Rad2Deg);
renderer.transform.localScale = new Vector3(
length * Mathf.Clamp01(progress)
/ (ChainLightningArcCropWidth / 32f),
verticalScale,
1f);
}
private bool TryLoadChainLightningArcFrames()
{
if (chainLightningArcFrames != null)
{
return true;
}
Texture2D texture = Resources.Load<Texture2D>(
ChainLightningArcResource);
if (texture == null
|| texture.width < ChainLightningArcFrameSize * ChainLightningArcFrameCount
|| texture.height < ChainLightningArcFrameSize)
{
return false;
}
texture.filterMode = FilterMode.Point;
texture.wrapMode = TextureWrapMode.Clamp;
chainLightningArcFrames = new Sprite[ChainLightningArcFrameCount];
for (int i = 0; i < ChainLightningArcFrameCount; i++)
{
chainLightningArcFrames[i] = Sprite.Create(
texture,
new Rect(
i * ChainLightningArcFrameSize + ChainLightningArcCropX,
ChainLightningArcCropY,
ChainLightningArcCropWidth,
ChainLightningArcCropHeight),
new Vector2(0f, 0.5f),
32f);
chainLightningArcFrames[i].name = $"Chain Lightning Arc {i + 1}";
}
return true;
}
private Sprite GetChainLightningArcFrame(float age)
{
if (chainLightningArcFrames == null)
{
return null;
}
float elapsed = 0f;
for (int i = 0; i < ChainLightningArcFrameDurations.Length; i++)
{
elapsed += ChainLightningArcFrameDurations[i];
if (age < elapsed)
{
return chainLightningArcFrames[i];
}
}
return null;
}
private void DestroyChainLightningArcFrames()
{
DestroyGeneratedSpriteFrames(chainLightningArcFrames);
chainLightningArcFrames = null;
}
private static void DestroyGeneratedSpriteFrames(Sprite[] frames)
{
if (frames == null)
{
return;
}
foreach (Sprite frame in frames)
{
if (frame != null)
{
DestroyRuntimeObject(frame);
}
}
}
private SpriteRenderer CreateThunderCrashSprite(
GameObject owner,
Sprite[] frames,
float range)
{
GameObject layer = new GameObject("Thunder Crash Sprite");
layer.transform.SetParent(owner.transform, false);
SpriteRenderer renderer = layer.AddComponent<SpriteRenderer>();
renderer.color = Color.white;
renderer.sortingOrder = 211;
renderer.sprite = frames != null && frames.Length > 0 ? frames[0] : null;
renderer.enabled = renderer.sprite != null;
layer.transform.localScale = Vector3.one
* GetThunderCrashVisualScale(range);
return renderer;
}
private void SetThunderCrashSpriteFrame(
SpriteRenderer renderer,
Sprite[] frames,
float age)
{
if (renderer == null || frames == null || frames.Length == 0)
{
return;
}
if (age >= ThunderCrashAnimationDuration)
{
renderer.enabled = false;
return;
}
float elapsed = 0f;
int frameIndex = frames.Length - 1;
for (int i = 0; i < ThunderCrashFrameDurations.Length; i++)
{
elapsed += ThunderCrashFrameDurations[i];
if (age < elapsed)
{
frameIndex = i;
break;
}
}
renderer.sprite = frames[frameIndex];
renderer.enabled = true;
}
private bool TryLoadThunderCrashFrames()
{
if (thunderCrashFrames != null)
{
return true;
}
thunderCrashFrames = LoadThunderCrashFrames(
ThunderCrashResource, "Thunder Crash");
return thunderCrashFrames != null;
}
private static Sprite[] LoadThunderCrashFrames(
string resourcePath,
string frameName)
{
Texture2D texture = Resources.Load<Texture2D>(resourcePath);
if (texture == null
|| texture.width < ThunderCrashFrameSize * ThunderCrashFrameCount
|| texture.height < ThunderCrashFrameSize)
{
return null;
}
texture.filterMode = FilterMode.Point;
texture.wrapMode = TextureWrapMode.Clamp;
Sprite[] frames = new Sprite[ThunderCrashFrameCount];
Vector2 pivot = new(0.505f, 0.425f);
for (int i = 0; i < ThunderCrashFrameCount; i++)
{
frames[i] = Sprite.Create(
texture,
new Rect(i * ThunderCrashFrameSize, 0f,
ThunderCrashFrameSize, ThunderCrashFrameSize),
pivot,
ThunderCrashPixelsPerUnit);
frames[i].name = $"{frameName} {i + 1}";
}
return frames;
}
private SpriteRenderer CreateScorchingRaySprite(
GameObject owner,
bool charged,
Vector2 direction,
float range,
float width)
{
Sprite[] frames = charged
? scorchingRayChargedFrames
: scorchingRayNormalFrames;
if (frames == null || frames.Length == 0)
{
TryLoadScorchingRayFrames(charged);
frames = charged
? scorchingRayChargedFrames
: scorchingRayNormalFrames;
}
if (frames == null || frames.Length == 0)
{
return null;
}
owner.transform.rotation = Quaternion.Euler(
0f,
0f,
Mathf.Atan2(direction.y, direction.x) * Mathf.Rad2Deg);
SpriteRenderer renderer = owner.AddComponent<SpriteRenderer>();
renderer.sprite = frames[0];
renderer.sortingOrder = 210;
float visibleWidth = charged
? ScorchingRayChargedVisibleWidthPixels
: ScorchingRayNormalVisibleWidthPixels;
float visibleHeight = charged
? ScorchingRayChargedVisibleHeightPixels
: ScorchingRayNormalVisibleHeightPixels;
renderer.transform.localScale = new Vector3(
range * ScorchingRayPixelsPerUnit / visibleWidth,
width * ScorchingRayPixelsPerUnit / visibleHeight,
1f);
return renderer;
}
private static void SetScorchingRayFrame(
SpriteRenderer renderer,
Sprite[] frames,
float age)
{
if (renderer == null || frames == null || frames.Length == 0)
{
return;
}
float elapsed = 0f;
for (int i = 0; i < ScorchingRayFrameDurations.Length; i++)
{
elapsed += ScorchingRayFrameDurations[i];
if (age < elapsed)
{
renderer.sprite = frames[i];
return;
}
}
renderer.sprite = frames[frames.Length - 1];
}
private bool TryLoadScorchingRayFrames(bool charged)
{
Sprite[] existing = charged
? scorchingRayChargedFrames
: scorchingRayNormalFrames;
if (existing != null)
{
return true;
}
string resource = charged
? ScorchingRayChargedResource
: ScorchingRayNormalResource;
int frameWidth = charged
? ScorchingRayChargedFrameWidth
: ScorchingRayNormalFrameWidth;
int frameHeight = charged
? ScorchingRayChargedFrameHeight
: ScorchingRayNormalFrameHeight;
Texture2D texture = Resources.Load<Texture2D>(resource);
if (texture == null
|| texture.width < frameWidth * ScorchingRayFrameCount
|| texture.height < frameHeight)
{
return false;
}
texture.filterMode = FilterMode.Point;
texture.wrapMode = TextureWrapMode.Clamp;
Sprite[] frames = new Sprite[ScorchingRayFrameCount];
for (int i = 0; i < frames.Length; i++)
{
frames[i] = Sprite.Create(
texture,
new Rect(i * frameWidth, 0f, frameWidth, frameHeight),
new Vector2(0.0625f, 0.5f),
ScorchingRayPixelsPerUnit);
frames[i].name = charged
? $"Scorching Ray Charged {i + 1}"
: $"Scorching Ray Normal {i + 1}";
}
if (charged)
{
scorchingRayChargedFrames = frames;
}
else
{
scorchingRayNormalFrames = frames;
}
return true;
}
private void DestroyScorchingRayFrames()
{
DestroyGeneratedSpriteFrames(scorchingRayNormalFrames);
DestroyGeneratedSpriteFrames(scorchingRayChargedFrames);
scorchingRayNormalFrames = null;
scorchingRayChargedFrames = null;
}
private void DestroyThunderCrashFrames()
{
DestroyGeneratedSpriteFrames(thunderCrashFrames);
thunderCrashFrames = null;
}
private EnemyController FindInitialChainTarget(
Vector2 origin,
float range)
{
List<EnemyController> enemies = FindNearestTargets(
origin,
range,
null);
return enemies.Count > 0 ? enemies[0] : null;
}
private List<EnemyController> FindNearestTargets(
Vector2 origin,
float range,
HashSet<int> excludedIds,
bool filterByGroundAnchor = true)
{
circleQueryHits.Clear();
Physics2D.OverlapCircle(
origin,
range,
circleQueryFilter,
circleQueryHits);
List<EnemyController> enemies = CollectUniqueEnemies(
circleQueryHits,
excludedIds);
if (filterByGroundAnchor)
{
float rangeSqr = range * range;
for (int i = enemies.Count - 1; i >= 0; i--)
{
Vector2 offset = enemies[i].GroundAnchorPosition - origin;
if (offset.sqrMagnitude > rangeSqr + 0.0001f)
{
enemies.RemoveAt(i);
}
}
}
enemies.Sort((left, right) =>
{
float leftDistance =
(left.GroundAnchorPosition - origin).sqrMagnitude;
float rightDistance =
(right.GroundAnchorPosition - origin).sqrMagnitude;
return leftDistance.CompareTo(rightDistance);
});
return enemies;
}
private int HitEnemiesInEllipse(
ActiveArtifactDefinition definition,
Vector2 center,
Vector2 radii,
float rawDamage,
float knockback,
int maxTargets,
bool batchesHitFeedback = false,
bool charged = false,
int castIdentity = 0,
bool useBodyColliderOverlap = false)
{
List<EnemyController> enemies = FindNearestTargets(
center,
Mathf.Max(radii.x, radii.y),
null,
!useBodyColliderOverlap);
int hitCount = 0;
bool hasShownHitFeedback = false;
foreach (EnemyController enemy in enemies)
{
if (hitCount >= maxTargets)
{
break;
}
Vector2 offset = enemy.GroundAnchorPosition - center;
if (useBodyColliderOverlap)
{
if (!DoesBodyColliderOverlapEllipse(
enemy,
center,
radii))
{
continue;
}
}
else if (GetEllipseDistance(offset, radii) > 1.0001f)
{
continue;
}
Vector2 direction =
(enemy.GroundAnchorPosition - center).normalized;
if (direction.sqrMagnitude < MinimumDirectionSqrMagnitude)
{
direction = GetUseDirection();
}
if (ApplyArtifactHit(
enemy,
definition,
rawDamage,
direction,
knockback,
!batchesHitFeedback || !hasShownHitFeedback,
charged,
0f,
castIdentity))
{
hitCount++;
hasShownHitFeedback = true;
}
}
return hitCount;
}
private static bool DoesBodyColliderOverlapEllipse(
EnemyController enemy,
Vector2 center,
Vector2 radii)
{
Collider2D bodyCollider = enemy != null
? enemy.GetComponent<Collider2D>()
: null;
if (bodyCollider == null || !bodyCollider.enabled)
{
return false;
}
Vector2 closestToCenter = bodyCollider.ClosestPoint(center);
if (GetEllipseDistance(closestToCenter - center, radii)
<= 1.0001f)
{
return true;
}
const int ellipseBoundarySampleCount = 64;
for (int i = 0; i < ellipseBoundarySampleCount; i++)
{
float angle = Mathf.PI * 2f * i
/ ellipseBoundarySampleCount;
Vector2 ellipseBoundaryPoint = center + new Vector2(
Mathf.Cos(angle) * radii.x,
Mathf.Sin(angle) * radii.y);
Vector2 closestToBoundary = bodyCollider.ClosestPoint(
ellipseBoundaryPoint);
if ((closestToBoundary - ellipseBoundaryPoint).sqrMagnitude
<= 0.000001f)
{
return true;
}
}
return false;
}
private bool ApplyArtifactHit(
EnemyController enemy,
ActiveArtifactDefinition definition,
float rawDamage,
Vector2 direction,
float knockback,
bool showsHitFeedback = true,
bool charged = false,
float igniteTickDamage = 0f,
int castIdentity = 0)
{
if (enemy == null || enemy.IsDead)
{
return false;
}
if (direction.sqrMagnitude < MinimumDirectionSqrMagnitude)
{
direction = GetUseDirection();
}
direction.Normalize();
float damage = playerStats.CalculateDamage(
rawDamage,
definition.DamageTag);
EnemyArtifactHitResult result = enemy.TryTakeArtifactHit(
damage,
direction,
knockback,
definition.ArtifactColor,
castIdentity);
if (result == EnemyArtifactHitResult.Rejected)
{
return false;
}
float damageTaken = enemy.LastAppliedDamage;
bool damageWasApplied = result == EnemyArtifactHitResult.DamageApplied
&& damageTaken > 0f;
float reportedDamage = enemy.LastReportedDamage;
bool protectionBegan = enemy.IsDamageInvulnerable;
if (result == EnemyArtifactHitResult.Shielded)
{
if (enemy.LastArtifactContactCounted)
{
CombatEvents.RaiseArtifactEffectiveHit(
enemy.gameObject,
castIdentity,
definition.Effect,
charged,
true);
ShowArtifactImpactForContact(
enemy,
definition,
direction,
charged);
}
// RegisterArtifactContact already published the stagger pip;
// a shielded artifact contact is not a damage hit.
return false;
}
if (damageWasApplied && !protectionBegan)
{
ApplyArtifactSecondaryEffect(
enemy,
definition,
charged,
igniteTickDamage);
}
if (damageWasApplied)
{
CombatEvents.RaiseArtifactEffectiveHit(
enemy.gameObject,
castIdentity,
definition.Effect,
charged);
}
CombatEvents.RaiseValidHit(new CombatHitResult(
gameObject,
enemy.gameObject,
false,
HitSide.Front,
reportedDamage,
direction,
knockback,
enemy.GroundAnchorPosition,
false,
false,
definition.DamageTag,
definition.ArtifactId,
false,
result == EnemyArtifactHitResult.DamageApplied
&& showsHitFeedback,
false,
true,
definition.Effect,
charged));
return damageWasApplied;
}
private void ShowArtifactImpactForContact(
EnemyController enemy,
ActiveArtifactDefinition definition,
Vector2 direction,
bool charged)
{
CombatFeedback.ShowArtifactImpact(
CombatHitResult.ForArtifactContact(
gameObject,
enemy.gameObject,
definition.Effect,
charged,
HitSide.Front,
direction,
enemy.GroundAnchorPosition,
definition.DamageTag,
definition.ArtifactId));
}
private static void ApplyArtifactSecondaryEffect(
EnemyController enemy,
ActiveArtifactDefinition definition,
bool charged,
float igniteTickDamage)
{
switch (definition.Effect)
{
case ActiveArtifactEffect.Phoenix:
enemy.ApplyIgnite(
charged
? definition.ChargedIgniteDuration
: definition.NormalIgniteDuration,
charged
? definition.ChargedIgniteInterval
: definition.NormalIgniteInterval,
igniteTickDamage);
break;
case ActiveArtifactEffect.ThunderCrash:
enemy.ApplyBumpVulnerability(
charged
? definition.ChargedVulnerabilityDuration
: definition.NormalVulnerabilityDuration,
charged
? definition.ChargedVulnerabilityIncrease
: definition.NormalVulnerabilityIncrease);
break;
case ActiveArtifactEffect.ChainLightning:
float chance = charged
? definition.ChargedShockChance
: definition.NormalShockChance;
if (chance > 0f && UnityEngine.Random.value < chance)
{
enemy.ApplyShock(
charged
? definition.ChargedShockDuration
: definition.NormalShockDuration,
charged
? definition.ChargedShockIncrease
: definition.NormalShockIncrease);
}
break;
}
}
private List<EnemyController> CollectUniqueEnemies(
IReadOnlyList<Collider2D> hits,
HashSet<int> excludedIds = null)
{
queryTargetIds.Clear();
List<EnemyController> enemies = new();
for (int i = 0; i < hits.Count; i++)
{
Collider2D hit = hits[i];
EnemyController enemy =
hit.GetComponentInParent<EnemyController>();
if (enemy == null || enemy.IsDead)
{
continue;
}
int id = enemy.GetInstanceID();
if ((excludedIds != null && excludedIds.Contains(id))
|| !queryTargetIds.Add(id))
{
continue;
}
enemies.Add(enemy);
}
return enemies;
}
private void BeginExecution(bool locksMovement)
{
IsExecutingArtifact = true;
LocksMovement = locksMovement;
OnStateChanged?.Invoke();
}
private void EndExecution()
{
executionRoutine = null;
IsExecutingArtifact = false;
LocksMovement = false;
OnStateChanged?.Invoke();
}
private void CancelExecution()
{
if (!IsExecutingArtifact && !LocksMovement)
{
return;
}
if (executionRoutine != null)
{
StopCoroutine(executionRoutine);
executionRoutine = null;
}
IsExecutingArtifact = false;
LocksMovement = false;
DestroyTransientVisuals();
OnStateChanged?.Invoke();
}
private Vector2 GetUseDirection()
{
if (playerController == null)
{
return Vector2.right;
}
Vector2 direction =
playerController.MoveDirection.sqrMagnitude
> MinimumDirectionSqrMagnitude
? playerController.MoveDirection
: playerController.FacingDirection;
return direction.sqrMagnitude > MinimumDirectionSqrMagnitude
? direction.normalized
: Vector2.right;
}
private Vector2 GetThunderCrashDirection()
{
if (playerController == null)
{
return Vector2.right;
}
Vector2 direction = playerController.FacingDirection;
if (direction.sqrMagnitude <= MinimumDirectionSqrMagnitude)
{
return Vector2.right;
}
// Player input is currently keyboard based, but keep the artifact
// contract explicit: a facing value always resolves to one of the
// eight authored directions before placing the effect.
float angle = Mathf.Atan2(direction.y, direction.x);
int sector = Mathf.FloorToInt(
(angle + Mathf.PI * 0.125f - 0.0001f)
/ (Mathf.PI * 0.25f));
int index = (sector % ThunderCrashDirections.Length
+ ThunderCrashDirections.Length)
% ThunderCrashDirections.Length;
return ThunderCrashDirections[index];
}
private Vector2 GetThunderCrashOrigin()
{
Vector2 physicalOrigin = body != null
? body.position
: (Vector2)transform.position;
return physicalOrigin
+ (Vector2)transform.TransformVector(
new Vector2(0f, ThunderCrashOriginLocalY));
}
private Vector2 GetCurrentArtifactOrigin()
{
return body != null
? body.position
: (Vector2)transform.position;
}
private int NextArtifactCastIdentity()
{
nextArtifactCastIdentity++;
if (nextArtifactCastIdentity <= 0)
{
nextArtifactCastIdentity = 1;
}
return nextArtifactCastIdentity;
}
public static float EvaluateProjectileProgress(float linearProgress)
{
float progress = Mathf.Clamp01(linearProgress);
float denominator =
Mathf.Exp(ProjectileAccelerationStrength) - 1f;
return (Mathf.Exp(
ProjectileAccelerationStrength * progress) - 1f)
/ denominator;
}
private static float GetRange(
ActiveArtifactDefinition definition,
bool charged)
{
return charged
? definition.ChargedRange
: definition.NormalRange;
}
private static int GetScheduledHitCount(
float duration,
float interval)
{
if (duration <= 0f || interval <= 0f)
{
return 1;
}
return Mathf.Max(
1,
Mathf.FloorToInt((duration + 0.0001f) / interval) + 1);
}
private static float GetDamage(
ActiveArtifactDefinition definition,
bool charged)
{
return charged
? definition.ChargedDamage
: definition.NormalDamage;
}
private static float GetKnockback(
ActiveArtifactDefinition definition,
bool charged)
{
return charged
? definition.ChargedKnockback
: definition.NormalKnockback;
}
private void AddGauge(float amount)
{
float previous = CurrentGauge;
CurrentGauge = Mathf.Min(ResolveMaxGauge(), CurrentGauge + amount);
if (!Mathf.Approximately(previous, CurrentGauge))
{
OnStateChanged?.Invoke();
}
}
private static float GetCycloneVisualScale(
float definitionRange,
bool charged)
{
return definitionRange / (charged
? CycloneChargedBaseRange
: CycloneNormalBaseRange);
}
private Vector2 GetCycloneShadowOffsetWorld()
{
Vector3 localOffset = new(
0f,
CycloneShadowCenterOffsetLocalYPixels
/ CycloneOrbitPixelsPerUnit,
0f);
return transform.TransformVector(localOffset);
}
private Vector2 GetCycloneHitCenterOffset(
float definitionRange,
bool charged)
{
float visualScale = GetCycloneVisualScale(
definitionRange,
charged);
return GetCycloneShadowOffsetWorld()
+ Vector2.up
* CycloneOrbitCenterOffsetYPixels
/ CycloneOrbitPixelsPerUnit
* visualScale;
}
private static Vector2 GetCycloneHitRadii(
float definitionRange,
bool charged)
{
float visualScale = GetCycloneVisualScale(
definitionRange,
charged);
return new Vector2(
charged
? CycloneChargedOrbitRadiusXPixels
: CycloneNormalOrbitRadiusXPixels,
charged
? CycloneChargedOrbitRadiusYPixels
: CycloneNormalOrbitRadiusYPixels)
/ CycloneOrbitPixelsPerUnit
* visualScale;
}
private static float GetThunderCrashVisualScale(
float legacyDefinitionRange)
{
return 1.05f * legacyDefinitionRange;
}
private static Vector2 GetThunderCrashHitCenterOffset(
float legacyDefinitionRange,
Vector2 direction)
{
Vector2 authoredOffset = new Vector2(
ThunderCrashGroundCenterOffsetXPixels,
ThunderCrashGroundCenterOffsetYPixels)
/ ThunderCrashPixelsPerUnit
* GetThunderCrashVisualScale(legacyDefinitionRange);
return authoredOffset;
}
private static Vector2 GetThunderCrashHitRadii(
float legacyDefinitionRange,
Vector2 direction)
{
return new Vector2(
ThunderCrashGroundRadiusXPixels,
ThunderCrashGroundRadiusYPixels)
/ ThunderCrashPixelsPerUnit
* GetThunderCrashVisualScale(legacyDefinitionRange);
}
private void CreateCycloneSpriteLayers(
GameObject owner,
bool charged,
float visualScale,
out SpriteRenderer backRenderer,
out SpriteRenderer frontRenderer)
{
backRenderer = null;
frontRenderer = null;
if (!TryLoadCycloneOrbitFrames(charged))
{
return;
}
Sprite[] backFrames = charged
? cycloneChargedBackFrames
: cycloneNormalBackFrames;
Sprite[] frontFrames = charged
? cycloneChargedFrontFrames
: cycloneNormalFrontFrames;
backRenderer = CreateCycloneOrbitRenderer(
owner,
"Cyclone Orbit Back",
backFrames[0],
visualScale);
frontRenderer = CreateCycloneOrbitRenderer(
owner,
"Cyclone Orbit Front",
frontFrames[0],
visualScale);
UpdateCycloneSorting(backRenderer, frontRenderer);
}
private static SpriteRenderer CreateCycloneOrbitRenderer(
GameObject owner,
string layerName,
Sprite firstFrame,
float visualScale)
{
GameObject layer = new(layerName);
layer.transform.SetParent(owner.transform, false);
layer.transform.localScale = Vector3.one * visualScale;
SpriteRenderer renderer = layer.AddComponent<SpriteRenderer>();
renderer.color = Color.white;
renderer.sprite = firstFrame;
renderer.enabled = true;
return renderer;
}
private void SetCycloneSpriteFrame(
SpriteRenderer backRenderer,
SpriteRenderer frontRenderer,
bool charged,
float elapsed)
{
Sprite[] backFrames = charged
? cycloneChargedBackFrames
: cycloneNormalBackFrames;
Sprite[] frontFrames = charged
? cycloneChargedFrontFrames
: cycloneNormalFrontFrames;
if (backFrames == null || frontFrames == null)
{
return;
}
int frameIndex = EvaluateCycloneFrameIndex(elapsed);
if (backRenderer != null)
{
backRenderer.sprite = backFrames[frameIndex];
}
if (frontRenderer != null)
{
frontRenderer.sprite = frontFrames[frameIndex];
}
}
private void UpdateCycloneSorting(
SpriteRenderer backRenderer,
SpriteRenderer frontRenderer)
{
SpriteRenderer playerRenderer = GetComponent<SpriteRenderer>();
if (playerRenderer == null)
{
return;
}
BumpCombat.Core.YSortRenderer ySort =
GetComponent<BumpCombat.Core.YSortRenderer>();
int playerSortingOrder = ySort != null
? ySort.CalculateSortingOrder()
: playerRenderer.sortingOrder;
if (backRenderer != null)
{
backRenderer.sortingLayerID = playerRenderer.sortingLayerID;
backRenderer.sortingOrder = playerSortingOrder - 1;
}
if (frontRenderer != null)
{
frontRenderer.sortingLayerID = playerRenderer.sortingLayerID;
frontRenderer.sortingOrder = playerSortingOrder + 1;
}
}
private static int EvaluateCycloneFrameIndex(float elapsed)
{
int frame = Mathf.FloorToInt(
Mathf.Max(0f, elapsed) / CycloneOrbitFrameDuration);
return frame % CycloneOrbitFrameCount;
}
private bool TryLoadCycloneOrbitFrames(bool charged)
{
Sprite[] existingBackFrames = charged
? cycloneChargedBackFrames
: cycloneNormalBackFrames;
Sprite[] existingFrontFrames = charged
? cycloneChargedFrontFrames
: cycloneNormalFrontFrames;
if (existingBackFrames != null && existingFrontFrames != null)
{
return true;
}
string backResource = charged
? CycloneOrbitChargedBackResource
: CycloneOrbitNormalBackResource;
string frontResource = charged
? CycloneOrbitChargedFrontResource
: CycloneOrbitNormalFrontResource;
Texture2D backTexture = Resources.Load<Texture2D>(backResource);
Texture2D frontTexture = Resources.Load<Texture2D>(frontResource);
if (!IsCycloneOrbitTextureValid(backTexture)
|| !IsCycloneOrbitTextureValid(frontTexture))
{
return false;
}
backTexture.filterMode = FilterMode.Point;
backTexture.wrapMode = TextureWrapMode.Clamp;
frontTexture.filterMode = FilterMode.Point;
frontTexture.wrapMode = TextureWrapMode.Clamp;
Sprite[] backFrames = CreateCycloneOrbitFrames(
backTexture,
charged ? "Charged Back" : "Normal Back");
Sprite[] frontFrames = CreateCycloneOrbitFrames(
frontTexture,
charged ? "Charged Front" : "Normal Front");
if (charged)
{
cycloneChargedBackFrames = backFrames;
cycloneChargedFrontFrames = frontFrames;
}
else
{
cycloneNormalBackFrames = backFrames;
cycloneNormalFrontFrames = frontFrames;
}
return true;
}
private static bool IsCycloneOrbitTextureValid(Texture2D texture)
{
return texture != null
&& texture.width >= CycloneOrbitFrameSize * CycloneOrbitFrameCount
&& texture.height >= CycloneOrbitFrameSize;
}
private static Sprite[] CreateCycloneOrbitFrames(
Texture2D texture,
string layerName)
{
Sprite[] frames = new Sprite[CycloneOrbitFrameCount];
Vector2 pivot = new(0.5f, 0.3f);
for (int i = 0; i < CycloneOrbitFrameCount; i++)
{
frames[i] = Sprite.Create(
texture,
new Rect(i * CycloneOrbitFrameSize, 0f,
CycloneOrbitFrameSize,
CycloneOrbitFrameSize),
pivot,
CycloneOrbitPixelsPerUnit);
frames[i].name = $"Cyclone Orbit {layerName} {i + 1}";
}
return frames;
}
private void DestroyCycloneOrbitFrames()
{
DestroyGeneratedSpriteFrames(cycloneNormalBackFrames);
DestroyGeneratedSpriteFrames(cycloneNormalFrontFrames);
DestroyGeneratedSpriteFrames(cycloneChargedBackFrames);
DestroyGeneratedSpriteFrames(cycloneChargedFrontFrames);
cycloneNormalBackFrames = null;
cycloneNormalFrontFrames = null;
cycloneChargedBackFrames = null;
cycloneChargedFrontFrames = null;
}
private IEnumerator ShowCircleEffect(
string visualName,
float radius,
Color color,
float duration,
bool followPlayer,
bool usePulsePalette = false,
bool charged = false)
{
GameObject visual = CreateVisual(visualName, followPlayer);
SpriteRenderer paletteRenderer = usePulsePalette
? CreatePulseRingPaletteVisual(visual, radius, charged)
: null;
float elapsed = 0f;
while (elapsed < duration
&& (playerHealth == null || playerHealth.CurrentHealth > 0f)
&& (RunManager.Instance == null
|| (!RunManager.Instance.IsGameOver
&& !RunManager.Instance.IsTitleScreen)))
{
float progress = Mathf.Clamp01(elapsed / duration);
Color faded = color;
faded.a = 1f - progress;
if (!usePulsePalette)
{
visual.transform.localScale = Vector3.one * Mathf.Lerp(
0.8f,
1.08f,
progress);
}
if (paletteRenderer != null)
{
paletteRenderer.color = new Color(
1f, 1f, 1f, faded.a);
float animationProgress = progress < 0.5f
? progress * 2f
: (1f - progress) * 2f;
int frame = Mathf.Min(
PulseRingFrameCount - 1,
Mathf.FloorToInt(
animationProgress * PulseRingFrameCount));
paletteRenderer.sprite = (charged
? pulseRingChargedFrames
: pulseRingNormalFrames)[frame];
ConfigurePulseRingPaletteVisual(
paletteRenderer,
radius,
charged);
}
yield return null;
elapsed += Time.deltaTime;
}
DestroyVisual(visual);
}
private SpriteRenderer CreatePulseRingPaletteVisual(
GameObject visual,
float radius = 1f,
bool charged = false)
{
if (!TryLoadPulseRingFrames(charged))
{
return null;
}
GameObject paletteObject = new("Pulse Ring Palette");
paletteObject.transform.SetParent(visual.transform, false);
SpriteRenderer renderer = paletteObject.AddComponent<SpriteRenderer>();
renderer.sprite = (charged
? pulseRingChargedFrames
: pulseRingNormalFrames)[0];
renderer.color = Color.white;
renderer.sortingOrder = 210;
ConfigurePulseRingPaletteVisual(renderer, radius, charged);
return renderer;
}
private void ConfigurePulseRingPaletteVisual(
SpriteRenderer renderer,
float radius,
bool charged = false)
{
if (renderer == null)
{
return;
}
float visibleWidth = charged
? PulseRingChargedVisibleWidthPixels
: PulseRingNormalVisibleWidthPixels;
float visibleHeight = charged ? 52f : 33f;
renderer.transform.localScale = new Vector3(
radius * 2f * PulseRingPixelsPerUnit / visibleWidth,
radius * PulseRingSpriteAspect * 2f * PulseRingPixelsPerUnit
/ visibleHeight,
1f);
}
private bool TryLoadPulseRingFrames(bool charged = false)
{
Sprite[] existing = charged ? pulseRingChargedFrames : pulseRingNormalFrames;
if (existing != null)
{
return true;
}
string resource = charged ? PulseRingChargedResource : PulseRingNormalResource;
int frameWidth = charged ? PulseRingChargedFrameWidth : PulseRingNormalFrameWidth;
int frameHeight = charged ? PulseRingChargedFrameHeight : PulseRingNormalFrameHeight;
Vector2 pivot = charged ? new(0.5f, 0.5f) : new(0.5f, 0.505f);
Texture2D texture = Resources.Load<Texture2D>(resource);
if (texture == null
|| texture.width < frameWidth * PulseRingFrameCount
|| texture.height < frameHeight)
{
return false;
}
texture.filterMode = FilterMode.Point;
texture.wrapMode = TextureWrapMode.Clamp;
Sprite[] frames = new Sprite[PulseRingFrameCount];
for (int i = 0; i < PulseRingFrameCount; i++)
{
frames[i] = Sprite.Create(
texture,
new Rect(i * frameWidth, 0f, frameWidth, frameHeight),
pivot,
32f);
frames[i].name = charged
? $"Pulse Ring Charged {i + 1}"
: $"Pulse Ring Normal {i + 1}";
}
if (charged) pulseRingChargedFrames = frames;
else
{
pulseRingNormalFrames = frames;
}
return true;
}
private void DestroyPulseRingFrames()
{
DestroyGeneratedSpriteFrames(pulseRingNormalFrames);
DestroyGeneratedSpriteFrames(pulseRingChargedFrames);
pulseRingNormalFrames = null;
pulseRingChargedFrames = null;
}
private GameObject CreateVisual(string visualName, bool followPlayer)
{
GameObject visual = new(visualName);
if (followPlayer)
{
visual.transform.SetParent(transform, false);
}
else
{
visual.transform.position = body != null
? body.position
: transform.position;
}
transientVisuals.Add(visual);
return visual;
}
private void DestroyVisual(GameObject visual)
{
if (visual == null)
{
return;
}
transientVisuals.Remove(visual);
DestroyRuntimeObject(visual);
}
private void DestroyTransientVisuals()
{
for (int i = transientVisuals.Count - 1; i >= 0; i--)
{
if (transientVisuals[i] != null)
{
DestroyRuntimeObject(transientVisuals[i]);
}
}
transientVisuals.Clear();
}
private LineRenderer CreatePathLine(
GameObject owner,
Color color,
float width)
{
LineRenderer line = owner.AddComponent<LineRenderer>();
line.useWorldSpace = false;
line.positionCount = 2;
line.sharedMaterial = GetEffectMaterial();
line.startColor = color;
line.endColor = color;
line.widthMultiplier = width;
line.numCapVertices = 4;
line.sortingOrder = 200;
return line;
}
private Material GetEffectMaterial()
{
if (effectMaterial == null)
{
effectMaterial = new Material(Shader.Find("Sprites/Default"))
{
name = "Runtime Artifact Effect Material",
};
}
return effectMaterial;
}
private static float GetPulseEllipseDistance(
Vector2 offset,
float radiusX,
float radiusY)
{
return GetEllipseDistance(offset, new Vector2(radiusX, radiusY));
}
private static float GetEllipseDistance(
Vector2 offset,
Vector2 radii)
{
return (offset.x * offset.x) / (radii.x * radii.x)
+ (offset.y * offset.y) / (radii.y * radii.y);
}
private static Vector2 GetPulseEllipseBoundaryOffset(
Vector2 direction,
float radiusX,
float radiusY)
{
if (direction.sqrMagnitude < MinimumDirectionSqrMagnitude)
{
return new Vector2(radiusX, 0f);
}
direction.Normalize();
float denominator = Mathf.Sqrt(
(direction.x * direction.x) / (radiusX * radiusX)
+ (direction.y * direction.y) / (radiusY * radiusY));
return direction / denominator;
}
private static void DestroyRuntimeObject(UnityEngine.Object target)
{
if (target == null)
{
return;
}
if (Application.isPlaying)
{
Destroy(target);
}
else
{
DestroyImmediate(target);
}
}
#if UNITY_EDITOR
public void Configure(ActiveArtifactDefinition[] definitions)
{
artifactCatalog = definitions;
grantCatalogAtStart = false;
maxOwnedArtifacts = -1;
maxArtifactsPerColor = -1;
maxGauge = -1f;
startingGauge = -1f;
normalHitGain = -1f;
movementGainPerSecond = -1f;
}
public void DebugGrantCatalogArtifacts()
{
ActiveArtifactDefinition[] catalog = GetArtifactCatalog();
if (catalog == null)
{
return;
}
int previousMaximum = maxOwnedArtifacts;
maxOwnedArtifacts = Mathf.Max(
ResolveMaximumOwnedArtifacts(),
catalog.Length);
foreach (ActiveArtifactDefinition definition in catalog)
{
TryAddArtifact(definition, false, true);
}
maxOwnedArtifacts = previousMaximum;
CurrentGauge = ResolveMaxGauge();
OnStateChanged?.Invoke();
}
#endif
private ActiveArtifactDefinition[] GetArtifactCatalog()
{
return artifactCatalog ?? GameplayConstants.Current.Artifacts.Definitions;
}
private int ResolveMaximumOwnedArtifacts()
{
return maxOwnedArtifacts > 0
? maxOwnedArtifacts
: GameplayConstants.Current.Artifacts.MaximumOwnedArtifacts;
}
private int ResolveMaximumArtifactsPerColor()
{
return maxArtifactsPerColor > 0
? maxArtifactsPerColor
: GameplayConstants.Current.Artifacts.MaximumArtifactsPerColor;
}
private float ResolveMaxGauge()
{
return maxGauge >= 0f
? maxGauge
: GameplayConstants.Current.Artifacts.MaximumGauge;
}
private float ResolveStartingGauge()
{
return startingGauge >= 0f
? startingGauge
: GameplayConstants.Current.Artifacts.StartingGauge;
}
private float ResolveNormalHitGain()
{
return normalHitGain >= 0f
? normalHitGain
: GameplayConstants.Current.Artifacts.NormalBumpGaugeGain;
}
private float ResolveMovementGainPerSecond()
{
return movementGainPerSecond >= 0f
? movementGainPerSecond
: GameplayConstants.Current.Artifacts.MovementGaugeGainPerSecond;
}
}
}