1098 lines
37 KiB
C#
1098 lines
37 KiB
C#
using System.Collections;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using BumpCombat.Combat;
|
|
using BumpCombat.Constants;
|
|
using BumpCombat.Core;
|
|
using BumpCombat.Defense;
|
|
using BumpCombat.Enemies;
|
|
using BumpCombat.Player;
|
|
using UnityEngine;
|
|
|
|
namespace BumpCombat.Progression
|
|
{
|
|
public enum PassiveRelicId
|
|
{
|
|
TwinHornTalisman,
|
|
ResonanceCore,
|
|
RuptureSeed,
|
|
WaveNail,
|
|
CapacitorRing,
|
|
EmberWick,
|
|
EmberCarrier,
|
|
RiftWedge,
|
|
ExecutionerSigil,
|
|
HunterFeather,
|
|
BloodFang,
|
|
RecoveryFunnel,
|
|
TrophyMask,
|
|
ResonantCrystal,
|
|
BackflowHeart,
|
|
CounterBell,
|
|
SprintWheel,
|
|
BattleBaton,
|
|
BondLedger,
|
|
PilgrimPouch,
|
|
}
|
|
|
|
public static class PassiveRelicRules
|
|
{
|
|
private static readonly PassiveRelicId[] Catalog =
|
|
{
|
|
PassiveRelicId.TwinHornTalisman,
|
|
PassiveRelicId.ResonanceCore,
|
|
PassiveRelicId.RuptureSeed,
|
|
PassiveRelicId.WaveNail,
|
|
PassiveRelicId.CapacitorRing,
|
|
PassiveRelicId.EmberWick,
|
|
PassiveRelicId.EmberCarrier,
|
|
PassiveRelicId.RiftWedge,
|
|
PassiveRelicId.ExecutionerSigil,
|
|
PassiveRelicId.HunterFeather,
|
|
PassiveRelicId.BloodFang,
|
|
PassiveRelicId.RecoveryFunnel,
|
|
PassiveRelicId.TrophyMask,
|
|
PassiveRelicId.ResonantCrystal,
|
|
PassiveRelicId.BackflowHeart,
|
|
PassiveRelicId.CounterBell,
|
|
PassiveRelicId.SprintWheel,
|
|
PassiveRelicId.BattleBaton,
|
|
PassiveRelicId.BondLedger,
|
|
PassiveRelicId.PilgrimPouch,
|
|
};
|
|
|
|
public static bool RollsChance(float chance, float roll) =>
|
|
chance > 0f && roll >= 0f && roll < Mathf.Clamp01(chance);
|
|
|
|
public static PassiveRelicId[] GetAvailableRelics(
|
|
IReadOnlyCollection<PassiveRelicId> owned)
|
|
{
|
|
List<PassiveRelicId> available = new(Catalog.Length);
|
|
foreach (PassiveRelicId id in Catalog)
|
|
{
|
|
if (owned == null || !owned.Contains(id))
|
|
{
|
|
available.Add(id);
|
|
}
|
|
}
|
|
|
|
return available.ToArray();
|
|
}
|
|
|
|
public static float ApplyTwinHornDamage(
|
|
float damage,
|
|
bool ownsRelic,
|
|
bool isOrdinaryBump,
|
|
bool hasArtifactColor,
|
|
float chance,
|
|
float roll)
|
|
{
|
|
return ownsRelic
|
|
&& isOrdinaryBump
|
|
&& !hasArtifactColor
|
|
&& RollsChance(chance, roll)
|
|
? damage * 2f
|
|
: damage;
|
|
}
|
|
public static float GetExplosionDamage(float maximumHealth, float fraction) =>
|
|
Mathf.Max(0f, maximumHealth) * Mathf.Clamp01(fraction);
|
|
|
|
public static float GetExplosionRadius(float bodyDiameter, float diameterMultiplier) =>
|
|
Mathf.Max(0f, bodyDiameter) * Mathf.Max(0f, diameterMultiplier);
|
|
|
|
public static bool IsPlayerCreditedKill(EnemyDamageSource source) =>
|
|
source == EnemyDamageSource.Player
|
|
|| source == EnemyDamageSource.PassiveRelicAttack;
|
|
|
|
public static bool IsPlayerRewardKill(EnemyDamageSource source) =>
|
|
source == EnemyDamageSource.Player
|
|
|| source == EnemyDamageSource.PassiveRelicExplosion
|
|
|| source == EnemyDamageSource.PassiveRelicAttack
|
|
|| source == EnemyDamageSource.PassiveRelicTransferredIgnite;
|
|
}
|
|
|
|
public sealed class PassiveRelicController : MonoBehaviour
|
|
{
|
|
public const string EchoSourceId = "passive_relic_echo";
|
|
private const string HunterBuffSourceId = "relic.hunter-feather";
|
|
private const string TrophyBuffSourceId = "relic.trophy-mask";
|
|
|
|
private readonly HashSet<PassiveRelicId> owned = new();
|
|
private readonly HashSet<int> processedDeaths = new();
|
|
private ActiveArtifactController artifacts;
|
|
private PlayerController player;
|
|
private Collider2D playerCollider;
|
|
private PlayerHealth playerHealth;
|
|
private PlayerStats playerStats;
|
|
private float nextBloodAllowedAt;
|
|
private float nextRecoveryAllowedAt;
|
|
private float hunterExpiresAt;
|
|
private float trophyExpiresAt;
|
|
private float heartAccumulatedDamage;
|
|
private float nextHeartAllowedAt;
|
|
private float nextBellAllowedAt;
|
|
private float nextRepeatAllowedAt;
|
|
private bool repeatPending;
|
|
private float voluntaryMovementDistance;
|
|
private bool wheelCharged;
|
|
private float nextBatonAllowedAt;
|
|
private float nextLedgerAllowedAt;
|
|
private float nextPouchAllowedAt;
|
|
private readonly Dictionary<int, float> recentBumps = new();
|
|
private PassiveRelicHealingOrb healingOrb;
|
|
private bool active;
|
|
private float nextEchoAllowedAt;
|
|
private float nextWaveAllowedAt;
|
|
private float nextChainAllowedAt;
|
|
private float nextCarrierAllowedAt;
|
|
private readonly Dictionary<int, (int hits, float lastHitAt)> riftHits = new();
|
|
|
|
public static PassiveRelicController Instance { get; private set; }
|
|
public int OwnedCount => owned.Count;
|
|
public bool IsRunActive => active;
|
|
public bool Owns(PassiveRelicId id) => owned.Contains(id);
|
|
public int AvailableCount => GetAvailableRelics().Length;
|
|
|
|
#if UNITY_EDITOR
|
|
public static System.Func<float> RandomRollForTests { get; set; }
|
|
#endif
|
|
|
|
private void Awake()
|
|
{
|
|
if (Instance != null && Instance != this)
|
|
{
|
|
Destroy(this);
|
|
return;
|
|
}
|
|
|
|
Instance = this;
|
|
}
|
|
|
|
private void OnEnable()
|
|
{
|
|
CombatEvents.OnValidHit += HandleValidHit;
|
|
CombatEvents.OnEnemyDied += HandleEnemyDied;
|
|
}
|
|
|
|
private void OnDisable()
|
|
{
|
|
active = false;
|
|
if (playerHealth != null)
|
|
{
|
|
playerHealth.OnEnemyDamageTaken -= HandleDamageTaken;
|
|
}
|
|
if (artifacts != null)
|
|
{
|
|
artifacts.OnArtifactUseSucceeded -= HandleArtifactUseSucceeded;
|
|
}
|
|
repeatPending = false;
|
|
CombatEvents.OnValidHit -= HandleValidHit;
|
|
CombatEvents.OnEnemyDied -= HandleEnemyDied;
|
|
}
|
|
|
|
private void OnDestroy()
|
|
{
|
|
if (Instance == this)
|
|
{
|
|
Instance = null;
|
|
}
|
|
}
|
|
|
|
public void BeginDefenseRun()
|
|
{
|
|
owned.Clear();
|
|
processedDeaths.Clear();
|
|
nextEchoAllowedAt = 0f;
|
|
nextWaveAllowedAt = 0f;
|
|
nextChainAllowedAt = 0f;
|
|
nextCarrierAllowedAt = 0f;
|
|
riftHits.Clear();
|
|
recentBumps.Clear();
|
|
voluntaryMovementDistance = 0f;
|
|
wheelCharged = false;
|
|
nextBatonAllowedAt = 0f;
|
|
nextLedgerAllowedAt = 0f;
|
|
nextPouchAllowedAt = 0f;
|
|
if (healingOrb != null)
|
|
{
|
|
Destroy(healingOrb.gameObject);
|
|
healingOrb = null;
|
|
}
|
|
player = GetComponent<PlayerController>();
|
|
player?.SetPassiveRelicController(this);
|
|
playerCollider = GetComponent<Collider2D>();
|
|
playerHealth = GetComponent<PlayerHealth>();
|
|
playerStats = GetComponent<PlayerStats>();
|
|
nextBloodAllowedAt = 0f;
|
|
nextRecoveryAllowedAt = 0f;
|
|
ClearTemporaryBuffs();
|
|
heartAccumulatedDamage = 0f;
|
|
nextHeartAllowedAt = 0f;
|
|
nextBellAllowedAt = 0f;
|
|
playerHealth.OnEnemyDamageTaken -= HandleDamageTaken;
|
|
playerHealth.OnEnemyDamageTaken += HandleDamageTaken;
|
|
artifacts = GetComponent<ActiveArtifactController>();
|
|
if (artifacts != null)
|
|
{
|
|
artifacts.OnArtifactUseSucceeded -= HandleArtifactUseSucceeded;
|
|
artifacts.OnArtifactUseSucceeded += HandleArtifactUseSucceeded;
|
|
}
|
|
nextRepeatAllowedAt = 0f;
|
|
repeatPending = false;
|
|
active = true;
|
|
}
|
|
|
|
public void EndDefenseRun()
|
|
{
|
|
active = false;
|
|
StopAllCoroutines();
|
|
repeatPending = false;
|
|
if (healingOrb != null)
|
|
{
|
|
Destroy(healingOrb.gameObject);
|
|
healingOrb = null;
|
|
}
|
|
if (artifacts != null)
|
|
{
|
|
artifacts.OnArtifactUseSucceeded -= HandleArtifactUseSucceeded;
|
|
}
|
|
if (playerHealth != null)
|
|
{
|
|
playerHealth.OnEnemyDamageTaken -= HandleDamageTaken;
|
|
}
|
|
ClearTemporaryBuffs();
|
|
}
|
|
|
|
public PassiveRelicId[] GetAvailableRelics() =>
|
|
PassiveRelicRules.GetAvailableRelics(owned)
|
|
.Where(CanOfferRelic)
|
|
.ToArray();
|
|
|
|
private bool CanOfferRelic(PassiveRelicId id)
|
|
{
|
|
if (id == PassiveRelicId.EmberCarrier)
|
|
{
|
|
ActiveArtifactDefinition phoenix = FindArtifactDefinition(
|
|
ActiveArtifactEffect.Phoenix);
|
|
return owned.Contains(PassiveRelicId.EmberWick)
|
|
|| (phoenix != null
|
|
&& artifacts != null
|
|
&& artifacts.OwnsArtifact(phoenix));
|
|
}
|
|
|
|
if (id == PassiveRelicId.ResonantCrystal)
|
|
{
|
|
if (artifacts == null)
|
|
{
|
|
return false;
|
|
}
|
|
for (int i = 0; i < artifacts.OwnedArtifactCount; i++)
|
|
{
|
|
ActiveArtifactDefinition definition = artifacts.GetArtifactAt(i);
|
|
if (definition != null
|
|
&& (definition.NormalDamage > 0f
|
|
|| definition.ChargedDamage > 0f))
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
public bool TryAcquire(PassiveRelicId id)
|
|
{
|
|
if (!active || !System.Array.Exists(GetAvailableRelics(), candidate => candidate == id))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
return owned.Add(id);
|
|
}
|
|
|
|
public void AddVoluntaryMovement(float actualDistance)
|
|
{
|
|
if (!active
|
|
|| !owned.Contains(PassiveRelicId.SprintWheel)
|
|
|| wheelCharged
|
|
|| actualDistance <= 0f
|
|
|| playerHealth == null
|
|
|| playerHealth.CurrentHealth <= 0f)
|
|
{
|
|
return;
|
|
}
|
|
|
|
float diameter = playerCollider != null
|
|
? Mathf.Max(playerCollider.bounds.size.x, playerCollider.bounds.size.y)
|
|
: 0f;
|
|
if (diameter <= 0f)
|
|
{
|
|
return;
|
|
}
|
|
|
|
voluntaryMovementDistance += actualDistance;
|
|
wheelCharged = voluntaryMovementDistance >= diameter
|
|
* GameplayConstants.Current.PassiveRelics.SprintWheelRequiredBodyDiameters;
|
|
}
|
|
|
|
public float ApplyTrophyBumpDamage(float damage, bool ordinaryBump)
|
|
{
|
|
return active
|
|
&& ordinaryBump
|
|
&& Time.time < trophyExpiresAt
|
|
? damage * (1f + GameplayConstants.Current.PassiveRelics.TrophyMaskBumpDamageIncrease)
|
|
: damage;
|
|
}
|
|
|
|
public float ApplyOrdinaryBumpDamageMultiplier(
|
|
float damage,
|
|
bool isOrdinaryBump,
|
|
bool hasArtifactColor)
|
|
{
|
|
if (!active || !owned.Contains(PassiveRelicId.TwinHornTalisman))
|
|
{
|
|
return damage;
|
|
}
|
|
|
|
return PassiveRelicRules.ApplyTwinHornDamage(
|
|
damage,
|
|
true,
|
|
isOrdinaryBump,
|
|
hasArtifactColor,
|
|
GameplayConstants.Current.PassiveRelics.TwinHornDoubleDamageChance,
|
|
GetRandomRoll());
|
|
}
|
|
private void HandleValidHit(CombatHitResult hit)
|
|
{
|
|
if (!active
|
|
|| hit.Attacker != gameObject
|
|
|| hit.Target == null
|
|
|| hit.Damage <= 0f
|
|
|| playerHealth == null
|
|
|| playerHealth.CurrentHealth <= 0f
|
|
|| hit.SourceId == EchoSourceId)
|
|
{
|
|
return;
|
|
}
|
|
|
|
PassiveRelicConstants tuning = GameplayConstants.Current.PassiveRelics;
|
|
if (hit.IsOrdinaryBump && !hit.IsArtifactHit)
|
|
{
|
|
EnemyController bumped = hit.Target.GetComponentInParent<EnemyController>();
|
|
if (bumped != null && owned.Contains(PassiveRelicId.BondLedger))
|
|
{
|
|
recentBumps[bumped.SpawnLifetimeIdentity] = Time.time;
|
|
}
|
|
TrySprintWheel(hit, tuning);
|
|
}
|
|
if (hit.BaseBumpDamage > 0f)
|
|
{
|
|
TryWaveNail(hit, tuning);
|
|
TryCapacitorRing(hit, tuning);
|
|
}
|
|
TryEmberWick(hit, tuning);
|
|
TryRiftWedge(hit, tuning);
|
|
TryExecutionerSigil(hit, tuning);
|
|
TryBloodFang(tuning);
|
|
|
|
if (!owned.Contains(PassiveRelicId.ResonanceCore)
|
|
|| Time.time < nextEchoAllowedAt
|
|
|| !RollsChance(tuning.ResonanceEchoChance)
|
|
|| artifacts == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (artifacts.TryEmitPassiveRelicEcho(
|
|
hit.HitPosition,
|
|
hit.KnockbackDirection,
|
|
tuning.ResonanceEchoDamageMultiplier))
|
|
{
|
|
nextEchoAllowedAt = Time.time
|
|
+ tuning.ResonanceEchoCooldownSeconds;
|
|
}
|
|
}
|
|
|
|
private void TryWaveNail(CombatHitResult hit, PassiveRelicConstants tuning)
|
|
{
|
|
if (!owned.Contains(PassiveRelicId.WaveNail)
|
|
|| Time.time < nextWaveAllowedAt
|
|
|| !RollsChance(tuning.WaveNailChance))
|
|
{
|
|
return;
|
|
}
|
|
|
|
float damage = hit.BaseBumpDamage * tuning.WaveNailDamageFraction;
|
|
if (damage <= 0f)
|
|
{
|
|
return;
|
|
}
|
|
|
|
bool applied = false;
|
|
foreach (EnemyController enemy in FindNearbyEnemies(hit.HitPosition))
|
|
{
|
|
applied |= TryApplyRelicDamage(enemy, hit.HitPosition, damage);
|
|
}
|
|
if (applied)
|
|
{
|
|
nextWaveAllowedAt = Time.time + tuning.WaveNailCooldownSeconds;
|
|
}
|
|
}
|
|
|
|
private void TryCapacitorRing(CombatHitResult hit, PassiveRelicConstants tuning)
|
|
{
|
|
if (!owned.Contains(PassiveRelicId.CapacitorRing)
|
|
|| Time.time < nextChainAllowedAt
|
|
|| !RollsChance(tuning.CapacitorRingChance))
|
|
{
|
|
return;
|
|
}
|
|
|
|
float damage = hit.BaseBumpDamage * tuning.CapacitorRingDamageFraction;
|
|
if (damage <= 0f)
|
|
{
|
|
return;
|
|
}
|
|
|
|
bool applied = false;
|
|
int hitCount = 0;
|
|
foreach (EnemyController enemy in FindNearbyEnemies(hit.HitPosition)
|
|
.OrderBy(enemy => ((Vector2)enemy.transform.position - hit.HitPosition).sqrMagnitude))
|
|
{
|
|
if (enemy.gameObject == hit.Target || hitCount >= tuning.CapacitorRingMaxTargets)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (TryApplyRelicDamage(enemy, hit.HitPosition, damage))
|
|
{
|
|
applied = true;
|
|
hitCount++;
|
|
}
|
|
}
|
|
if (applied)
|
|
{
|
|
nextChainAllowedAt = Time.time + tuning.CapacitorRingCooldownSeconds;
|
|
}
|
|
}
|
|
|
|
private void TryEmberWick(CombatHitResult hit, PassiveRelicConstants tuning)
|
|
{
|
|
if (!owned.Contains(PassiveRelicId.EmberWick)
|
|
|| !RollsChance(tuning.EmberWickChance)
|
|
|| artifacts == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
EnemyController enemy = hit.Target.GetComponentInParent<EnemyController>();
|
|
if (enemy == null || enemy.IsDead)
|
|
{
|
|
return;
|
|
}
|
|
|
|
ActiveArtifactDefinition phoenix = FindArtifactDefinition(
|
|
ActiveArtifactEffect.Phoenix);
|
|
if (phoenix != null)
|
|
{
|
|
enemy.ApplyIgnite(
|
|
phoenix.NormalIgniteDuration,
|
|
phoenix.NormalIgniteInterval,
|
|
playerStats.CalculateDamage(
|
|
phoenix.NormalIgniteTickDamage,
|
|
phoenix.DamageTag));
|
|
}
|
|
}
|
|
private List<EnemyController> FindNearbyEnemies(Vector2 position)
|
|
{
|
|
float diameter = playerCollider != null
|
|
? Mathf.Max(playerCollider.bounds.size.x, playerCollider.bounds.size.y)
|
|
: 0f;
|
|
float radius = PassiveRelicRules.GetExplosionRadius(diameter, 3f);
|
|
List<EnemyController> nearby = new();
|
|
HashSet<EnemyController> seen = new();
|
|
foreach (Collider2D collider in Physics2D.OverlapCircleAll(position, radius))
|
|
{
|
|
EnemyController enemy = collider != null
|
|
? collider.GetComponentInParent<EnemyController>()
|
|
: null;
|
|
if (enemy != null && !enemy.IsDead && seen.Add(enemy))
|
|
{
|
|
nearby.Add(enemy);
|
|
}
|
|
}
|
|
return nearby;
|
|
}
|
|
|
|
private static bool TryApplyRelicDamage(EnemyController enemy, Vector2 origin, float damage)
|
|
{
|
|
Vector2 direction = ((Vector2)enemy.transform.position - origin).normalized;
|
|
return enemy.TryTakeDamage(
|
|
damage,
|
|
direction,
|
|
0f,
|
|
true,
|
|
false,
|
|
EnemyDamageSource.PassiveRelicAttack);
|
|
}
|
|
private void TrySprintWheel(CombatHitResult hit, PassiveRelicConstants tuning)
|
|
{
|
|
if (!wheelCharged || hit.BaseBumpDamage <= 0f)
|
|
{
|
|
return;
|
|
}
|
|
|
|
wheelCharged = false;
|
|
voluntaryMovementDistance = 0f;
|
|
float damage = hit.BaseBumpDamage
|
|
* tuning.SprintWheelShockwaveDamageFraction;
|
|
foreach (EnemyController enemy in FindNearbyEnemies(hit.HitPosition))
|
|
{
|
|
TryApplyRelicDamage(enemy, hit.HitPosition, damage);
|
|
}
|
|
}
|
|
|
|
private void TryRiftWedge(CombatHitResult hit, PassiveRelicConstants tuning)
|
|
{
|
|
if (!owned.Contains(PassiveRelicId.RiftWedge)
|
|
|| !hit.IsOrdinaryBump
|
|
|| hit.IsArtifactHit)
|
|
{
|
|
return;
|
|
}
|
|
|
|
EnemyController enemy = hit.Target.GetComponentInParent<EnemyController>();
|
|
if (enemy == null || enemy.IsDead)
|
|
{
|
|
return;
|
|
}
|
|
|
|
int key = enemy.SpawnLifetimeIdentity;
|
|
riftHits.TryGetValue(key, out var progress);
|
|
int count = Time.time - progress.lastHitAt > tuning.RiftWedgeMaxHitGapSeconds
|
|
? 1
|
|
: progress.hits + 1;
|
|
if (count < tuning.RiftWedgeRequiredHits)
|
|
{
|
|
riftHits[key] = (count, Time.time);
|
|
return;
|
|
}
|
|
|
|
riftHits.Remove(key);
|
|
ActiveArtifactDefinition thunder = FindArtifactDefinition(
|
|
ActiveArtifactEffect.ThunderCrash);
|
|
if (thunder != null)
|
|
{
|
|
enemy.ApplyBumpVulnerability(
|
|
thunder.NormalVulnerabilityDuration,
|
|
thunder.NormalVulnerabilityIncrease);
|
|
}
|
|
}
|
|
|
|
private void TryExecutionerSigil(CombatHitResult hit, PassiveRelicConstants tuning)
|
|
{
|
|
if (!owned.Contains(PassiveRelicId.ExecutionerSigil)
|
|
|| !hit.IsOrdinaryBump
|
|
|| hit.IsArtifactHit
|
|
|| !RollsChance(tuning.ExecutionerSigilChance))
|
|
{
|
|
return;
|
|
}
|
|
|
|
EnemyController enemy = hit.Target.GetComponentInParent<EnemyController>();
|
|
if (enemy == null
|
|
|| enemy.IsDead
|
|
|| enemy.GroggyTier == BumpCombat.Core.RunTimedEvent.MidBoss
|
|
|| enemy.GroggyTier == BumpCombat.Core.RunTimedEvent.FinalBoss
|
|
|| enemy.CurrentHealth > enemy.MaximumHealth
|
|
* tuning.ExecutionerSigilHealthThreshold)
|
|
{
|
|
return;
|
|
}
|
|
|
|
enemy.TryTakeDamage(
|
|
enemy.MaximumHealth * 100f,
|
|
hit.KnockbackDirection,
|
|
0f,
|
|
true,
|
|
false,
|
|
EnemyDamageSource.PassiveRelicAttack);
|
|
}
|
|
|
|
private ActiveArtifactDefinition FindArtifactDefinition(ActiveArtifactEffect effect)
|
|
{
|
|
if (artifacts == null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
for (int i = 0; i < artifacts.CatalogCount; i++)
|
|
{
|
|
ActiveArtifactDefinition definition = artifacts.GetCatalogArtifactAt(i);
|
|
if (definition != null && definition.Effect == effect)
|
|
{
|
|
return definition;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
private void HandleEnemyDied(GameObject target)
|
|
{
|
|
if (!active || target == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
EnemyController victim = target.GetComponent<EnemyController>();
|
|
if (victim == null
|
|
|| !processedDeaths.Add(victim.SpawnLifetimeIdentity)
|
|
|| playerHealth == null
|
|
|| playerHealth.CurrentHealth <= 0f)
|
|
{
|
|
return;
|
|
}
|
|
|
|
riftHits.Remove(victim.SpawnLifetimeIdentity);
|
|
TryBondLedger(victim);
|
|
recentBumps.Remove(victim.SpawnLifetimeIdentity);
|
|
if (!PassiveRelicRules.IsPlayerRewardKill(victim.LastDamageSource))
|
|
{
|
|
return;
|
|
}
|
|
|
|
TryHunterFeather();
|
|
TryRecoveryFunnel();
|
|
TryTrophyMask(victim);
|
|
TryPilgrimPouch(victim);
|
|
if (!PassiveRelicRules.IsPlayerCreditedKill(victim.LastDamageSource))
|
|
{
|
|
return;
|
|
}
|
|
|
|
TryEmberCarrier(victim);
|
|
if (!owned.Contains(PassiveRelicId.RuptureSeed)
|
|
|| !RollsChance(GameplayConstants.Current.PassiveRelics.RuptureSeedChance))
|
|
{
|
|
return;
|
|
}
|
|
|
|
float damage = PassiveRelicRules.GetExplosionDamage(
|
|
victim.MaximumHealthAtDeath,
|
|
GameplayConstants.Current.PassiveRelics.RuptureExplosionMaxHealthFraction);
|
|
float diameter = playerCollider != null
|
|
? Mathf.Max(playerCollider.bounds.size.x, playerCollider.bounds.size.y)
|
|
: 0f;
|
|
float radius = PassiveRelicRules.GetExplosionRadius(
|
|
diameter,
|
|
GameplayConstants.Current.PassiveRelics.RuptureExplosionPlayerBodyDiameters);
|
|
if (damage <= 0f || radius <= 0f)
|
|
{
|
|
return;
|
|
}
|
|
|
|
Collider2D[] hits = Physics2D.OverlapCircleAll(victim.transform.position, radius);
|
|
HashSet<EnemyController> damagedEnemies = new();
|
|
foreach (Collider2D hit in hits)
|
|
{
|
|
EnemyController enemy = hit != null ? hit.GetComponentInParent<EnemyController>() : null;
|
|
if (enemy == null
|
|
|| enemy == victim
|
|
|| enemy.IsDead
|
|
|| !damagedEnemies.Add(enemy))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
Vector2 direction = ((Vector2)enemy.transform.position - (Vector2)victim.transform.position).normalized;
|
|
enemy.TryTakeDamage(
|
|
damage,
|
|
direction,
|
|
0f,
|
|
true,
|
|
false,
|
|
EnemyDamageSource.PassiveRelicExplosion);
|
|
}
|
|
}
|
|
|
|
private void Update()
|
|
{
|
|
if (playerStats == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (!active || playerHealth == null || playerHealth.CurrentHealth <= 0f)
|
|
{
|
|
ClearTemporaryBuffs();
|
|
return;
|
|
}
|
|
|
|
if (hunterExpiresAt > 0f && Time.time >= hunterExpiresAt)
|
|
{
|
|
playerStats.RemoveModifiersFromSource(HunterBuffSourceId);
|
|
hunterExpiresAt = 0f;
|
|
}
|
|
if (trophyExpiresAt > 0f && Time.time >= trophyExpiresAt)
|
|
{
|
|
playerStats.RemoveModifiersFromSource(TrophyBuffSourceId);
|
|
trophyExpiresAt = 0f;
|
|
}
|
|
}
|
|
|
|
private void ClearTemporaryBuffs()
|
|
{
|
|
playerStats?.RemoveModifiersFromSource(HunterBuffSourceId);
|
|
playerStats?.RemoveModifiersFromSource(TrophyBuffSourceId);
|
|
hunterExpiresAt = 0f;
|
|
trophyExpiresAt = 0f;
|
|
}
|
|
|
|
private void TryBloodFang(PassiveRelicConstants tuning)
|
|
{
|
|
if (!owned.Contains(PassiveRelicId.BloodFang)
|
|
|| Time.time < nextBloodAllowedAt
|
|
|| playerHealth.CurrentHealth >= playerHealth.MaxHealth
|
|
|| !RollsChance(tuning.BloodFangChance))
|
|
{
|
|
return;
|
|
}
|
|
|
|
float before = playerHealth.CurrentHealth;
|
|
playerHealth.Heal(playerHealth.MaxHealth * tuning.BloodFangHealMaxHealthFraction);
|
|
if (playerHealth.CurrentHealth > before)
|
|
{
|
|
nextBloodAllowedAt = Time.time + tuning.BloodFangCooldownSeconds;
|
|
}
|
|
}
|
|
|
|
private void TryHunterFeather()
|
|
{
|
|
PassiveRelicConstants tuning = GameplayConstants.Current.PassiveRelics;
|
|
if (!owned.Contains(PassiveRelicId.HunterFeather)
|
|
|| !RollsChance(tuning.HunterFeatherChance))
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (hunterExpiresAt <= Time.time)
|
|
{
|
|
playerStats.AddModifier(new StatModifier(
|
|
HunterBuffSourceId,
|
|
CharacterStat.MoveSpeed,
|
|
ModifierOperation.Increased,
|
|
tuning.HunterFeatherMoveSpeedIncrease));
|
|
}
|
|
hunterExpiresAt = Time.time + tuning.HunterFeatherDurationSeconds;
|
|
}
|
|
|
|
private void TryRecoveryFunnel()
|
|
{
|
|
PassiveRelicConstants tuning = GameplayConstants.Current.PassiveRelics;
|
|
if (!owned.Contains(PassiveRelicId.RecoveryFunnel)
|
|
|| Time.time < nextRecoveryAllowedAt
|
|
|| artifacts == null
|
|
|| !RollsChance(tuning.RecoveryFunnelChance))
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (artifacts.TryRestorePassiveRelicGauge(
|
|
tuning.RecoveryFunnelGaugeFraction))
|
|
{
|
|
nextRecoveryAllowedAt = Time.time
|
|
+ tuning.RecoveryFunnelCooldownSeconds;
|
|
}
|
|
}
|
|
|
|
private void TryTrophyMask(EnemyController victim)
|
|
{
|
|
if (!owned.Contains(PassiveRelicId.TrophyMask)
|
|
|| victim.GroggyTier != RunTimedEvent.Elite)
|
|
{
|
|
return;
|
|
}
|
|
|
|
PassiveRelicConstants tuning = GameplayConstants.Current.PassiveRelics;
|
|
if (trophyExpiresAt <= Time.time)
|
|
{
|
|
|
|
playerStats.AddModifier(new StatModifier(
|
|
TrophyBuffSourceId,
|
|
CharacterStat.MoveSpeed,
|
|
ModifierOperation.Increased,
|
|
tuning.TrophyMaskMoveSpeedIncrease));
|
|
}
|
|
trophyExpiresAt = Time.time + tuning.TrophyMaskDurationSeconds;
|
|
}
|
|
|
|
private void TryBattleBaton(PassiveRelicConstants tuning)
|
|
{
|
|
if (!active
|
|
|| !owned.Contains(PassiveRelicId.BattleBaton)
|
|
|| Time.time < nextBatonAllowedAt)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (DefensePrototypeController.Instance?.TryBoostVisibleTowers(
|
|
tuning.BattleBatonDurationSeconds) == true)
|
|
{
|
|
nextBatonAllowedAt = Time.time
|
|
+ tuning.BattleBatonCooldownSeconds;
|
|
}
|
|
}
|
|
|
|
private void TryBondLedger(EnemyController victim)
|
|
{
|
|
PassiveRelicConstants tuning = GameplayConstants.Current.PassiveRelics;
|
|
if (!owned.Contains(PassiveRelicId.BondLedger)
|
|
|| victim.LastDamageSource != EnemyDamageSource.Tower
|
|
|| Time.time < nextLedgerAllowedAt
|
|
|| !recentBumps.TryGetValue(
|
|
victim.SpawnLifetimeIdentity,
|
|
out float bumpedAt)
|
|
|| Time.time - bumpedAt > tuning.BondLedgerBumpWindowSeconds
|
|
|| !RollsChance(tuning.BondLedgerChance))
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (DefensePrototypeController.Instance?.TryAwardBondLedgerCurrency(
|
|
tuning.BondLedgerCurrency) == true)
|
|
{
|
|
nextLedgerAllowedAt = Time.time
|
|
+ tuning.BondLedgerCooldownSeconds;
|
|
}
|
|
}
|
|
|
|
private void TryPilgrimPouch(EnemyController victim)
|
|
{
|
|
PassiveRelicConstants tuning = GameplayConstants.Current.PassiveRelics;
|
|
if (!owned.Contains(PassiveRelicId.PilgrimPouch)
|
|
|| healingOrb != null
|
|
|| Time.time < nextPouchAllowedAt
|
|
|| !RollsChance(tuning.PilgrimPouchChance))
|
|
{
|
|
return;
|
|
}
|
|
|
|
GameObject orbObject = new("Pilgrim Healing Orb");
|
|
orbObject.transform.position = victim.transform.position;
|
|
healingOrb = orbObject.AddComponent<PassiveRelicHealingOrb>();
|
|
healingOrb.Initialize(
|
|
this,
|
|
playerHealth,
|
|
tuning.PilgrimPouchHealMaxHealthFraction,
|
|
tuning.PilgrimPouchOrbLifetimeSeconds);
|
|
nextPouchAllowedAt = Time.time + tuning.PilgrimPouchCooldownSeconds;
|
|
}
|
|
|
|
private void HandleArtifactUseSucceeded(
|
|
ActiveArtifactDefinition definition,
|
|
bool charged)
|
|
{
|
|
PassiveRelicConstants tuning = GameplayConstants.Current.PassiveRelics;
|
|
TryBattleBaton(tuning);
|
|
if (!active
|
|
|| !owned.Contains(PassiveRelicId.ResonantCrystal)
|
|
|| repeatPending
|
|
|| Time.time < nextRepeatAllowedAt
|
|
|| playerHealth == null
|
|
|| playerHealth.CurrentHealth <= 0f
|
|
|| !RollsChance(tuning.ResonantCrystalChance))
|
|
{
|
|
return;
|
|
}
|
|
|
|
Vector2 origin = artifacts.GetPassiveRelicUseOrigin();
|
|
Vector2 direction = artifacts.GetPassiveRelicUseDirection();
|
|
repeatPending = true;
|
|
StartCoroutine(RepeatArtifactAfterDelay(
|
|
definition,
|
|
charged,
|
|
origin,
|
|
direction,
|
|
tuning));
|
|
}
|
|
|
|
private IEnumerator RepeatArtifactAfterDelay(
|
|
ActiveArtifactDefinition definition,
|
|
bool charged,
|
|
Vector2 origin,
|
|
Vector2 direction,
|
|
PassiveRelicConstants tuning)
|
|
{
|
|
yield return new WaitForSeconds(tuning.ResonantCrystalDelaySeconds);
|
|
repeatPending = false;
|
|
if (!active
|
|
|| playerHealth == null
|
|
|| playerHealth.CurrentHealth <= 0f
|
|
|| artifacts == null)
|
|
{
|
|
yield break;
|
|
}
|
|
|
|
if (artifacts.TryEmitPassiveRelicRepeat(
|
|
definition,
|
|
charged,
|
|
origin,
|
|
direction,
|
|
tuning.ResonantCrystalDamageFraction))
|
|
{
|
|
nextRepeatAllowedAt = Time.time
|
|
+ tuning.ResonantCrystalCooldownSeconds;
|
|
}
|
|
}
|
|
|
|
private void HandleDamageTaken(float actualLoss)
|
|
{
|
|
if (!active
|
|
|| actualLoss <= 0f
|
|
|| playerHealth == null
|
|
|| playerHealth.CurrentHealth <= 0f)
|
|
{
|
|
return;
|
|
}
|
|
|
|
PassiveRelicConstants tuning = GameplayConstants.Current.PassiveRelics;
|
|
if (owned.Contains(PassiveRelicId.BackflowHeart)
|
|
&& Time.time >= nextHeartAllowedAt)
|
|
{
|
|
heartAccumulatedDamage += actualLoss;
|
|
if (heartAccumulatedDamage >= playerHealth.MaxHealth
|
|
* tuning.BackflowHeartDamageThreshold)
|
|
{
|
|
heartAccumulatedDamage = 0f;
|
|
if (playerHealth.GrantPassiveRelicShield(
|
|
playerHealth.MaxHealth * tuning.BackflowHeartShieldFraction,
|
|
tuning.BackflowHeartShieldDurationSeconds))
|
|
{
|
|
nextHeartAllowedAt = Time.time
|
|
+ tuning.BackflowHeartCooldownSeconds;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!owned.Contains(PassiveRelicId.CounterBell)
|
|
|| Time.time < nextBellAllowedAt
|
|
|| !RollsChance(tuning.CounterBellChance))
|
|
{
|
|
return;
|
|
}
|
|
|
|
bool applied = false;
|
|
foreach (EnemyController enemy in FindNearbyEnemies(transform.position))
|
|
{
|
|
applied |= enemy.ApplySlow(
|
|
tuning.CounterBellSlowDurationSeconds,
|
|
tuning.CounterBellSlowFraction);
|
|
}
|
|
if (applied)
|
|
{
|
|
nextBellAllowedAt = Time.time + tuning.CounterBellCooldownSeconds;
|
|
}
|
|
}
|
|
|
|
private void TryEmberCarrier(EnemyController victim)
|
|
{
|
|
PassiveRelicConstants tuning = GameplayConstants.Current.PassiveRelics;
|
|
if (!owned.Contains(PassiveRelicId.EmberCarrier)
|
|
|| victim.IgniteRemainingAtDeath <= 0f
|
|
|| victim.IgniteTickIntervalAtDeath <= 0f
|
|
|| victim.IgniteTickDamageAtDeath <= 0f
|
|
|| Time.time < nextCarrierAllowedAt
|
|
|| !RollsChance(tuning.EmberCarrierChance))
|
|
{
|
|
return;
|
|
}
|
|
|
|
int count = 0;
|
|
foreach (EnemyController enemy in FindNearbyEnemies(victim.transform.position)
|
|
.OrderBy(enemy => ((Vector2)enemy.transform.position - (Vector2)victim.transform.position).sqrMagnitude))
|
|
{
|
|
if (enemy == victim || count >= tuning.EmberCarrierMaxTargets)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (enemy.ApplyIgnite(
|
|
victim.IgniteRemainingAtDeath,
|
|
victim.IgniteTickIntervalAtDeath,
|
|
victim.IgniteTickDamageAtDeath,
|
|
EnemyDamageSource.PassiveRelicTransferredIgnite))
|
|
{
|
|
count++;
|
|
}
|
|
}
|
|
if (count > 0)
|
|
{
|
|
nextCarrierAllowedAt = Time.time + tuning.EmberCarrierCooldownSeconds;
|
|
}
|
|
}
|
|
private static float GetRandomRoll()
|
|
{
|
|
#if UNITY_EDITOR
|
|
if (RandomRollForTests != null)
|
|
{
|
|
return RandomRollForTests();
|
|
}
|
|
#endif
|
|
return Random.value;
|
|
}
|
|
|
|
private static bool RollsChance(float chance)
|
|
{
|
|
return PassiveRelicRules.RollsChance(chance, GetRandomRoll());
|
|
}
|
|
}
|
|
|
|
public sealed class PassiveRelicHealingOrb : MonoBehaviour
|
|
{
|
|
private PassiveRelicController owner;
|
|
private PlayerHealth playerHealth;
|
|
private float healFraction;
|
|
private float expiresAt;
|
|
|
|
public void Initialize(
|
|
PassiveRelicController relicOwner,
|
|
PlayerHealth health,
|
|
float fraction,
|
|
float lifetime)
|
|
{
|
|
owner = relicOwner;
|
|
playerHealth = health;
|
|
healFraction = fraction;
|
|
expiresAt = Time.time + lifetime;
|
|
SpriteRenderer renderer = gameObject.AddComponent<SpriteRenderer>();
|
|
renderer.sprite = DefensePrototypeController.WhiteSprite;
|
|
renderer.color = new Color(0.35f, 1f, 0.55f);
|
|
renderer.sortingOrder = 12;
|
|
transform.localScale = Vector3.one * 0.3f;
|
|
}
|
|
|
|
private void Update()
|
|
{
|
|
if (owner == null || !owner.IsRunActive || Time.time >= expiresAt)
|
|
{
|
|
Destroy(gameObject);
|
|
return;
|
|
}
|
|
|
|
if (playerHealth == null
|
|
|| playerHealth.CurrentHealth <= 0f
|
|
|| Vector2.Distance(
|
|
transform.position,
|
|
playerHealth.transform.position)
|
|
> GameplayConstants.Current.Items.ExperienceOrbPickupRadius)
|
|
{
|
|
return;
|
|
}
|
|
|
|
playerHealth.Heal(playerHealth.MaxHealth * healFraction);
|
|
Destroy(gameObject);
|
|
}
|
|
}}
|