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 ownedArtifacts = new(); private readonly HashSet queryTargetIds = new(); private readonly List circleQueryHits = new(64); private readonly List transientVisuals = new(); private readonly Dictionary 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 OnArtifactChargeStarted; public event Action OnArtifactSwitched; public event Action 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(); playerController = GetComponent(); playerHealth = GetComponent(); dashController = GetComponent(); artifactChargeVisual = GetComponent(); if (artifactChargeVisual == null) { artifactChargeVisual = gameObject.AddComponent(); } artifactChargeVisual.Connect(this); body = GetComponent(); 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 expiredSourceIds = null; foreach (KeyValuePair pair in temporaryPlayerBuffs) { if (Time.time < pair.Value.ExpiresAt) { continue; } expiredSourceIds ??= new List(); 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 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 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 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 strikes = charged ? PlanBranchingChain(definition, first, origin) : PlanArcChain(definition, first, origin); executionRoutine = StartCoroutine(ChainLightningRoutine( definition, strikes, origin, charged, castIdentity)); return true; } private List PlanArcChain( ActiveArtifactDefinition definition, EnemyController first, Vector2 origin) { List strikes = new(); HashSet 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 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 PlanBranchingChain( ActiveArtifactDefinition definition, EnemyController first, Vector2 origin) { List strikes = new(); HashSet visited = new(); Queue 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 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 strikes, Vector2 origin, bool charged, int castIdentity) { GameObject visual = CreateVisual( charged ? "Charged Chain Lightning" : "Chain Lightning", false); visual.transform.position = origin; List arcRenderers = new(strikes.Count); Dictionary 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(); 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( 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(); 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(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(); 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(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 enemies = FindNearestTargets( origin, range, null); return enemies.Count > 0 ? enemies[0] : null; } private List FindNearestTargets( Vector2 origin, float range, HashSet excludedIds, bool filterByGroundAnchor = true) { circleQueryHits.Clear(); Physics2D.OverlapCircle( origin, range, circleQueryFilter, circleQueryHits); List 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 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() : 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 CollectUniqueEnemies( IReadOnlyList hits, HashSet excludedIds = null) { queryTargetIds.Clear(); List enemies = new(); for (int i = 0; i < hits.Count; i++) { Collider2D hit = hits[i]; EnemyController enemy = hit.GetComponentInParent(); 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(); 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(); if (playerRenderer == null) { return; } BumpCombat.Core.YSortRenderer ySort = GetComponent(); 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(backResource); Texture2D frontTexture = Resources.Load(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(); 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(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(); 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; } } }