Implement passive relics and six-family defense towers

This commit is contained in:
2026-09-23 18:11:38 +09:00
parent 5d11288a6f
commit c94cbda1e6
21 changed files with 2882 additions and 152 deletions
@@ -554,11 +554,13 @@ namespace BumpCombat.Progression
return;
}
List<PassiveRelicId> candidates = new(available);
for (int i = 0; i < shownCount; i++)
{
shownRelics[i] = available[i];
int choiceIndex = Random.Range(0, candidates.Count);
shownRelics[i] = candidates[choiceIndex];
candidates.RemoveAt(choiceIndex);
}
showingPassiveRelicReward = true;
selectedIndex = 0;
titleText.text = "PASSIVE RELIC - CHOOSE ONE";
@@ -572,6 +574,23 @@ namespace BumpCombat.Progression
PassiveRelicId.TwinHornTalisman => "01 TWIN HORN TALISMAN\n15% chance: ordinary bump deals 2x damage",
PassiveRelicId.ResonanceCore => "02 RESONANCE CORE\n8% chance: echo current artifact at 50% damage",
PassiveRelicId.RuptureSeed => "03 RUPTURE SEED\n25% chance: player kill explodes for 10% max HP",
PassiveRelicId.WaveNail => "04 WAVE NAIL\n20% hit: 60% bump shockwave",
PassiveRelicId.CapacitorRing => "05 CAPACITOR RING\n20% hit: chain to 3 for 35% bump",
PassiveRelicId.EmberWick => "06 EMBER WICK\n25% hit: ignite",
PassiveRelicId.EmberCarrier => "07 EMBER CARRIER\n30% ignited kill: spread remaining ignite",
PassiveRelicId.RiftWedge => "08 RIFT WEDGE\n3 bumps on one enemy: vulnerability",
PassiveRelicId.ExecutionerSigil => "09 EXECUTIONER SIGIL\n25% bump: execute under 10% HP",
PassiveRelicId.HunterFeather => "10 HUNTER FEATHER\n20% kill: +20% speed for 4s",
PassiveRelicId.BloodFang => "11 BLOOD FANG\n20% hit: heal 2% max HP",
PassiveRelicId.RecoveryFunnel => "12 RECOVERY FUNNEL\n25% kill: restore 5% max gauge",
PassiveRelicId.TrophyMask => "13 TROPHY MASK\nElite kill: +20% bump, +10% speed for 8s",
PassiveRelicId.ResonantCrystal => "14 RESONANT CRYSTAL\n20% manual use: repeat after 0.5s",
PassiveRelicId.BackflowHeart => "15 BACKFLOW HEART\nDamage taken: temporary shield",
PassiveRelicId.CounterBell => "16 COUNTER BELL\n20% HP hit: slow nearby enemies",
PassiveRelicId.SprintWheel => "17 SPRINT WHEEL\nMove 8 body widths: next bump shockwave",
PassiveRelicId.BattleBaton => "18 BATTLE BATON\nManual use: boost visible towers",
PassiveRelicId.BondLedger => "19 BOND LEDGER\nTower kills bumped enemy: +1 build",
PassiveRelicId.PilgrimPouch => "20 PILGRIM POUCH\n5% kill: healing orb",
_ => id.ToString(),
};
}
@@ -1,7 +1,10 @@
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;
@@ -13,6 +16,23 @@ namespace BumpCombat.Progression
TwinHornTalisman,
ResonanceCore,
RuptureSeed,
WaveNail,
CapacitorRing,
EmberWick,
EmberCarrier,
RiftWedge,
ExecutionerSigil,
HunterFeather,
BloodFang,
RecoveryFunnel,
TrophyMask,
ResonantCrystal,
BackflowHeart,
CounterBell,
SprintWheel,
BattleBaton,
BondLedger,
PilgrimPouch,
}
public static class PassiveRelicRules
@@ -22,6 +42,23 @@ namespace BumpCombat.Progression
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) =>
@@ -64,12 +101,21 @@ namespace BumpCombat.Progression
Mathf.Max(0f, bodyDiameter) * Mathf.Max(0f, diameterMultiplier);
public static bool IsPlayerCreditedKill(EnemyDamageSource source) =>
source == EnemyDamageSource.Player;
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();
@@ -77,11 +123,33 @@ namespace BumpCombat.Progression
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;
@@ -109,11 +177,20 @@ namespace BumpCombat.Progression
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()
private void OnDestroy()
{
if (Instance == this)
{
@@ -126,20 +203,104 @@ private void OnDestroy()
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);
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)
{
@@ -151,6 +312,40 @@ private void OnDestroy()
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,
@@ -172,14 +367,39 @@ private void OnDestroy()
private void HandleValidHit(CombatHitResult hit)
{
if (!active
|| !owned.Contains(PassiveRelicId.ResonanceCore)
|| hit.Attacker != gameObject
|| hit.Target == null
|| hit.Damage <= 0f
|| playerHealth == null
|| playerHealth.CurrentHealth <= 0f
|| hit.SourceId == EchoSourceId
|| 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(GameplayConstants.Current.PassiveRelics.ResonanceEchoChance)
|| !RollsChance(tuning.ResonanceEchoChance)
|| artifacts == null)
{
return;
@@ -188,24 +408,272 @@ private void OnDestroy()
if (artifacts.TryEmitPassiveRelicEcho(
hit.HitPosition,
hit.KnockbackDirection,
GameplayConstants.Current.PassiveRelics.ResonanceEchoDamageMultiplier))
tuning.ResonanceEchoDamageMultiplier))
{
nextEchoAllowedAt = Time.time
+ GameplayConstants.Current.PassiveRelics.ResonanceEchoCooldownSeconds;
+ 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 || !owned.Contains(PassiveRelicId.RuptureSeed) || target == null)
if (!active || target == null)
{
return;
}
EnemyController victim = target.GetComponent<EnemyController>();
if (victim == null
|| !PassiveRelicRules.IsPlayerCreditedKill(victim.LastDamageSource)
|| !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;
@@ -220,7 +688,7 @@ private void OnDestroy()
float radius = PassiveRelicRules.GetExplosionRadius(
diameter,
GameplayConstants.Current.PassiveRelics.RuptureExplosionPlayerBodyDiameters);
if (damage <= 0f || radius <= 0f || playerHealth == null || playerHealth.CurrentHealth <= 0f)
if (damage <= 0f || radius <= 0f)
{
return;
}
@@ -249,6 +717,321 @@ private void OnDestroy()
}
}
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
@@ -262,7 +1045,53 @@ private void OnDestroy()
private static bool RollsChance(float chance)
{
return PassiveRelicRules.RollsChance(chance, GetRandomRoll());
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);
}
}}