3187 lines
111 KiB
C#
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;
|
|
}
|
|
}
|
|
}
|