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 owned) { List 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 owned = new(); private readonly HashSet 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 recentBumps = new(); private PassiveRelicHealingOrb healingOrb; private bool active; private float nextEchoAllowedAt; private float nextWaveAllowedAt; private float nextChainAllowedAt; private float nextCarrierAllowedAt; private readonly Dictionary 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 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(); player?.SetPassiveRelicController(this); playerCollider = GetComponent(); playerHealth = GetComponent(); playerStats = GetComponent(); nextBloodAllowedAt = 0f; nextRecoveryAllowedAt = 0f; ClearTemporaryBuffs(); heartAccumulatedDamage = 0f; nextHeartAllowedAt = 0f; nextBellAllowedAt = 0f; playerHealth.OnEnemyDamageTaken -= HandleDamageTaken; playerHealth.OnEnemyDamageTaken += HandleDamageTaken; artifacts = GetComponent(); 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(); 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(); 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 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 nearby = new(); HashSet seen = new(); foreach (Collider2D collider in Physics2D.OverlapCircleAll(position, radius)) { EnemyController enemy = collider != null ? collider.GetComponentInParent() : 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(); 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(); 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(); 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 damagedEnemies = new(); foreach (Collider2D hit in hits) { EnemyController enemy = hit != null ? hit.GetComponentInParent() : 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(); 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(); 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); } }}