diff --git a/Assets/_Project/Constants/Defense/DefenseConstants.asset b/Assets/_Project/Constants/Defense/DefenseConstants.asset index 153bd2b..7715abf 100644 --- a/Assets/_Project/Constants/Defense/DefenseConstants.asset +++ b/Assets/_Project/Constants/Defense/DefenseConstants.asset @@ -16,6 +16,14 @@ MonoBehaviour: DirectTowerCost: 30 SummonTowerCost: 40 PortalRewardCurrency: 20 + EnemyKillCurrency: 15 + LightningTowerCost: 35 + ColdTowerCost: 35 + SeismicTowerCost: 35 + EmpoweringTowerCost: 35 + Tier2UpgradeCost: 20 + Tier3UpgradeCost: 30 + Tier4UpgradeCost: 40 BaseMaximumHealth: 12 PortalEnemyCount: 8 ArrivalBaseDamage: 1 @@ -34,6 +42,10 @@ MonoBehaviour: BuildPadPositions: - {x: -3.25, y: 3.25} - {x: 3.25, y: -3.25} + - {x: -3.25, y: -3.25} + - {x: 3.25, y: 3.25} + - {x: 0, y: 3.25} + - {x: 0, y: -3.25} WaypointPositions: - {x: -3, y: 3.25} - {x: 3, y: 3.25} @@ -53,6 +65,22 @@ MonoBehaviour: SummonAreaRadius: 2.25 SummonUnitMoveSpeed: 2.5 SummonAttackRange: 1.2 + LightningDamage: 6 + LightningInterval: 1.1 + LightningRadius: 1.3 + ColdDamage: 3 + ColdInterval: 1.2 + ColdSlowFraction: 0.35 + ColdSlowSeconds: 1.5 + SeismicDamage: 2 + SeismicInterval: 2 + SeismicStunSeconds: 0.45 + SeismicRadius: 1.4 + SupportRadius: 3.5 + SupportDamageBonusPerTier: 0.1 + SmotheringDamageBonus: 0.3 + ImbuingAttackIntervalMultiplier: 0.75 + TowerDamageBonusPerTier: 0.25 PlayerReviveDelaySeconds: 5 PlayerReviveInvulnerabilitySeconds: 1 RewardPickupRadius: 1.35 diff --git a/Assets/_Project/Constants/Relics/PassiveRelicConstants.asset b/Assets/_Project/Constants/Relics/PassiveRelicConstants.asset index d478630..a1e698a 100644 --- a/Assets/_Project/Constants/Relics/PassiveRelicConstants.asset +++ b/Assets/_Project/Constants/Relics/PassiveRelicConstants.asset @@ -19,3 +19,55 @@ MonoBehaviour: RuptureSeedChance: 0.25 RuptureExplosionMaxHealthFraction: 0.1 RuptureExplosionPlayerBodyDiameters: 3 + WaveNailChance: 0.2 + WaveNailDamageFraction: 0.6 + WaveNailCooldownSeconds: 1.5 + CapacitorRingChance: 0.2 + CapacitorRingDamageFraction: 0.35 + CapacitorRingCooldownSeconds: 1.5 + CapacitorRingMaxTargets: 3 + EmberWickChance: 0.25 + EmberCarrierChance: 0.3 + EmberCarrierCooldownSeconds: 0.5 + EmberCarrierMaxTargets: 3 + RiftWedgeRequiredHits: 3 + RiftWedgeMaxHitGapSeconds: 4 + ExecutionerSigilChance: 0.25 + ExecutionerSigilHealthThreshold: 0.1 + HunterFeatherChance: 0.2 + HunterFeatherDurationSeconds: 4 + HunterFeatherMoveSpeedIncrease: 0.2 + BloodFangChance: 0.2 + BloodFangHealMaxHealthFraction: 0.02 + BloodFangCooldownSeconds: 1 + RecoveryFunnelChance: 0.25 + RecoveryFunnelGaugeFraction: 0.05 + RecoveryFunnelCooldownSeconds: 1 + TrophyMaskDurationSeconds: 8 + TrophyMaskBumpDamageIncrease: 0.2 + TrophyMaskMoveSpeedIncrease: 0.1 + ResonantCrystalChance: 0.2 + ResonantCrystalDelaySeconds: 0.5 + ResonantCrystalDamageFraction: 0.5 + ResonantCrystalCooldownSeconds: 6 + BackflowHeartDamageThreshold: 0.25 + BackflowHeartShieldFraction: 0.15 + BackflowHeartShieldDurationSeconds: 4 + BackflowHeartCooldownSeconds: 12 + CounterBellChance: 0.2 + CounterBellSlowFraction: 0.3 + CounterBellSlowDurationSeconds: 2 + CounterBellCooldownSeconds: 4 + SprintWheelRequiredBodyDiameters: 8 + SprintWheelShockwaveDamageFraction: 0.8 + BattleBatonTowerDamageIncrease: 0.15 + BattleBatonDurationSeconds: 3 + BattleBatonCooldownSeconds: 6 + BondLedgerBumpWindowSeconds: 3 + BondLedgerChance: 0.25 + BondLedgerCooldownSeconds: 1 + BondLedgerCurrency: 1 + PilgrimPouchChance: 0.05 + PilgrimPouchHealMaxHealthFraction: 0.1 + PilgrimPouchOrbLifetimeSeconds: 8 + PilgrimPouchCooldownSeconds: 15 diff --git a/Assets/_Project/Scripts/Combat/BumpCombatResolver.cs b/Assets/_Project/Scripts/Combat/BumpCombatResolver.cs index ae389b2..761bf19 100644 --- a/Assets/_Project/Scripts/Combat/BumpCombatResolver.cs +++ b/Assets/_Project/Scripts/Combat/BumpCombatResolver.cs @@ -339,6 +339,18 @@ namespace BumpCombat.Combat playerStats.CalculateDamage(rawDamage, damageTag), side, isOffsetHit); + finalDamage = PassiveRelicController.Instance?.ApplyTrophyBumpDamage( + finalDamage, + !isDash && string.IsNullOrEmpty(sourceId) && !artifactColor.HasValue) + ?? finalDamage; + float baseBumpDamage = !isDash + && string.IsNullOrEmpty(sourceId) + && !artifactColor.HasValue + ? finalDamage + : BumpCombatMath.ApplySideMultiplier( + playerStats.CalculateDamage(baseDamage, DamageTag.Collision), + side, + isOffsetHit); finalDamage = PassiveRelicController.Instance?.ApplyOrdinaryBumpDamageMultiplier( finalDamage, !isDash && string.IsNullOrEmpty(sourceId), @@ -494,7 +506,8 @@ namespace BumpCombat.Combat playerHealth?.TryTakeDamage( enemy.ContactDamage, enemyToPlayer, - enemy.PlayerKnockbackDistance); + enemy.PlayerKnockbackDistance, + fromEnemyAttack: false); } else if (side != HitSide.Front && !enemy.IsCrowd @@ -527,7 +540,8 @@ namespace BumpCombat.Combat launchSucceeded, artifactEffect.HasValue, artifactEffect.GetValueOrDefault(), - artifactEffect.HasValue && isStrongDash)); + artifactEffect.HasValue && isStrongDash, + baseBumpDamage)); return true; } diff --git a/Assets/_Project/Scripts/Combat/CombatHitResult.cs b/Assets/_Project/Scripts/Combat/CombatHitResult.cs index fb0e3f8..d73bf29 100644 --- a/Assets/_Project/Scripts/Combat/CombatHitResult.cs +++ b/Assets/_Project/Scripts/Combat/CombatHitResult.cs @@ -24,13 +24,15 @@ namespace BumpCombat.Combat bool launchSucceeded = false, bool isArtifactHit = false, ActiveArtifactEffect artifactEffect = ActiveArtifactEffect.Dash, - bool isChargedArtifact = false) + bool isChargedArtifact = false, + float baseBumpDamage = 0f) { Attacker = attacker; Target = target; IsDash = isDash; Side = side; Damage = damage; + BaseBumpDamage = baseBumpDamage; KnockbackDirection = knockbackDirection; KnockbackForce = knockbackForce; HitPosition = hitPosition; @@ -96,6 +98,7 @@ namespace BumpCombat.Combat public bool IsOrdinaryBump => !IsDash && string.IsNullOrEmpty(SourceId); public HitSide Side { get; } public float Damage { get; } + public float BaseBumpDamage { get; } public bool HasBackBonus => UsesPositionBonus && Side == HitSide.Back; public bool IsOffsetHit { get; } public bool HasPositionBonus => diff --git a/Assets/_Project/Scripts/Constants/DefenseConstants.cs b/Assets/_Project/Scripts/Constants/DefenseConstants.cs index 8ee32ef..2494abe 100644 --- a/Assets/_Project/Scripts/Constants/DefenseConstants.cs +++ b/Assets/_Project/Scripts/Constants/DefenseConstants.cs @@ -12,6 +12,14 @@ namespace BumpCombat.Constants [Min(0)] public int DirectTowerCost = 30; [Min(0)] public int SummonTowerCost = 40; [Min(0)] public int PortalRewardCurrency = 20; + [Min(0)] public int EnemyKillCurrency = 15; + [Min(0)] public int LightningTowerCost = 35; + [Min(0)] public int ColdTowerCost = 35; + [Min(0)] public int SeismicTowerCost = 35; + [Min(0)] public int EmpoweringTowerCost = 35; + [Min(0)] public int Tier2UpgradeCost = 20; + [Min(0)] public int Tier3UpgradeCost = 30; + [Min(0)] public int Tier4UpgradeCost = 40; [Min(0.1f)] public float BuildInteractionRadius = 1.5f; [Header("Prototype arena")] @@ -39,6 +47,10 @@ namespace BumpCombat.Constants { new(-3.25f, 3.25f), new(3.25f, -3.25f), + new(-3.25f, -3.25f), + new(3.25f, 3.25f), + new(0f, 3.25f), + new(0f, -3.25f), }; [Tooltip("Two ordered waypoints per lane, indexed by lane * 2 + waypoint.")] public Vector2[] WaypointPositions = @@ -65,6 +77,23 @@ namespace BumpCombat.Constants [Min(0.1f)] public float SummonAreaRadius = 2.25f; [Min(0.1f)] public float SummonUnitMoveSpeed = 2.5f; [Min(0.1f)] public float SummonAttackRange = 1.2f; + [Header("Six tower family comparison values")] + [Min(0.1f)] public float LightningDamage = 6f; + [Min(0.05f)] public float LightningInterval = 1.1f; + [Min(0.1f)] public float LightningRadius = 1.3f; + [Min(0.1f)] public float ColdDamage = 3f; + [Min(0.05f)] public float ColdInterval = 1.2f; + [Min(0f)] public float ColdSlowFraction = 0.35f; + [Min(0f)] public float ColdSlowSeconds = 1.5f; + [Min(0.1f)] public float SeismicDamage = 2f; + [Min(0.05f)] public float SeismicInterval = 2f; + [Min(0f)] public float SeismicStunSeconds = 0.45f; + [Min(0.1f)] public float SeismicRadius = 1.4f; + [Min(0.1f)] public float SupportRadius = 3.5f; + [Min(0f)] public float SupportDamageBonusPerTier = 0.1f; + [Min(0f)] public float SmotheringDamageBonus = 0.3f; + [Range(0.1f, 1f)] public float ImbuingAttackIntervalMultiplier = 0.75f; + [Min(0f)] public float TowerDamageBonusPerTier = 0.25f; [Header("Prototype player recovery")] [Min(0f)] public float PlayerReviveDelaySeconds = 5f; diff --git a/Assets/_Project/Scripts/Constants/PassiveRelicConstants.cs b/Assets/_Project/Scripts/Constants/PassiveRelicConstants.cs index 076f97a..bf9b980 100644 --- a/Assets/_Project/Scripts/Constants/PassiveRelicConstants.cs +++ b/Assets/_Project/Scripts/Constants/PassiveRelicConstants.cs @@ -17,5 +17,91 @@ namespace BumpCombat.Constants [Range(0f, 1f)] public float RuptureSeedChance = 0.25f; [Range(0f, 1f)] public float RuptureExplosionMaxHealthFraction = 0.1f; [Min(0f)] public float RuptureExplosionPlayerBodyDiameters = 3f; + + [Header("Wave Nail")] + [Range(0f, 1f)] public float WaveNailChance = 0.2f; + [Range(0f, 1f)] public float WaveNailDamageFraction = 0.6f; + [Min(0f)] public float WaveNailCooldownSeconds = 1.5f; + + [Header("Capacitor Ring")] + [Range(0f, 1f)] public float CapacitorRingChance = 0.2f; + [Range(0f, 1f)] public float CapacitorRingDamageFraction = 0.35f; + [Min(0f)] public float CapacitorRingCooldownSeconds = 1.5f; + [Min(0)] public int CapacitorRingMaxTargets = 3; + + [Header("Ember Wick")] + [Range(0f, 1f)] public float EmberWickChance = 0.25f; + + [Header("Ember Carrier")] + [Range(0f, 1f)] public float EmberCarrierChance = 0.3f; + [Min(0f)] public float EmberCarrierCooldownSeconds = 0.5f; + [Min(0)] public int EmberCarrierMaxTargets = 3; + + [Header("Rift Wedge")] + [Min(1)] public int RiftWedgeRequiredHits = 3; + [Min(0f)] public float RiftWedgeMaxHitGapSeconds = 4f; + + [Header("Executioner Sigil")] + [Range(0f, 1f)] public float ExecutionerSigilChance = 0.25f; + [Range(0f, 1f)] public float ExecutionerSigilHealthThreshold = 0.1f; + + [Header("Hunter Feather")] + [Range(0f, 1f)] public float HunterFeatherChance = 0.2f; + [Min(0f)] public float HunterFeatherDurationSeconds = 4f; + [Range(0f, 1f)] public float HunterFeatherMoveSpeedIncrease = 0.2f; + + [Header("Blood Fang")] + [Range(0f, 1f)] public float BloodFangChance = 0.2f; + [Range(0f, 1f)] public float BloodFangHealMaxHealthFraction = 0.02f; + [Min(0f)] public float BloodFangCooldownSeconds = 1f; + + [Header("Recovery Funnel")] + [Range(0f, 1f)] public float RecoveryFunnelChance = 0.25f; + [Range(0f, 1f)] public float RecoveryFunnelGaugeFraction = 0.05f; + [Min(0f)] public float RecoveryFunnelCooldownSeconds = 1f; + + [Header("Trophy Mask")] + [Min(0f)] public float TrophyMaskDurationSeconds = 8f; + [Range(0f, 1f)] public float TrophyMaskBumpDamageIncrease = 0.2f; + [Range(0f, 1f)] public float TrophyMaskMoveSpeedIncrease = 0.1f; + + [Header("Resonant Crystal")] + [Range(0f, 1f)] public float ResonantCrystalChance = 0.2f; + [Min(0f)] public float ResonantCrystalDelaySeconds = 0.5f; + [Range(0f, 1f)] public float ResonantCrystalDamageFraction = 0.5f; + [Min(0f)] public float ResonantCrystalCooldownSeconds = 6f; + + [Header("Backflow Heart")] + [Range(0f, 1f)] public float BackflowHeartDamageThreshold = 0.25f; + [Range(0f, 1f)] public float BackflowHeartShieldFraction = 0.15f; + [Min(0f)] public float BackflowHeartShieldDurationSeconds = 4f; + [Min(0f)] public float BackflowHeartCooldownSeconds = 12f; + + [Header("Counter Bell")] + [Range(0f, 1f)] public float CounterBellChance = 0.2f; + [Range(0f, 1f)] public float CounterBellSlowFraction = 0.3f; + [Min(0f)] public float CounterBellSlowDurationSeconds = 2f; + [Min(0f)] public float CounterBellCooldownSeconds = 4f; + + [Header("Sprint Wheel")] + [Min(0f)] public float SprintWheelRequiredBodyDiameters = 8f; + [Range(0f, 1f)] public float SprintWheelShockwaveDamageFraction = 0.8f; + + [Header("Battle Baton")] + [Range(0f, 1f)] public float BattleBatonTowerDamageIncrease = 0.15f; + [Min(0f)] public float BattleBatonDurationSeconds = 3f; + [Min(0f)] public float BattleBatonCooldownSeconds = 6f; + + [Header("Bond Ledger")] + [Min(0f)] public float BondLedgerBumpWindowSeconds = 3f; + [Range(0f, 1f)] public float BondLedgerChance = 0.25f; + [Min(0f)] public float BondLedgerCooldownSeconds = 1f; + [Min(0)] public int BondLedgerCurrency = 1; + + [Header("Pilgrim Pouch")] + [Range(0f, 1f)] public float PilgrimPouchChance = 0.05f; + [Range(0f, 1f)] public float PilgrimPouchHealMaxHealthFraction = 0.1f; + [Min(0f)] public float PilgrimPouchOrbLifetimeSeconds = 8f; + [Min(0f)] public float PilgrimPouchCooldownSeconds = 15f; } } diff --git a/Assets/_Project/Scripts/Defense/DefensePrototypeController.cs b/Assets/_Project/Scripts/Defense/DefensePrototypeController.cs index 896d52a..6a1ef30 100644 --- a/Assets/_Project/Scripts/Defense/DefensePrototypeController.cs +++ b/Assets/_Project/Scripts/Defense/DefensePrototypeController.cs @@ -16,12 +16,14 @@ namespace BumpCombat.Defense public sealed class DefensePrototypeController : MonoBehaviour { private const int LaneCount = 2; + private const int BuildPadCount = 6; private static Sprite whiteSprite; private readonly Dictionary activeAgents = new(); - private readonly DefenseTower[] towers = new DefenseTower[LaneCount]; - private readonly bool[] padsBuilt = new bool[LaneCount]; + private readonly DefenseTower[] towers = new DefenseTower[BuildPadCount]; + private readonly Dictionary boostedTowers = new(); + private readonly bool[] padsBuilt = new bool[BuildPadCount]; private readonly int[] suppliedPerLane = new int[LaneCount]; private readonly DefenseWaveLedger[] portalLedgers = new DefenseWaveLedger[LaneCount]; private readonly bool[] portalSupplyEnded = new bool[LaneCount]; @@ -200,16 +202,25 @@ namespace BumpCombat.Defense if (Keyboard.current != null && CanOperateTowers) { - if (Keyboard.current.digit1Key.wasPressedThisFrame - || Keyboard.current.numpad1Key.wasPressedThisFrame) - { + Keyboard keys = Keyboard.current; + if (keys.digit1Key.wasPressedThisFrame || keys.numpad1Key.wasPressedThisFrame) TryBuildNearestTower(DefenseTowerKind.Direct); - } - else if (Keyboard.current.digit2Key.wasPressedThisFrame - || Keyboard.current.numpad2Key.wasPressedThisFrame) - { + else if (keys.digit2Key.wasPressedThisFrame || keys.numpad2Key.wasPressedThisFrame) TryBuildNearestTower(DefenseTowerKind.Summon); - } + else if (keys.digit3Key.wasPressedThisFrame || keys.numpad3Key.wasPressedThisFrame) + TryBuildNearestTower(DefenseTowerKind.Lightning); + else if (keys.digit4Key.wasPressedThisFrame || keys.numpad4Key.wasPressedThisFrame) + TryBuildNearestTower(DefenseTowerKind.Cold); + else if (keys.digit5Key.wasPressedThisFrame || keys.numpad5Key.wasPressedThisFrame) + TryBuildNearestTower(DefenseTowerKind.Seismic); + else if (keys.digit6Key.wasPressedThisFrame || keys.numpad6Key.wasPressedThisFrame) + TryBuildNearestTower(DefenseTowerKind.Empowering); + else if (keys.uKey.wasPressedThisFrame) + TryUpgradeNearestTower(DefenseTowerBranch.None); + else if (keys.jKey.wasPressedThisFrame) + TryUpgradeNearestTower(DefenseTowerBranch.First); + else if (keys.kKey.wasPressedThisFrame) + TryUpgradeNearestTower(DefenseTowerBranch.Second); } } @@ -241,6 +252,7 @@ namespace BumpCombat.Defense failed = false; playerDead = false; constructionCurrency = tuning.InitialConstructionCurrency; + boostedTowers.Clear(); pendingPassiveRelicChoices = 0; baseHealth = tuning.BaseMaximumHealth; activeAgents.Clear(); @@ -253,8 +265,12 @@ namespace BumpCombat.Defense portalRewardObjectCreated[lane] = false; portalRewardObjects[lane] = null; portalRewardLabels[lane] = null; - padsBuilt[lane] = false; - towers[lane] = null; + } + + for (int pad = 0; pad < BuildPadCount; pad++) + { + padsBuilt[pad] = false; + towers[pad] = null; } nextSupplyLane = 0; @@ -268,14 +284,15 @@ namespace BumpCombat.Defense { return TryBuildTowerAtPad( padIndex, - padIndex == 0 ? DefenseTowerKind.Direct : DefenseTowerKind.Summon); + Tuning.GetBuildPadPosition(padIndex).y >= Tuning.ArenaCenter.y + ? DefenseTowerKind.Summon : DefenseTowerKind.Direct); } public bool TryBuildTowerAtPad(int padIndex, DefenseTowerKind kind) { if (!CanOperateTowers || padIndex < 0 - || padIndex >= LaneCount + || padIndex >= BuildPadCount || padsBuilt[padIndex] || playerController == null) { @@ -289,16 +306,17 @@ namespace BumpCombat.Defense return false; } - int cost = kind == DefenseTowerKind.Direct - ? tuning.DirectTowerCost - : tuning.SummonTowerCost; + int cost = GetBuildCost(kind); + if (cost < 0) + { + return false; + } if (constructionCurrency < cost) { return false; } - GameObject towerObject = new( - kind == DefenseTowerKind.Direct ? "Direct Damage Tower" : "Summon Tower"); + GameObject towerObject = new($"{kind} Tower"); towerObject.transform.SetParent(worldRoot.transform, false); towerObject.transform.position = position; DefenseTower tower = towerObject.AddComponent(); @@ -309,6 +327,95 @@ namespace BumpCombat.Defense return true; } + private int GetBuildCost(DefenseTowerKind kind) + { + return kind switch + { + DefenseTowerKind.Direct => tuning.DirectTowerCost, + DefenseTowerKind.Summon => tuning.SummonTowerCost, + DefenseTowerKind.Lightning => tuning.LightningTowerCost, + DefenseTowerKind.Cold => tuning.ColdTowerCost, + DefenseTowerKind.Seismic => tuning.SeismicTowerCost, + DefenseTowerKind.Empowering => tuning.EmpoweringTowerCost, + _ => -1, + }; + } + + public bool TryUpgradeTowerAtPad(int padIndex, DefenseTowerBranch branch) + { + if (!CanOperateTowers || playerController == null + || padIndex < 0 || padIndex >= BuildPadCount + || towers[padIndex] == null) + { + return false; + } + + DefenseTower tower = towers[padIndex]; + if (tower.Tier >= 4 + || (tower.Tier < 3 && branch != DefenseTowerBranch.None) + || (tower.Tier == 3 && branch != DefenseTowerBranch.First + && branch != DefenseTowerBranch.Second)) + { + return false; + } + + Vector2 position = tuning.GetBuildPadPosition(padIndex); + if (((Vector2)playerController.transform.position - position).sqrMagnitude + > tuning.BuildInteractionRadius * tuning.BuildInteractionRadius) + { + return false; + } + + int cost = tower.Tier switch + { + 1 => tuning.Tier2UpgradeCost, + 2 => tuning.Tier3UpgradeCost, + _ => tuning.Tier4UpgradeCost, + }; + if (constructionCurrency < cost || !tower.Upgrade(branch)) + { + return false; + } + + constructionCurrency -= cost; + return true; + } + + private bool TryUpgradeNearestTower(DefenseTowerBranch branch) + { + DefenseTower nearest = GetNearestBuiltTower(); + return nearest != null && TryUpgradeTowerAtPad(nearest.PadIndex, branch); + } + + private DefenseTower GetNearestBuiltTower() + { + if (playerController == null) + { + return null; + } + + DefenseTower nearest = null; + float nearestDistance = tuning.BuildInteractionRadius + * tuning.BuildInteractionRadius; + for (int pad = 0; pad < BuildPadCount; pad++) + { + if (towers[pad] == null) + { + continue; + } + + float distance = ((Vector2)playerController.transform.position + - tuning.GetBuildPadPosition(pad)).sqrMagnitude; + if (distance <= nearestDistance) + { + nearestDistance = distance; + nearest = towers[pad]; + } + } + + return nearest; + } + private bool TryBuildNearestTower(DefenseTowerKind kind) { if (playerController == null) @@ -318,7 +425,7 @@ namespace BumpCombat.Defense int nearestPad = -1; float nearestDistance = tuning.BuildInteractionRadius * tuning.BuildInteractionRadius; - for (int pad = 0; pad < LaneCount; pad++) + for (int pad = 0; pad < BuildPadCount; pad++) { if (padsBuilt[pad]) { @@ -344,17 +451,14 @@ namespace BumpCombat.Defense return false; } - DefenseRouteAgent target = FindNearestTarget( - tower.transform.position, - tuning.TowerRange, - tower.Kind); - return tower.TryAttack(target); + return tower.TryPulse(); } public DefenseRouteAgent FindNearestTarget( Vector2 sourcePosition, float range, - DefenseTowerKind sourceKind) + DefenseTowerKind sourceKind, + bool ignoreTowerImmunity = false) { DefenseRouteAgent nearest = null; float nearestDistance = range * range; @@ -364,9 +468,10 @@ namespace BumpCombat.Defense || agent.HasArrived || agent.Enemy == null || agent.Enemy.IsDead - || DefensePrototypeRules.IsTowerImmune( - sourceKind, - agent.ImmuneTowerKind)) + || (!ignoreTowerImmunity + && DefensePrototypeRules.IsTowerImmune( + sourceKind, + agent.ImmuneTowerKind))) { continue; } @@ -382,6 +487,133 @@ namespace BumpCombat.Defense return nearest; } + public List GetTargetsInRange(Vector2 position, float radius) + { + List targets = new(); + float radiusSquared = radius * radius; + foreach (DefenseRouteAgent agent in activeAgents.Values) + { + if (agent != null && !agent.HasArrived + && agent.Enemy != null && !agent.Enemy.IsDead + && ((Vector2)agent.transform.position - position).sqrMagnitude + <= radiusSquared) + { + targets.Add(agent); + } + } + + targets.Sort((a, b) => + ((Vector2)a.transform.position - position).sqrMagnitude + .CompareTo(((Vector2)b.transform.position - position).sqrMagnitude)); + return targets; + } + + public float GetTowerAttackIntervalMultiplier(DefenseTower tower) + { + if (!CanOperateTowers || tower == null) + { + return 1f; + } + + foreach (DefenseTower support in towers) + { + if (support != null && support != tower + && support.IsOperational + && support.Kind == DefenseTowerKind.Empowering + && support.Tier == 4 + && support.Branch == DefenseTowerBranch.First + && Vector2.Distance(support.transform.position, + tower.transform.position) <= tuning.SupportRadius) + { + float sightEfficiency = GetSupportSightEfficiency(support); + return 1f - (1f - tuning.ImbuingAttackIntervalMultiplier) + * sightEfficiency; + } + } + + return 1f; + } + + private float GetSupportSightEfficiency(DefenseTower support) + { + return playerHealth == null + ? 0f + : DefensePrototypeRules.GetTowerEfficiency( + Vector2.Distance(playerHealth.transform.position, + support.transform.position), + tuning.PlayerSightRadius, 1f, + tuning.OutsideSightDamageEfficiency); + } + + public bool TryApplyTowerSlow( + DefenseTower tower, DefenseRouteAgent target, + float duration, float fraction) + { + if (!CanOperateTowers || tower == null || target == null + || target.HasArrived || target.Enemy == null || playerHealth == null) + { + return false; + } + + float efficiency = DefensePrototypeRules.GetTowerEfficiency( + Vector2.Distance(playerHealth.transform.position, tower.transform.position), + tuning.PlayerSightRadius, 1f, tuning.OutsideSightDamageEfficiency); + return target.Enemy.ApplySlow(duration, fraction * efficiency); + } + + public bool TryApplyTowerStun( + DefenseTower tower, DefenseRouteAgent target, float duration) + { + if (!CanOperateTowers || tower == null || target == null + || target.HasArrived || target.Enemy == null || playerHealth == null) + { + return false; + } + + float efficiency = DefensePrototypeRules.GetTowerEfficiency( + Vector2.Distance(playerHealth.transform.position, tower.transform.position), + tuning.PlayerSightRadius, 1f, tuning.OutsideSightDamageEfficiency); + return target.Enemy.ApplyStun(duration * efficiency); + } + + public bool TryBoostVisibleTowers(float duration) + { + if (!CanOperateTowers || duration <= 0f || playerHealth == null) + { + return false; + } + + bool applied = false; + foreach (DefenseTower tower in towers) + { + if (tower == null + || !tower.IsOperational + || !tower.IsDamaging + || tower.Kind == DefenseTowerKind.Summon + || Vector2.Distance( + playerHealth.transform.position, + tower.transform.position) > tuning.PlayerSightRadius) + { + continue; + } + + boostedTowers[tower] = Time.time + duration; + applied = true; + } + return applied; + } + + public bool TryAwardBondLedgerCurrency(int amount) + { + if (!active || failed || !PlayerIsAlive || amount <= 0) + { + return false; + } + + constructionCurrency += amount; + return true; + } + public bool TryApplyTowerDamage( DefenseTower tower, DefenseRouteAgent target, @@ -395,9 +627,44 @@ namespace BumpCombat.Defense float distanceToPlayer = Vector2.Distance( playerHealth.transform.position, tower.transform.position); + float multiplier = 1f; + foreach (DefenseTower support in towers) + { + if (support == null || support == tower + || support.Kind != DefenseTowerKind.Empowering + || !support.IsOperational) + { + continue; + } + + if (Vector2.Distance(support.transform.position, + tower.transform.position) <= tuning.SupportRadius) + { + multiplier = Mathf.Max(multiplier, + 1f + tuning.SupportDamageBonusPerTier * support.Tier + * GetSupportSightEfficiency(support)); + } + + if (support.Tier == 4 + && support.Branch == DefenseTowerBranch.Second + && Vector2.Distance(support.transform.position, + target.transform.position) <= tuning.SupportRadius) + { + multiplier *= 1f + tuning.SmotheringDamageBonus + * GetSupportSightEfficiency(support); + } + } + + if (boostedTowers.TryGetValue(tower, out float buffUntil) + && Time.time < buffUntil) + { + multiplier *= 1f + GameplayConstants.Current.PassiveRelics + .BattleBatonTowerDamageIncrease; + } + return target.TryTakeTowerDamage( tower.Kind, - damage, + damage * multiplier, distanceToPlayer, tuning.PlayerSightRadius, tuning.OutsideSightDamageEfficiency, @@ -528,9 +795,12 @@ namespace BumpCombat.Defense enemy.OnDied -= HandleEnemyDied; activeAgents.Remove(enemy); int lane = Mathf.Clamp(agent.Lane, 0, LaneCount - 1); - portalLedgers[lane]?.TryResolve( + if (portalLedgers[lane]?.TryResolve( enemy.SpawnLifetimeIdentity, - DefenseEnemyResolution.Death); + DefenseEnemyResolution.Death) == true) + { + constructionCurrency += tuning.EnemyKillCurrency; + } } private void HandleEnemyArrived(DefenseRouteAgent agent) @@ -700,7 +970,12 @@ namespace BumpCombat.Defense new Vector2(0.85f, 1.25f), new Color32(179, 84, 220, 255), 5); - CreateWorldLabel($"Portal {lane + 1} Label", portal + Vector2.right * 1.6f + Vector2.up * 0.9f, $"P{lane + 1} PORTAL\nIMMUNE {tuning.GetPortalImmuneTowerKind(lane).ToString().ToUpperInvariant()}"); + DefenseTowerKind immune = tuning.GetPortalImmuneTowerKind(lane); + string immuneName = immune == DefenseTowerKind.Direct + ? "FIRE" : immune.ToString().ToUpperInvariant(); + CreateWorldLabel($"Portal {lane + 1} Label", + portal + Vector2.right * 1.6f + Vector2.up * 0.9f, + $"P{lane + 1} PORTAL\nIMMUNE {immuneName}"); if (lane == 0) { CreateShape( @@ -713,7 +988,7 @@ namespace BumpCombat.Defense } } - for (int pad = 0; pad < LaneCount; pad++) + for (int pad = 0; pad < BuildPadCount; pad++) { Vector2 position = tuning.GetBuildPadPosition(pad); CreateShape( @@ -748,10 +1023,10 @@ namespace BumpCombat.Defense rect.anchorMax = new Vector2(0f, 1f); rect.pivot = new Vector2(0f, 1f); rect.anchoredPosition = new Vector2(28f, -452f); - rect.sizeDelta = new Vector2(900f, 110f); + rect.sizeDelta = new Vector2(1200f, 165f); hudText = hudObject.GetComponent(); hudText.font = BumpCombat.UI.PresentationUiStyle.GetFont(); - hudText.fontSize = 24; + hudText.fontSize = 19; hudText.color = new Color32(236, 240, 246, 255); hudText.alignment = TextAnchor.UpperLeft; hudText.horizontalOverflow = HorizontalWrapMode.Overflow; @@ -767,11 +1042,22 @@ namespace BumpCombat.Defense string status = playerDead ? $"PLAYER DOWN revive {Mathf.CeilToInt(reviveRemaining)}s" - : IsRewardVisible ? "REWARD READY move to portal" : $"1 DIRECT {tuning.DirectTowerCost} / 2 SUMMON {tuning.SummonTowerCost} near a pad"; + : IsRewardVisible ? "REWARD READY move to portal" + : $"1 FIRE {tuning.DirectTowerCost} 2 SUMMON {tuning.SummonTowerCost} 3 LIGHTNING {tuning.LightningTowerCost}"; + DefenseTower nearby = GetNearestBuiltTower(); + string upgrade = nearby == null + ? "Move near a built tower to upgrade" + : nearby.Tier == 4 + ? $"{nearby.Kind} final branch: {DefenseTower.GetBranchName(nearby.Kind, nearby.Branch)}" + : nearby.Tier == 3 + ? $"J {DefenseTower.GetBranchName(nearby.Kind, DefenseTowerBranch.First)} / K {DefenseTower.GetBranchName(nearby.Kind, DefenseTowerBranch.Second)} {tuning.Tier4UpgradeCost}" + : $"U TIER {nearby.Tier + 1} {(nearby.Tier == 1 ? tuning.Tier2UpgradeCost : tuning.Tier3UpgradeCost)}"; hudText.text = $"DEFENSE TEST BASE {baseHealth}/{tuning.BaseMaximumHealth}\n" + $"BUILD {constructionCurrency} P1 WAIT {GetPortalRemainingSupply(0)} LIVE {CountActiveAgentsForLane(0)} P2 WAIT {GetPortalRemainingSupply(1)} LIVE {CountActiveAgentsForLane(1)}\n" - + status; + + status + "\n" + + $"4 COLD {tuning.ColdTowerCost} 5 SEISMIC {tuning.SeismicTowerCost} 6 SUPPORT {tuning.EmpoweringTowerCost}\n" + + upgrade; } private void CreateLaneLine(int lane, Vector2 from, Vector2 to) diff --git a/Assets/_Project/Scripts/Defense/DefensePrototypeRules.cs b/Assets/_Project/Scripts/Defense/DefensePrototypeRules.cs index 592ce64..dce05e5 100644 --- a/Assets/_Project/Scripts/Defense/DefensePrototypeRules.cs +++ b/Assets/_Project/Scripts/Defense/DefensePrototypeRules.cs @@ -4,8 +4,19 @@ namespace BumpCombat.Defense { public enum DefenseTowerKind { - Direct, - Summon, + Direct = 0, + Summon = 1, + Lightning, + Cold, + Seismic, + Empowering, + } + + public enum DefenseTowerBranch + { + None, + First, + Second, } public enum DefenseEnemyResolution diff --git a/Assets/_Project/Scripts/Defense/DefenseRouteAgent.cs b/Assets/_Project/Scripts/Defense/DefenseRouteAgent.cs index 6610c8c..aecd7ad 100644 --- a/Assets/_Project/Scripts/Defense/DefenseRouteAgent.cs +++ b/Assets/_Project/Scripts/Defense/DefenseRouteAgent.cs @@ -123,7 +123,7 @@ namespace BumpCombat.Defense Vector2 next = Vector2.MoveTowards( current, target, - routeSpeed * Mathf.Max(0f, deltaTime)); + routeSpeed * enemy.MovementSpeedMultiplier * Mathf.Max(0f, deltaTime)); if (!enemy.TryReposition(next)) { return false; diff --git a/Assets/_Project/Scripts/Defense/DefenseSummonUnit.cs b/Assets/_Project/Scripts/Defense/DefenseSummonUnit.cs index 0179cdf..cef1aff 100644 --- a/Assets/_Project/Scripts/Defense/DefenseSummonUnit.cs +++ b/Assets/_Project/Scripts/Defense/DefenseSummonUnit.cs @@ -5,11 +5,15 @@ namespace BumpCombat.Defense [DisallowMultipleComponent] public sealed class DefenseSummonUnit : MonoBehaviour { + private static Sprite[] idleFrames; + private static Sprite[] attackFrames; + private DefenseTower owner; private DefensePrototypeController controller; private Vector2 anchorLocalPosition; private SpriteRenderer spriteRenderer; private float attackCooldown; + private float attackAnimationTime = -1f; public void Configure( DefenseTower tower, @@ -20,18 +24,22 @@ namespace BumpCombat.Defense controller = prototypeController; anchorLocalPosition = localAnchor; spriteRenderer = gameObject.AddComponent(); - Sprite[] frames = Resources.LoadAll("Tutorial/Swordsman_Idle"); - if (frames != null && frames.Length > 0) + idleFrames ??= Resources.LoadAll("Tutorial/Swordsman_Idle"); + attackFrames ??= Resources.LoadAll("Tutorial/Swordsman_BumpAttack-v1"); + if (idleFrames.Length > 0) { - spriteRenderer.sprite = frames[0]; + spriteRenderer.sprite = idleFrames[0]; } spriteRenderer.color = new Color32(255, 234, 137, 255); spriteRenderer.sortingOrder = 11; - transform.localScale = new Vector3(0.9f, 0.9f, 1f); + float scale = owner.Tier == 4 && owner.Branch == DefenseTowerBranch.First + ? 1.35f : 0.9f; + transform.localScale = new Vector3(scale, scale, 1f); } private void Update() { + UpdateAttackSprite(); if (owner == null || !owner.isActiveAndEnabled || controller == null @@ -41,11 +49,11 @@ namespace BumpCombat.Defense } attackCooldown = Mathf.Max(0f, attackCooldown - Time.deltaTime); + Vector2 anchor = owner.transform.TransformPoint(anchorLocalPosition); DefenseRouteAgent target = controller.FindNearestTarget( - transform.position, + anchor, controller.Tuning.SummonAreaRadius, DefenseTowerKind.Summon); - Vector2 anchor = owner.transform.TransformPoint(anchorLocalPosition); if (target == null) { transform.position = Vector2.MoveTowards( @@ -56,24 +64,56 @@ namespace BumpCombat.Defense } Vector2 targetPosition = target.transform.position; - Vector2 fromAnchor = targetPosition - anchor; - if (fromAnchor.magnitude > controller.Tuning.SummonAreaRadius) - { - targetPosition = anchor + fromAnchor.normalized * controller.Tuning.SummonAreaRadius; - } - + spriteRenderer.flipX = targetPosition.x < transform.position.x; transform.position = Vector2.MoveTowards( transform.position, targetPosition, controller.Tuning.SummonUnitMoveSpeed * Time.deltaTime); + float attackRange = controller.Tuning.SummonAttackRange + * (owner.Tier == 4 && owner.Branch == DefenseTowerBranch.Second ? 2.5f : 1f); if (attackCooldown <= 0f - && (target.transform.position - transform.position).sqrMagnitude <= controller.Tuning.SummonAttackRange * controller.Tuning.SummonAttackRange) + && (targetPosition - (Vector2)transform.position).sqrMagnitude + <= attackRange * attackRange + && owner.TryAttack(target)) { - if (owner.TryAttack(target)) - { - attackCooldown = controller.Tuning.SummonAttackInterval; - } + PlayAttackSprite(); + owner.ShowSummonAttackEffect(transform.position, targetPosition); + attackCooldown = controller.Tuning.SummonAttackInterval + / (1f + 0.15f * (owner.Tier - 1)) + * controller.GetTowerAttackIntervalMultiplier(owner); } } + + private void PlayAttackSprite() + { + if (attackFrames.Length == 0) + { + return; + } + attackAnimationTime = 0f; + spriteRenderer.sprite = attackFrames[0]; + } + + private void UpdateAttackSprite() + { + if (attackAnimationTime < 0f || attackFrames == null + || attackFrames.Length == 0) + { + return; + } + + attackAnimationTime += Time.deltaTime; + int frame = Mathf.FloorToInt(attackAnimationTime * 12f); + if (frame >= attackFrames.Length) + { + attackAnimationTime = -1f; + if (idleFrames != null && idleFrames.Length > 0) + { + spriteRenderer.sprite = idleFrames[0]; + } + return; + } + spriteRenderer.sprite = attackFrames[frame]; + } } -} +} \ No newline at end of file diff --git a/Assets/_Project/Scripts/Defense/DefenseTower.cs b/Assets/_Project/Scripts/Defense/DefenseTower.cs index 2d734ee..c07dd09 100644 --- a/Assets/_Project/Scripts/Defense/DefenseTower.cs +++ b/Assets/_Project/Scripts/Defense/DefenseTower.cs @@ -10,12 +10,18 @@ namespace BumpCombat.Defense private DefenseConstants tuning; private float attackCooldown; private TextMesh label; + private LineRenderer effectRange; + private static Material effectMaterial; + private int summonCount; public DefenseTowerKind Kind { get; private set; } + public DefenseTowerBranch Branch { get; private set; } + public int Tier { get; private set; } = 1; public int PadIndex { get; private set; } public bool IsOperational => isActiveAndEnabled && owner != null && owner.CanOperateTowers; + public bool IsDamaging => Kind != DefenseTowerKind.Empowering; public void Configure( DefensePrototypeController controller, @@ -27,26 +33,57 @@ namespace BumpCombat.Defense Kind = kind; PadIndex = padIndex; tuning = constants; + Tier = 1; + Branch = DefenseTowerBranch.None; CreateVisuals(); if (Kind == DefenseTowerKind.Summon) { - for (int i = 0; i < tuning.SummonUnitCount; i++) + AddSummonUnits(tuning.SummonUnitCount); + } + } + + public bool Upgrade(DefenseTowerBranch branch) + { + if (Tier >= 4 || (Tier < 3 && branch != DefenseTowerBranch.None) + || (Tier == 3 && branch != DefenseTowerBranch.First + && branch != DefenseTowerBranch.Second)) + { + return false; + } + + Tier++; + if (Tier == 4) + { + Branch = branch; + } + + if (Kind == DefenseTowerKind.Summon) + { + if (Tier == 4 && Branch == DefenseTowerBranch.First) { - GameObject unitObject = new($"Summon Unit {i + 1}"); - unitObject.transform.SetParent(transform, false); - unitObject.transform.localPosition = new Vector3( - (i - (tuning.SummonUnitCount - 1) * 0.5f) * 0.42f, - -0.5f, - 0f); - DefenseSummonUnit unit = unitObject.AddComponent(); - unit.Configure(this, controller, unitObject.transform.localPosition); + foreach (DefenseSummonUnit unit in GetComponentsInChildren()) + { + unit.enabled = false; + Destroy(unit.gameObject); + } + summonCount = 0; + AddSummonUnits(1); + } + else + { + AddSummonUnits(Tier == 4 ? 2 : 1); } } + + UpdateLabel(); + UpdateEffectRange(); + return true; } private void Update() { - if (!IsOperational || Kind != DefenseTowerKind.Direct) + if (!IsOperational || Kind == DefenseTowerKind.Summon + || Kind == DefenseTowerKind.Empowering) { return; } @@ -57,12 +94,386 @@ namespace BumpCombat.Defense return; } - float interval = Kind == DefenseTowerKind.Direct - ? tuning.DirectTowerAttackInterval - : tuning.SummonAttackInterval; if (owner.TryAttackFromTower(this)) { - attackCooldown = Mathf.Max(0.05f, interval); + float interval = Kind switch + { + DefenseTowerKind.Direct => tuning.DirectTowerAttackInterval, + DefenseTowerKind.Lightning => tuning.LightningInterval, + DefenseTowerKind.Cold => tuning.ColdInterval, + _ => tuning.SeismicInterval, + }; + if (Tier == 4 && Kind == DefenseTowerKind.Direct + && Branch == DefenseTowerBranch.Second) + { + interval *= 0.4f; + } + + attackCooldown = Mathf.Max(0.05f, + interval / (1f + 0.15f * (Tier - 1)) + * owner.GetTowerAttackIntervalMultiplier(this)); + } + } + + public bool TryPulse() + { + if (!IsOperational || Kind == DefenseTowerKind.Empowering + || Kind == DefenseTowerKind.Summon) + { + return false; + } + + float range = GetEffectRange(); + DefenseRouteAgent target = owner.FindNearestTarget( + transform.position, range, Kind, + Kind == DefenseTowerKind.Cold || Kind == DefenseTowerKind.Seismic); + if (target == null) + { + return false; + } + + Vector2 center = target.transform.position; + float scale = 1f + tuning.TowerDamageBonusPerTier * (Tier - 1); + float impactRadius = 0f; + bool cone = false; + bool applied; + switch (Kind) + { + case DefenseTowerKind.Direct: + if (Tier == 4 && Branch == DefenseTowerBranch.Second) + { + cone = true; + impactRadius = range; + applied = AttackCone(center - (Vector2)transform.position, + range, tuning.DirectTowerDamage * scale * 0.4f); + } + else if (Tier == 4) + { + impactRadius = 1.8f; + applied = AttackArea(center, impactRadius, + tuning.DirectTowerDamage * scale * 1.6f); + } + else + { + applied = owner.TryApplyTowerDamage(this, target, + tuning.DirectTowerDamage * scale); + } + break; + + case DefenseTowerKind.Lightning: + impactRadius = Tier == 4 + ? (Branch == DefenseTowerBranch.First ? 3f : 2.4f) + : tuning.LightningRadius; + applied = AttackArea(center, impactRadius, + tuning.LightningDamage * scale + * (Tier == 4 && Branch == DefenseTowerBranch.Second ? 1.4f : 1f), + Tier == 4 && Branch == DefenseTowerBranch.First ? 3 : int.MaxValue); + break; + + case DefenseTowerKind.Cold: + if (Tier == 4 && Branch == DefenseTowerBranch.Second) + { + impactRadius = 1.65f; + applied = AttackArea(center, impactRadius, + tuning.ColdDamage * scale * 0.5f, + int.MaxValue, tuning.ColdSlowSeconds * 1.5f, + tuning.ColdSlowFraction, 0.35f); + } + else + { + bool slowed = owner.TryApplyTowerSlow(this, target, + tuning.ColdSlowSeconds * (1f + 0.25f * (Tier - 1)), + tuning.ColdSlowFraction); + bool damaged = owner.TryApplyTowerDamage(this, target, + tuning.ColdDamage * scale * (Tier == 4 ? 1.8f : 1f)); + if (Tier == 4) + { + owner.TryApplyTowerStun(this, target, 0.3f); + } + applied = slowed || damaged; + } + break; + + case DefenseTowerKind.Seismic: + impactRadius = Tier == 4 + ? (Branch == DefenseTowerBranch.Second ? 2.2f : 1.8f) + : tuning.SeismicRadius; + applied = Tier == 4 && Branch == DefenseTowerBranch.Second + ? AttackArea(center, impactRadius, + tuning.SeismicDamage * scale, int.MaxValue, 2f, 0.5f) + : AttackArea(center, impactRadius, + tuning.SeismicDamage * scale, int.MaxValue, + 0f, 0f, + tuning.SeismicStunSeconds * (Tier == 4 ? 2f : 1f)); + break; + + default: + return false; + } + + if (applied) + { + ShowAttackEffect(center, impactRadius, cone); + } + return applied; + } + + private bool AttackCone(Vector2 direction, float range, float damage) + { + bool applied = false; + Vector2 facing = direction.normalized; + foreach (DefenseRouteAgent agent in owner.GetTargetsInRange( + transform.position, range)) + { + Vector2 toAgent = ((Vector2)agent.transform.position + - (Vector2)transform.position).normalized; + if (toAgent.sqrMagnitude < 0.000001f + || Vector2.Dot(facing, toAgent) >= 0.5f) + { + applied |= owner.TryApplyTowerDamage(this, agent, damage); + } + } + + return applied; + } + + private bool AttackArea( + Vector2 center, + float radius, + float damage, + int maxTargets = int.MaxValue, + float slowSeconds = 0f, + float slowFraction = 0f, + float stunSeconds = 0f) + { + bool applied = false; + int count = 0; + foreach (DefenseRouteAgent agent in owner.GetTargetsInRange(center, radius)) + { + if (count++ >= maxTargets) + { + break; + } + + bool damaged = owner.TryApplyTowerDamage(this, agent, damage); + bool slowed = slowSeconds > 0f + && owner.TryApplyTowerSlow(this, agent, slowSeconds, slowFraction); + bool stunned = stunSeconds > 0f + && owner.TryApplyTowerStun(this, agent, stunSeconds); + applied |= damaged || slowed || stunned; + } + + return applied; + } + + public bool TryAttack(DefenseRouteAgent target) + { + if (target == null || owner == null || !IsOperational) + { + return false; + } + + float damage = Kind == DefenseTowerKind.Summon + ? tuning.SummonDamage : tuning.DirectTowerDamage; + damage *= 1f + tuning.TowerDamageBonusPerTier * (Tier - 1); + if (Tier == 4 && Kind == DefenseTowerKind.Summon + && Branch == DefenseTowerBranch.First) + { + damage *= 1.8f; + } + + bool hit = owner.TryApplyTowerDamage(this, target, damage); + if (hit && Tier == 4 && Kind == DefenseTowerKind.Summon + && Branch == DefenseTowerBranch.First) + { + owner.TryApplyTowerStun(this, target, 0.3f); + } + + return hit; + } + + public void ShowSummonAttackEffect(Vector2 origin, Vector2 target) + { + if (IsOperational && Kind == DefenseTowerKind.Summon) + { + ShowAttackEffect(target, 0f, false, origin); + } + } + + private float GetEffectRange() + { + if (Kind == DefenseTowerKind.Summon) + { + return tuning.SummonAreaRadius; + } + if (Kind == DefenseTowerKind.Empowering) + { + return tuning.SupportRadius; + } + return Kind == DefenseTowerKind.Direct && Tier == 4 + && Branch == DefenseTowerBranch.Second + ? tuning.TowerRange * 0.5f : tuning.TowerRange; + } + + private void CreateEffectRange() + { + GameObject rangeObject = new("Effect Range"); + rangeObject.transform.SetParent(transform, false); + effectRange = rangeObject.AddComponent(); + effectRange.useWorldSpace = false; + effectRange.loop = true; + effectRange.positionCount = 48; + effectRange.sharedMaterial = GetEffectMaterial(); + effectRange.sortingOrder = 3; + Color color = GetComponent().color; + color.a = Kind == DefenseTowerKind.Empowering ? 0.35f : 0.22f; + effectRange.startColor = color; + effectRange.endColor = color; + UpdateEffectRange(); + } + + private void UpdateEffectRange() + { + if (effectRange == null) + { + return; + } + float worldScale = Mathf.Max(0.001f, transform.lossyScale.x); + float radius = GetEffectRange() / worldScale; + effectRange.startWidth = 0.055f / worldScale; + effectRange.endWidth = effectRange.startWidth; + for (int i = 0; i < effectRange.positionCount; i++) + { + float angle = i * Mathf.PI * 2f / effectRange.positionCount; + effectRange.SetPosition(i, new Vector3( + Mathf.Cos(angle) * radius, + Mathf.Sin(angle) * radius, 0f)); + } + } + + private void ShowAttackEffect( + Vector2 center, float radius, bool cone, Vector2? source = null) + { + Vector2 origin = source ?? (Vector2)transform.position; + GameObject effect = new($"{Kind} Attack Effect"); + effect.transform.SetParent(transform.parent, false); + effect.transform.position = center; + Color color = GetComponent().color; + color.a = 0.9f; + Vector2 direction = center - origin; + + if (Kind == DefenseTowerKind.Lightning && direction.sqrMagnitude > 0.001f) + { + LineRenderer bolt = CreateEffectLine(effect.transform, "Bolt", color, 0.13f); + bolt.positionCount = 5; + Vector2 side = new(-direction.y, direction.x); + side.Normalize(); + for (int i = 0; i < 5; i++) + { + float fraction = i / 4f; + float offset = i == 1 ? 0.22f : i == 2 ? -0.16f + : i == 3 ? 0.18f : 0f; + bolt.SetPosition(i, origin - center + + direction * fraction + side * offset); + } + } + else if (Kind != DefenseTowerKind.Seismic + && direction.sqrMagnitude > 0.001f) + { + SpriteRenderer shot = CreateEffectSprite( + effect.transform, "Shot Sprite", + origin + direction * 0.5f, + new Vector2(direction.magnitude, + Kind == DefenseTowerKind.Direct ? 0.2f : 0.13f), color); + shot.transform.rotation = Quaternion.Euler( + 0f, 0f, Mathf.Atan2(direction.y, direction.x) + * Mathf.Rad2Deg); + } + + CreateEffectSprite(effect.transform, "Impact Sprite", + center, new Vector2(0.42f, 0.42f), color); + if (cone) + { + LineRenderer outline = CreateEffectLine( + effect.transform, "Cone Radius", color, 0.07f); + outline.positionCount = 3; + Vector2 forward = direction.normalized * radius; + outline.SetPosition(0, origin - center); + outline.SetPosition(1, origin - center + + (Vector2)(Quaternion.Euler(0f, 0f, -60f) * forward)); + outline.SetPosition(2, origin - center + + (Vector2)(Quaternion.Euler(0f, 0f, 60f) * forward)); + } + else if (radius > 0f) + { + LineRenderer area = CreateEffectLine( + effect.transform, "Impact Radius", color, 0.07f); + area.loop = true; + area.positionCount = 40; + for (int i = 0; i < area.positionCount; i++) + { + float angle = i * Mathf.PI * 2f / area.positionCount; + area.SetPosition(i, new Vector3( + Mathf.Cos(angle) * radius, + Mathf.Sin(angle) * radius, 0f)); + } + } + + Destroy(effect, 0.22f); + } + + private static SpriteRenderer CreateEffectSprite( + Transform parent, string name, Vector2 position, + Vector2 size, Color color) + { + GameObject shape = new(name); + shape.transform.SetParent(parent, false); + shape.transform.position = position; + shape.transform.localScale = new Vector3(size.x, size.y, 1f); + SpriteRenderer renderer = shape.AddComponent(); + renderer.sprite = DefensePrototypeController.WhiteSprite; + renderer.color = color; + renderer.sortingOrder = 15; + return renderer; + } + + private static LineRenderer CreateEffectLine( + Transform parent, string name, Color color, float width) + { + GameObject shape = new(name); + shape.transform.SetParent(parent, false); + LineRenderer line = shape.AddComponent(); + line.useWorldSpace = false; + line.sharedMaterial = GetEffectMaterial(); + line.startWidth = width; + line.endWidth = width; + line.startColor = color; + line.endColor = color; + line.sortingOrder = 14; + return line; + } + + private static Material GetEffectMaterial() + { + if (effectMaterial == null) + { + effectMaterial = new Material(Shader.Find("Sprites/Default")); + effectMaterial.hideFlags = HideFlags.HideAndDontSave; + } + return effectMaterial; + } + + private void AddSummonUnits(int count) + { + for (int i = 0; i < count; i++) + { + int unitIndex = summonCount++; + GameObject unitObject = new($"Summon Unit {unitIndex + 1}"); + unitObject.transform.SetParent(transform, false); + unitObject.transform.localPosition = new Vector3( + (unitIndex % 3 - 1) * 0.42f, -0.5f - 0.25f * (unitIndex / 3), 0f); + DefenseSummonUnit unit = unitObject.AddComponent(); + unit.Configure(this, owner, unitObject.transform.localPosition); } } @@ -74,9 +485,15 @@ namespace BumpCombat.Defense renderer = gameObject.AddComponent(); } renderer.sprite = DefensePrototypeController.WhiteSprite; - renderer.color = Kind == DefenseTowerKind.Direct - ? new Color32(65, 211, 240, 255) - : new Color32(244, 154, 74, 255); + renderer.color = Kind switch + { + DefenseTowerKind.Direct => new Color32(240, 96, 65, 255), + DefenseTowerKind.Summon => new Color32(244, 154, 74, 255), + DefenseTowerKind.Lightning => new Color32(238, 218, 83, 255), + DefenseTowerKind.Cold => new Color32(65, 211, 240, 255), + DefenseTowerKind.Seismic => new Color32(175, 141, 100, 255), + _ => new Color32(148, 228, 122, 255), + }; renderer.sortingOrder = 8; transform.localScale = new Vector3(0.72f, 0.72f, 1f); @@ -93,26 +510,43 @@ namespace BumpCombat.Defense labelObject.transform.SetParent(transform, false); labelObject.transform.localPosition = new Vector3(0f, 0.85f, 0f); label = labelObject.AddComponent(); - label.text = Kind == DefenseTowerKind.Direct ? "DIRECT" : "SUMMON"; label.anchor = TextAnchor.MiddleCenter; label.alignment = TextAlignment.Center; label.characterSize = 0.16f; label.fontSize = 32; label.color = Color.white; label.GetComponent().sortingOrder = 10; + UpdateLabel(); + CreateEffectRange(); } - public bool TryAttack(DefenseRouteAgent target) + private void UpdateLabel() { - if (target == null || owner == null) + if (label == null) { - return false; + return; } - float damage = Kind == DefenseTowerKind.Direct - ? tuning.DirectTowerDamage - : tuning.SummonDamage; - return owner.TryApplyTowerDamage(this, target, damage); + string family = Kind == DefenseTowerKind.Direct ? "FIRE" : Kind.ToString().ToUpperInvariant(); + string branchName = Tier == 4 + ? GetBranchName(Kind, Branch) : string.Empty; + label.text = Tier == 4 + ? $"{family} IV\n{branchName}" + : $"{family} {Tier}"; + } + + public static string GetBranchName(DefenseTowerKind kind, DefenseTowerBranch branch) + { + bool first = branch == DefenseTowerBranch.First; + return kind switch + { + DefenseTowerKind.Direct => first ? "METEOR" : "FLAMETHROWER", + DefenseTowerKind.Lightning => first ? "ARC" : "LIGHTNING STORM", + DefenseTowerKind.Cold => first ? "FREEZEBOLT" : "GLACIAL CAGE", + DefenseTowerKind.Seismic => first ? "STONE GAZE" : "TEMPORAL", + DefenseTowerKind.Summon => first ? "SENTINEL" : "SCOUT", + _ => first ? "IMBUING" : "SMOTHERING", + }; } } -} +} \ No newline at end of file diff --git a/Assets/_Project/Scripts/Enemies/EnemyController.cs b/Assets/_Project/Scripts/Enemies/EnemyController.cs index 728b3cd..fa4400a 100644 --- a/Assets/_Project/Scripts/Enemies/EnemyController.cs +++ b/Assets/_Project/Scripts/Enemies/EnemyController.cs @@ -99,6 +99,8 @@ namespace BumpCombat.Enemies private Vector2 knockbackVelocity; private float stateTimer; private float stunTimer; + private float slowRemaining; + private float slowFraction; private float igniteRemaining; private float igniteNextTickIn; private float igniteTickInterval; @@ -224,10 +226,14 @@ namespace BumpCombat.Enemies ? groggyTier : null; public bool IsStunned => stunTimer > 0f; + public float MovementSpeedMultiplier => slowRemaining > 0f ? 1f - slowFraction : 1f; public bool IsIgnited => igniteRemaining > 0f; public bool IsShocked => shockRemaining > 0f; public bool IsBumpVulnerable => vulnerabilityRemaining > 0f; public float IgniteRemaining => igniteRemaining; + public float IgniteRemainingAtDeath { get; private set; } + public float IgniteTickIntervalAtDeath { get; private set; } + public float IgniteTickDamageAtDeath { get; private set; } public float ShockRemaining => shockRemaining; public float BumpVulnerabilityRemaining => vulnerabilityRemaining; public float ShockIncrease => shockIncrease; @@ -265,7 +271,7 @@ namespace BumpCombat.Enemies public EnemyAttackShape AttackShape => definition != null ? CurrentAttackPattern.AttackShape : EnemyAttackShape.Box; - public float MoveSpeed => EnsureEnemyModel().MoveSpeed; + public float MoveSpeed => EnsureEnemyModel().MoveSpeed * MovementSpeedMultiplier; public float AttackDamage => EnsureEnemyModel().CalculatePatternDamage( CurrentAttackPattern.DamageMultiplier, IsEventEnemy); @@ -659,6 +665,15 @@ namespace BumpCombat.Enemies return; } + if (slowRemaining > 0f) + { + slowRemaining = Mathf.Max(0f, slowRemaining - Time.fixedDeltaTime); + if (slowRemaining <= 0f) + { + slowFraction = 0f; + } + } + if (stunTimer > 0f) { stunTimer = Mathf.Max( @@ -1622,6 +1637,22 @@ namespace BumpCombat.Enemies EnterState(EnemyState.Recovery, 0f); } + public bool ApplySlow(float duration, float fraction) + { + if (IsDead + || IsDamageInvulnerable + || isGroggy + || duration <= 0f + || fraction <= 0f) + { + return false; + } + + slowRemaining = Mathf.Max(slowRemaining, duration); + slowFraction = Mathf.Max(slowFraction, Mathf.Clamp01(fraction)); + return true; + } + public bool ApplyStun(float duration) { if (IsDead || duration <= 0f) @@ -2884,6 +2915,9 @@ namespace BumpCombat.Enemies private void Die() { + IgniteRemainingAtDeath = igniteRemaining; + IgniteTickIntervalAtDeath = igniteTickInterval; + IgniteTickDamageAtDeath = igniteTickDamage; ClearTimedEffects(); EnterState(EnemyState.Dead, 0f); ReleaseAttackToken(); diff --git a/Assets/_Project/Scripts/Enemies/EnemyDamageSource.cs b/Assets/_Project/Scripts/Enemies/EnemyDamageSource.cs index 68eefcc..aae7a03 100644 --- a/Assets/_Project/Scripts/Enemies/EnemyDamageSource.cs +++ b/Assets/_Project/Scripts/Enemies/EnemyDamageSource.cs @@ -6,5 +6,7 @@ namespace BumpCombat.Enemies Player, Tower, PassiveRelicExplosion, + PassiveRelicAttack, + PassiveRelicTransferredIgnite, } } \ No newline at end of file diff --git a/Assets/_Project/Scripts/Player/ActiveArtifactController.cs b/Assets/_Project/Scripts/Player/ActiveArtifactController.cs index 020d743..8ae54ce 100644 --- a/Assets/_Project/Scripts/Player/ActiveArtifactController.cs +++ b/Assets/_Project/Scripts/Player/ActiveArtifactController.cs @@ -439,6 +439,19 @@ namespace BumpCombat.Player ResolveMovementGainPerSecond() * actualMovementTime)); } + public bool TryRestorePassiveRelicGauge(float maxGaugeFraction) + { + if (CurrentArtifact == null + || maxGaugeFraction <= 0f + || CurrentGauge >= MaxGauge) + { + return false; + } + + AddGauge(MaxGauge * maxGaugeFraction); + return true; + } + public void AddNormalHitCharge() { if (IsExecutingArtifact @@ -2213,14 +2226,32 @@ namespace BumpCombat.Player return false; } + public Vector2 GetPassiveRelicUseOrigin() => lastArtifactUsePosition; + public Vector2 GetPassiveRelicUseDirection() => GetUseDirection(); + public bool TryEmitPassiveRelicEcho( + Vector2 origin, + Vector2 direction, + float damageMultiplier) => + EmitPassiveRelicAttack(CurrentArtifact, false, origin, direction, damageMultiplier); + + public bool TryEmitPassiveRelicRepeat( + ActiveArtifactDefinition definition, + bool charged, + Vector2 origin, + Vector2 direction, + float damageMultiplier) => + EmitPassiveRelicAttack(definition, charged, origin, direction, damageMultiplier); + + private bool EmitPassiveRelicAttack( + ActiveArtifactDefinition definition, + bool charged, Vector2 origin, Vector2 direction, float damageMultiplier) { - ActiveArtifactDefinition definition = CurrentArtifact; if (definition == null - || definition.NormalDamage <= 0f + || (charged ? definition.ChargedDamage : definition.NormalDamage) <= 0f || damageMultiplier <= 0f || definition.Effect == ActiveArtifactEffect.Pulse) { @@ -2236,18 +2267,18 @@ namespace BumpCombat.Player direction.Normalize(); int castIdentity = ++nextArtifactCastIdentity; float echoIgniteTickDamage = playerStats.CalculateDamage( - definition.NormalIgniteTickDamage * damageMultiplier, + (charged ? definition.ChargedIgniteTickDamage : definition.NormalIgniteTickDamage) * damageMultiplier, definition.DamageTag); if (definition.Effect == ActiveArtifactEffect.ChainLightning) { - EnemyController first = FindInitialChainTarget(origin, definition.NormalRange); + EnemyController first = FindInitialChainTarget(origin, (charged ? definition.ChargedRange : definition.NormalRange)); if (first == null) { return false; } bool applied = false; - foreach (ChainStrike strike in PlanArcChain(definition, first, origin)) + foreach (ChainStrike strike in (charged ? PlanBranchingChain(definition, first, origin) : PlanArcChain(definition, first, origin))) { applied |= ApplyArtifactHit( strike.Enemy, @@ -2256,7 +2287,7 @@ namespace BumpCombat.Player (strike.End - strike.Start).normalized, strike.Knockback, true, - false, + charged, echoIgniteTickDamage, castIdentity, true); @@ -2266,18 +2297,18 @@ namespace BumpCombat.Player } Vector2 center = origin; - Vector2 radii = new Vector2(definition.NormalRange, definition.NormalRange); - float rayRange = definition.NormalRange; - float rayHalfWidth = Mathf.Max(0.05f, definition.NormalWidth * 0.5f); + Vector2 radii = new Vector2((charged ? definition.ChargedRange : definition.NormalRange), (charged ? definition.ChargedRange : definition.NormalRange)); + float rayRange = (charged ? definition.ChargedRange : definition.NormalRange); + float rayHalfWidth = Mathf.Max(0.05f, (charged ? definition.ChargedWidth : definition.NormalWidth) * 0.5f); switch (definition.Effect) { case ActiveArtifactEffect.Cyclone: - center += GetCycloneHitCenterOffset(definition.NormalRange, false); - radii = GetCycloneHitRadii(definition.NormalRange, false); + center += GetCycloneHitCenterOffset((charged ? definition.ChargedRange : definition.NormalRange), charged); + radii = GetCycloneHitRadii((charged ? definition.ChargedRange : definition.NormalRange), charged); break; case ActiveArtifactEffect.ThunderCrash: center = origin + direction * 0.45f; - radii = GetThunderCrashHitRadii(definition.NormalRange, direction); + radii = GetThunderCrashHitRadii((charged ? definition.ChargedRange : definition.NormalRange), direction); break; } @@ -2292,7 +2323,7 @@ namespace BumpCombat.Player bool anyDamage = false; foreach (EnemyController enemy in enemies) { - if (hitCount >= definition.NormalMaxTargets) + if (hitCount >= (charged ? definition.ChargedMaxTargets : definition.NormalMaxTargets)) { break; } @@ -2324,11 +2355,11 @@ namespace BumpCombat.Player bool damaged = ApplyArtifactHit( enemy, definition, - definition.NormalDamage * damageMultiplier, + (charged ? definition.ChargedDamage : definition.NormalDamage) * damageMultiplier, hitDirection, - definition.NormalKnockback, + (charged ? definition.ChargedKnockback : definition.NormalKnockback), true, - false, + charged, echoIgniteTickDamage, castIdentity, true); @@ -2444,7 +2475,10 @@ namespace BumpCombat.Player false, true, definition.Effect, - charged)); + charged, + playerStats.CalculateDamage( + GameplayConstants.Current.Combat.BaseDamage, + DamageTag.Collision))); return damageWasApplied; } diff --git a/Assets/_Project/Scripts/Player/PlayerController.cs b/Assets/_Project/Scripts/Player/PlayerController.cs index 54e56a5..e783b9a 100644 --- a/Assets/_Project/Scripts/Player/PlayerController.cs +++ b/Assets/_Project/Scripts/Player/PlayerController.cs @@ -2,6 +2,7 @@ using UnityEngine; using UnityEngine.InputSystem; using BumpCombat.Core; using BumpCombat.Constants; +using BumpCombat.Progression; namespace BumpCombat.Player { @@ -33,12 +34,17 @@ namespace BumpCombat.Player private DashController dashController; private ActiveArtifactController activeArtifactController; private PlayerHealth playerHealth; + private PassiveRelicController passiveRelics; private Vector2 moveInput; private Vector2 pendingImpactRecoil; private Vector2 damageKnockbackDirection; private float damageKnockbackDistance; private float damageKnockbackDuration; private float damageKnockbackElapsed; + private Vector2 voluntaryMovementStart; + private bool voluntaryMovementPending; + + public void SetPassiveRelicController(PassiveRelicController relics) => passiveRelics = relics; public Vector2 MoveDirection { get; private set; } public Vector2 FacingDirection { get; private set; } = Vector2.right; @@ -72,6 +78,7 @@ namespace BumpCombat.Player dashController = GetComponent(); activeArtifactController = GetComponent(); playerHealth = GetComponent(); + passiveRelics = GetComponent(); } private void OnDisable() @@ -191,6 +198,16 @@ namespace BumpCombat.Player private void FixedUpdate() { + if (voluntaryMovementPending + && body != null + && !IsTransitionLocked + && !IsDamageKnockbackActive + && (dashController == null || !dashController.IsDashing)) + { + passiveRelics?.AddVoluntaryMovement( + Vector2.Distance(voluntaryMovementStart, body.position)); + } + voluntaryMovementPending = false; AttackIntentVelocity = Vector2.zero; if (IsTransitionLocked) { @@ -244,6 +261,8 @@ namespace BumpCombat.Player startPosition + AttackIntentVelocity * Time.fixedDeltaTime); body.MovePosition(targetPosition); + voluntaryMovementStart = startPosition; + voluntaryMovementPending = targetPosition != startPosition; CurrentVelocity = (targetPosition - startPosition) / Time.fixedDeltaTime; if (CurrentVelocity.sqrMagnitude > 0.0001f) { @@ -332,6 +351,7 @@ namespace BumpCombat.Player pendingImpactRecoil = Vector2.zero; IsDamageKnockbackActive = false; body.linearVelocity = Vector2.zero; + voluntaryMovementPending = false; body.position = ClampToArena(position); transform.position = body.position; return true; diff --git a/Assets/_Project/Scripts/Player/PlayerHealth.cs b/Assets/_Project/Scripts/Player/PlayerHealth.cs index b8e7a46..fd6f522 100644 --- a/Assets/_Project/Scripts/Player/PlayerHealth.cs +++ b/Assets/_Project/Scripts/Player/PlayerHealth.cs @@ -32,6 +32,8 @@ namespace BumpCombat.Player private float hurtMovementLockedUntil; private float guardUntil; private float guardCooldownUntil; + private float shieldAmount; + private float shieldExpiresAt; private int nextGuardActivationIdentity; private int activeGuardActivationIdentity; private bool activeGuardHasBlockedAttack; @@ -41,12 +43,14 @@ namespace BumpCombat.Player public event Action OnDamaged; /// Raised only for positive health actually lost after mitigation and lethal clamping. public event Action OnDamageTaken; + public event Action OnEnemyDamageTaken; public event Action OnGuardAttackBlocked; public event Action OnGuardImpact; public event Action OnDied; public float MaxHealth => playerStats.MaxHealth; public float CurrentHealth { get; private set; } + public float CurrentShield => Time.time < shieldExpiresAt ? shieldAmount : 0f; public static float DefaultGuardCooldown => GameplayConstants.Current.Player.GuardCooldownDuration; public static float DefaultGuardDuration => @@ -134,6 +138,10 @@ namespace BumpCombat.Player private void Update() { RefreshRunManagerSubscription(); + if (shieldAmount > 0f && Time.time >= shieldExpiresAt) + { + shieldAmount = 0f; + } } private void HandleInputSystemUpdate() @@ -173,7 +181,7 @@ namespace BumpCombat.Player return true; } - public bool TryTakeDamage(float damage, Vector2 knockbackDirection, float knockbackDistance) + public bool TryTakeDamage(float damage, Vector2 knockbackDirection, float knockbackDistance, bool fromEnemyAttack = true) { if (CurrentHealth <= 0f) { @@ -212,6 +220,18 @@ namespace BumpCombat.Player return false; } + if (CurrentShield > 0f) + { + float absorbed = Mathf.Min(shieldAmount, damageTaken); + shieldAmount -= absorbed; + damageTaken -= absorbed; + if (damageTaken <= 0f) + { + invulnerableUntil = Time.time + InvulnerabilityDuration; + return true; + } + } + float healthBefore = CurrentHealth; CurrentHealth = Mathf.Max(0f, CurrentHealth - damageTaken); @@ -232,6 +252,7 @@ namespace BumpCombat.Player if (actualDamageTaken > 0f) { OnDamageTaken?.Invoke(actualDamageTaken); + if (fromEnemyAttack) OnEnemyDamageTaken?.Invoke(actualDamageTaken); } if (CurrentHealth <= 0f) @@ -277,6 +298,8 @@ namespace BumpCombat.Player } CurrentHealth = MaxHealth; + shieldAmount = 0f; + shieldExpiresAt = 0f; invulnerableUntil = Time.time + Mathf.Max(0f, invulnerabilitySeconds); hurtMovementLockedUntil = 0f; guardUntil = 0f; @@ -289,6 +312,18 @@ namespace BumpCombat.Player return true; } + public bool GrantPassiveRelicShield(float amount, float duration) + { + if (amount <= 0f || duration <= 0f || CurrentHealth <= 0f) + { + return false; + } + + shieldAmount = amount; + shieldExpiresAt = Time.time + duration; + return true; + } + public void Heal(float amount) { if (amount <= 0f || CurrentHealth <= 0f) @@ -325,6 +360,8 @@ namespace BumpCombat.Player private void HandleRunStarted() { + shieldAmount = 0f; + shieldExpiresAt = 0f; guardUntil = 0f; guardCooldownUntil = 0f; nextGuardActivationIdentity = 0; diff --git a/Assets/_Project/Scripts/Progression/ArtifactRewardController.cs b/Assets/_Project/Scripts/Progression/ArtifactRewardController.cs index 0eb9f6d..d66f10b 100644 --- a/Assets/_Project/Scripts/Progression/ArtifactRewardController.cs +++ b/Assets/_Project/Scripts/Progression/ArtifactRewardController.cs @@ -554,11 +554,13 @@ namespace BumpCombat.Progression return; } + List 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(), }; } diff --git a/Assets/_Project/Scripts/Progression/PassiveRelicController.cs b/Assets/_Project/Scripts/Progression/PassiveRelicController.cs index f98fa29..1e06351 100644 --- a/Assets/_Project/Scripts/Progression/PassiveRelicController.cs +++ b/Assets/_Project/Scripts/Progression/PassiveRelicController.cs @@ -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 owned = new(); private readonly HashSet 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 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; @@ -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(); + 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); + 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(); + 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(); + 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 || !owned.Contains(PassiveRelicId.RuptureSeed) || target == null) + if (!active || target == null) { return; } EnemyController victim = target.GetComponent(); 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(); + 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(); + 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); + } + }} diff --git a/Assets/_Project/Tests/EditMode/DefensePrototypeRulesTests.cs b/Assets/_Project/Tests/EditMode/DefensePrototypeRulesTests.cs index bfe4aa3..06b4467 100644 --- a/Assets/_Project/Tests/EditMode/DefensePrototypeRulesTests.cs +++ b/Assets/_Project/Tests/EditMode/DefensePrototypeRulesTests.cs @@ -6,6 +6,25 @@ namespace BumpCombat.Tests { public sealed class DefensePrototypeRulesTests { + [Test] + public void TowerFamilies_PreserveLegacyIdsAndOfferSixKinds() + { + Assert.That((int)DefenseTowerKind.Direct, Is.Zero); + Assert.That((int)DefenseTowerKind.Summon, Is.EqualTo(1)); + Assert.That(System.Enum.GetValues(typeof(DefenseTowerKind)).Length, Is.EqualTo(6)); + } + [Test] + public void EachTowerFamily_HasTwoDistinctFinalBranches() + { + foreach (DefenseTowerKind kind in System.Enum.GetValues(typeof(DefenseTowerKind))) + { + string first = DefenseTower.GetBranchName(kind, DefenseTowerBranch.First); + string second = DefenseTower.GetBranchName(kind, DefenseTowerBranch.Second); + Assert.That(first, Is.Not.Empty); + Assert.That(second, Is.Not.Empty); + Assert.That(first, Is.Not.EqualTo(second)); + } + } [TestCase(6f, 1f)] [TestCase(5.999f, 1f)] [TestCase(6.001f, 0.7f)] diff --git a/Assets/_Project/Tests/EditMode/PassiveRelicRulesTests.cs b/Assets/_Project/Tests/EditMode/PassiveRelicRulesTests.cs index 84203ba..d7743f4 100644 --- a/Assets/_Project/Tests/EditMode/PassiveRelicRulesTests.cs +++ b/Assets/_Project/Tests/EditMode/PassiveRelicRulesTests.cs @@ -73,6 +73,19 @@ namespace BumpCombat.Tests } } + [TestCase(EnemyDamageSource.Player, true)] + [TestCase(EnemyDamageSource.PassiveRelicAttack, true)] + [TestCase(EnemyDamageSource.PassiveRelicExplosion, true)] + [TestCase(EnemyDamageSource.PassiveRelicTransferredIgnite, true)] + [TestCase(EnemyDamageSource.Tower, false)] + public void RewardKillCredit_ExcludesTowers( + EnemyDamageSource source, + bool expected) + { + Assert.That(PassiveRelicRules.IsPlayerRewardKill(source), + Is.EqualTo(expected)); + } + private static void SetField(ActiveArtifactDefinition definition, string fieldName, T value) { typeof(ActiveArtifactDefinition) @@ -82,32 +95,19 @@ namespace BumpCombat.Tests [Test] public void AvailableRelics_ExcludesOwnedIdsAndPreservesCatalogOrder() { - PassiveRelicId[] all = PassiveRelicRules.GetAvailableRelics( + PassiveRelicId[] catalog = PassiveRelicRules.GetAvailableRelics( System.Array.Empty()); + PassiveRelicId[] expected = (PassiveRelicId[]) + System.Enum.GetValues(typeof(PassiveRelicId)); + Assert.That(catalog, Is.EqualTo(expected)); + Assert.That(catalog.Length, Is.EqualTo(20)); + PassiveRelicId[] afterOne = PassiveRelicRules.GetAvailableRelics( new[] { PassiveRelicId.ResonanceCore }); - PassiveRelicId[] afterAll = PassiveRelicRules.GetAvailableRelics( - new[] - { - PassiveRelicId.TwinHornTalisman, - PassiveRelicId.ResonanceCore, - PassiveRelicId.RuptureSeed, - }); - - Assert.That(all, Is.EqualTo(new[] - { - PassiveRelicId.TwinHornTalisman, - PassiveRelicId.ResonanceCore, - PassiveRelicId.RuptureSeed, - })); - Assert.That(afterOne, Is.EqualTo(new[] - { - PassiveRelicId.TwinHornTalisman, - PassiveRelicId.RuptureSeed, - })); - Assert.That(afterAll, Is.Empty); + Assert.That(afterOne, Has.None.EqualTo(PassiveRelicId.ResonanceCore)); + Assert.That(afterOne.Length, Is.EqualTo(19)); + Assert.That(PassiveRelicRules.GetAvailableRelics(expected), Is.Empty); } - [Test] public void RuptureDamageAndRadiusScaleFromEnemyHealthAndPlayerBody() { @@ -116,6 +116,7 @@ namespace BumpCombat.Tests } [TestCase(EnemyDamageSource.Player, true)] + [TestCase(EnemyDamageSource.PassiveRelicAttack, true)] [TestCase(EnemyDamageSource.Tower, false)] [TestCase(EnemyDamageSource.PassiveRelicExplosion, false)] [TestCase(EnemyDamageSource.None, false)] diff --git a/Assets/_Project/Tests/PlayMode/DefensePrototypePlayModeTests.cs b/Assets/_Project/Tests/PlayMode/DefensePrototypePlayModeTests.cs index 4ea0bb7..8f53fc3 100644 --- a/Assets/_Project/Tests/PlayMode/DefensePrototypePlayModeTests.cs +++ b/Assets/_Project/Tests/PlayMode/DefensePrototypePlayModeTests.cs @@ -151,6 +151,344 @@ namespace BumpCombat.PlayModeTests } } + [UnityTest] + public IEnumerator DefensePrototype_DefaultBuildsCanDamageTheirPortalLanes() + { + yield return StartDefenseRun(); + DefensePrototypeController controller = DefensePrototypeController.Instance; + PlayerController player = UnityEngine.Object.FindAnyObjectByType(); + MovePlayerNear(player, controller.Tuning.GetBuildPadPosition(0)); + Assert.That(controller.TryBuildTowerAtPad(0), Is.True); + MovePlayerNear(player, controller.Tuning.GetBuildPadPosition(1)); + Assert.That(controller.TryBuildTowerAtPad(1), Is.True); + DefenseTower upper = FindTower(0, DefenseTowerKind.Summon); + DefenseTower lower = FindTower(1, DefenseTowerKind.Direct); + Assert.That(upper, Is.Not.Null, "Upper portal is immune to fire."); + Assert.That(lower, Is.Not.Null, "Lower portal is immune to Summon."); + + yield return WaitForRouteAgents(2); + DefenseRouteAgent upperAgent = FindAgentWithImmunity(DefenseTowerKind.Direct); + DefenseRouteAgent lowerAgent = FindAgentWithImmunity(DefenseTowerKind.Summon); + yield return WaitForAgentReady(upperAgent); + yield return WaitForAgentReady(lowerAgent); + FreezeEnemyControllers(FindRouteAgents()); + float upperHealth = upperAgent.Enemy.CurrentHealth; + float lowerHealth = lowerAgent.Enemy.CurrentHealth; + Assert.That(upper.TryAttack(upperAgent), Is.True); + Assert.That(lowerAgent.Enemy.TryReposition(lower.transform.position), Is.True); + Assert.That(lower.TryPulse(), Is.True); + Assert.That(upperAgent.Enemy.CurrentHealth, Is.LessThan(upperHealth)); + Assert.That(lowerAgent.Enemy.CurrentHealth, Is.LessThan(lowerHealth)); + } + + [UnityTest] + public IEnumerator DefensePrototype_AllSixTowersShowTheirGameplayRanges() + { + yield return StartDefenseRun(); + DefensePrototypeController controller = DefensePrototypeController.Instance; + PlayerController player = UnityEngine.Object.FindAnyObjectByType(); + Assert.That(controller.TryAwardBondLedgerCurrency(200), Is.True); + DefenseTowerKind[] kinds = + { + DefenseTowerKind.Direct, DefenseTowerKind.Summon, + DefenseTowerKind.Lightning, DefenseTowerKind.Cold, + DefenseTowerKind.Seismic, DefenseTowerKind.Empowering, + }; + for (int pad = 0; pad < kinds.Length; pad++) + { + DefenseTower tower = BuildTower(controller, player, pad, kinds[pad]); + Transform range = tower.transform.Find("Effect Range"); + Assert.That(range, Is.Not.Null, $"{kinds[pad]} range is missing."); + LineRenderer line = range.GetComponent(); + Assert.That(line, Is.Not.Null); + Assert.That(line.positionCount, Is.GreaterThan(16)); + float expected = kinds[pad] == DefenseTowerKind.Summon + ? controller.Tuning.SummonAreaRadius + : kinds[pad] == DefenseTowerKind.Empowering + ? controller.Tuning.SupportRadius : controller.Tuning.TowerRange; + Assert.That(line.GetPosition(0).x * tower.transform.lossyScale.x, + Is.EqualTo(expected).Within(0.02f)); + } + + DefenseTower fire = FindTower(0, DefenseTowerKind.Direct); + MovePlayerNear(player, fire.transform.position); + Assert.That(controller.TryUpgradeTowerAtPad(0, DefenseTowerBranch.None), Is.True); + Assert.That(controller.TryUpgradeTowerAtPad(0, DefenseTowerBranch.None), Is.True); + Assert.That(controller.TryUpgradeTowerAtPad(0, DefenseTowerBranch.Second), Is.True); + LineRenderer fireRange = fire.transform.Find("Effect Range") + .GetComponent(); + Assert.That(fireRange.GetPosition(0).x * fire.transform.lossyScale.x, + Is.EqualTo(controller.Tuning.TowerRange * 0.5f).Within(0.02f)); + } + + [UnityTest] + public IEnumerator DefensePrototype_DamageTowersShowSuccessfulAttackEffects() + { + yield return StartDefenseRun(); + DefensePrototypeController controller = DefensePrototypeController.Instance; + PlayerController player = UnityEngine.Object.FindAnyObjectByType(); + Assert.That(controller.TryAwardBondLedgerCurrency(200), Is.True); + yield return WaitForRouteAgents(2); + DefenseRouteAgent target = FindAgentWithImmunity(DefenseTowerKind.Summon); + yield return WaitForAgentReady(target); + FreezeEnemyControllers(FindRouteAgents()); + + DefenseTowerKind[] kinds = + { + DefenseTowerKind.Direct, DefenseTowerKind.Lightning, + DefenseTowerKind.Cold, DefenseTowerKind.Seismic, + }; + for (int i = 0; i < kinds.Length; i++) + { + DefenseTower tower = BuildTower(controller, player, i, kinds[i]); + Assert.That(target.Enemy.TryReposition(tower.transform.position), Is.True); + Physics2D.SyncTransforms(); + Assert.That(tower.TryPulse(), Is.True, $"{kinds[i]} should attack."); + GameObject effect = GameObject.Find($"{kinds[i]} Attack Effect"); + Assert.That(effect, Is.Not.Null, $"{kinds[i]} attack effect is missing."); + SpriteRenderer impact = effect.transform.Find("Impact Sprite") + ?.GetComponent(); + Assert.That(impact, Is.Not.Null); + Assert.That(impact.sprite, Is.Not.Null, + "The placeholder impact must be replaceable by an imported sprite."); + } + } + + [UnityTest] + public IEnumerator DefensePrototype_TowerAutomaticallyAttacksTargetInRange() + { + yield return StartDefenseRun(); + DefensePrototypeController controller = DefensePrototypeController.Instance; + PlayerController player = UnityEngine.Object.FindAnyObjectByType(); + DefenseTower fire = BuildTower(controller, player, 1, DefenseTowerKind.Direct); + yield return WaitForRouteAgents(2); + DefenseRouteAgent target = FindAgentWithImmunity(DefenseTowerKind.Summon); + yield return WaitForAgentReady(target); + FreezeEnemyControllers(FindRouteAgents()); + Assert.That(target.Enemy.TryReposition( + (Vector2)fire.transform.position + Vector2.left), Is.True); + Physics2D.SyncTransforms(); + + float health = target.Enemy.CurrentHealth; + bool effectSeen = false; + float deadline = Time.realtimeSinceStartup + 2f; + while (Time.realtimeSinceStartup < deadline + && (target.Enemy.CurrentHealth >= health || !effectSeen)) + { + yield return null; + effectSeen |= GameObject.Find("Direct Attack Effect") != null; + } + + Assert.That(target.Enemy.CurrentHealth, Is.LessThan(health), + "Tower Update must acquire and damage a nonimmune route enemy."); + Assert.That(effectSeen, Is.True, + "An automatic attack must show the same effect as a manual pulse."); + } + + [UnityTest] + public IEnumerator DefensePrototype_SummonPlaysExistingAttackSpriteOnHit() + { + yield return StartDefenseRun(); + DefensePrototypeController controller = DefensePrototypeController.Instance; + PlayerController player = UnityEngine.Object.FindAnyObjectByType(); + DefenseTower summon = BuildTower( + controller, player, 0, DefenseTowerKind.Summon); + yield return WaitForRouteAgents(2); + DefenseRouteAgent target = FindAgentWithImmunity(DefenseTowerKind.Direct); + yield return WaitForAgentReady(target); + FreezeEnemyControllers(FindRouteAgents()); + DefenseSummonUnit unit = summon.GetComponentInChildren(); + Assert.That(unit, Is.Not.Null); + Assert.That(target.Enemy.TryReposition(unit.transform.position), Is.True); + Physics2D.SyncTransforms(); + + bool attackSpriteSeen = false; + bool effectSeen = false; + float deadline = Time.realtimeSinceStartup + 2f; + while (Time.realtimeSinceStartup < deadline + && (!attackSpriteSeen || !effectSeen)) + { + yield return null; + Sprite sprite = unit.GetComponent().sprite; + attackSpriteSeen |= sprite != null + && sprite.name.Contains("BumpAttack"); + effectSeen |= GameObject.Find("Summon Attack Effect") != null; + } + Assert.That(attackSpriteSeen, Is.True, + "The summoned swordsman must play the existing attack sheet."); + Assert.That(effectSeen, Is.True, + "A successful summon hit must show its impact."); + } + + [UnityTest] + public IEnumerator DefensePrototype_AllSixFamiliesBuildAndUpgradeToDistinctFinalBranches() + { + yield return StartDefenseRun(); + DefensePrototypeController controller = DefensePrototypeController.Instance; + PlayerController player = UnityEngine.Object.FindAnyObjectByType(); + Assert.That(controller.TryAwardBondLedgerCurrency(1000), Is.True); + DefenseTowerKind[] kinds = + { + DefenseTowerKind.Direct, + DefenseTowerKind.Summon, + DefenseTowerKind.Lightning, + DefenseTowerKind.Cold, + DefenseTowerKind.Seismic, + DefenseTowerKind.Empowering, + }; + + for (int pad = 0; pad < kinds.Length; pad++) + { + DefenseTower tower = BuildTower(controller, player, pad, kinds[pad]); + Assert.That(tower.Tier, Is.EqualTo(1)); + Assert.That(controller.TryUpgradeTowerAtPad( + pad, DefenseTowerBranch.First), Is.False); + int beforeUpgrade = controller.ConstructionCurrency; + Assert.That(controller.TryUpgradeTowerAtPad( + pad, DefenseTowerBranch.None), Is.True); + Assert.That(tower.Tier, Is.EqualTo(2)); + Assert.That(controller.ConstructionCurrency, + Is.EqualTo(beforeUpgrade - controller.Tuning.Tier2UpgradeCost)); + Assert.That(controller.TryUpgradeTowerAtPad( + pad, DefenseTowerBranch.None), Is.True); + Assert.That(tower.Tier, Is.EqualTo(3)); + Assert.That(controller.TryUpgradeTowerAtPad( + pad, DefenseTowerBranch.None), Is.False); + DefenseTowerBranch branch = pad % 2 == 0 + ? DefenseTowerBranch.First : DefenseTowerBranch.Second; + Assert.That(controller.TryUpgradeTowerAtPad(pad, branch), Is.True); + Assert.That(tower.Tier, Is.EqualTo(4)); + Assert.That(tower.Branch, Is.EqualTo(branch)); + Assert.That(DefenseTower.GetBranchName(kinds[pad], branch), + Is.Not.Empty); + Assert.That(controller.TryUpgradeTowerAtPad( + pad, DefenseTowerBranch.First), Is.False); + tower.enabled = false; + } + + Assert.That(FindTowers().Length, Is.EqualTo(6)); + } + [UnityTest] + public IEnumerator DefensePrototype_SentinelAndScoutHaveDifferentUnitCounts() + { + yield return StartDefenseRun(); + DefensePrototypeController controller = DefensePrototypeController.Instance; + PlayerController player = UnityEngine.Object.FindAnyObjectByType(); + Assert.That(controller.TryAwardBondLedgerCurrency(500), Is.True); + DefenseTower sentinel = BuildTower(controller, player, 0, + DefenseTowerKind.Summon); + DefenseTower scout = BuildTower(controller, player, 1, + DefenseTowerKind.Summon); + + Assert.That(player.TryReposition(sentinel.transform.position), Is.True); + Assert.That(controller.TryUpgradeTowerAtPad(0, DefenseTowerBranch.None), Is.True); + Assert.That(controller.TryUpgradeTowerAtPad(0, DefenseTowerBranch.None), Is.True); + Assert.That(controller.TryUpgradeTowerAtPad(0, DefenseTowerBranch.First), Is.True); + Assert.That(player.TryReposition(scout.transform.position), Is.True); + Assert.That(controller.TryUpgradeTowerAtPad(1, DefenseTowerBranch.None), Is.True); + Assert.That(controller.TryUpgradeTowerAtPad(1, DefenseTowerBranch.None), Is.True); + Assert.That(controller.TryUpgradeTowerAtPad(1, DefenseTowerBranch.Second), Is.True); + yield return null; + + Assert.That(sentinel.GetComponentsInChildren().Length, + Is.EqualTo(1)); + Assert.That(scout.GetComponentsInChildren().Length, + Is.EqualTo(6)); + } + [UnityTest] + public IEnumerator DefensePrototype_SmotheringBoostsTowerDamageInsideSupportAura() + { + yield return StartDefenseRun(); + DefensePrototypeController controller = DefensePrototypeController.Instance; + PlayerController player = UnityEngine.Object.FindAnyObjectByType(); + Assert.That(controller.TryAwardBondLedgerCurrency(200), Is.True); + yield return WaitForRouteAgents(2); + DefenseRouteAgent target = FindAgentWithImmunity(DefenseTowerKind.Summon); + Assert.That(target, Is.Not.Null); + yield return WaitForAgentReady(target); + FreezeEnemyControllers(FindRouteAgents()); + + DefenseTower fire = BuildTower(controller, player, 0, DefenseTowerKind.Direct); + DefenseTower support = BuildTower(controller, player, 4, + DefenseTowerKind.Empowering); + Assert.That(controller.TryUpgradeTowerAtPad(4, DefenseTowerBranch.None), Is.True); + Assert.That(controller.TryUpgradeTowerAtPad(4, DefenseTowerBranch.None), Is.True); + Assert.That(controller.TryUpgradeTowerAtPad(4, DefenseTowerBranch.Second), Is.True); + Assert.That(target.Enemy.TryReposition(support.transform.position), Is.True); + + float health = target.Enemy.CurrentHealth; + Assert.That(controller.TryApplyTowerDamage(fire, target, 10f), Is.True); + float expected = BumpCombat.Combat.DamageCalculator.FinalizeDamage( + 10f * (1f + controller.Tuning.SupportDamageBonusPerTier * 4) + * (1f + controller.Tuning.SmotheringDamageBonus)); + Assert.That(health - target.Enemy.CurrentHealth, + Is.EqualTo(expected).Within(0.01f)); + } + [UnityTest] + public IEnumerator DefensePrototype_EnemyDeathAwardsConstructionCurrencyOnceArrivalDoesNot() + { + yield return StartDefenseRun(); + DefensePrototypeController controller = DefensePrototypeController.Instance; + PlayerController player = UnityEngine.Object.FindAnyObjectByType(); + DefenseTower fire = BuildTower(controller, player, 0, DefenseTowerKind.Direct); + fire.enabled = false; + MovePlayerNear(player, Vector2.zero); + yield return WaitForRouteAgents(2); + DefenseRouteAgent killTarget = FindAgentWithImmunity(DefenseTowerKind.Summon); + DefenseRouteAgent arrivalTarget = FindAgentWithImmunity(DefenseTowerKind.Direct); + Assert.That(killTarget, Is.Not.Null); + Assert.That(arrivalTarget, Is.Not.Null); + yield return WaitForAgentReady(killTarget); + yield return WaitForAgentReady(arrivalTarget); + FreezeEnemyControllers(FindRouteAgents()); + + int currency = controller.ConstructionCurrency; + Assert.That(controller.TryApplyTowerDamage(fire, killTarget, 10000f), Is.True); + Assert.That(killTarget.Enemy.IsDead, Is.True); + Assert.That(controller.ConstructionCurrency, + Is.EqualTo(currency + controller.Tuning.EnemyKillCurrency)); + Assert.That(controller.TryApplyTowerDamage(fire, killTarget, 10000f), Is.False); + Assert.That(controller.ConstructionCurrency, + Is.EqualTo(currency + controller.Tuning.EnemyKillCurrency)); + + DriveAgentToArrival(arrivalTarget); + Assert.That(controller.ConstructionCurrency, + Is.EqualTo(currency + controller.Tuning.EnemyKillCurrency)); + } + [UnityTest] + public IEnumerator DefensePrototype_ColdSeismicAndImbuingHaveCombatEffects() + { + yield return StartDefenseRun(); + DefensePrototypeController controller = DefensePrototypeController.Instance; + PlayerController player = UnityEngine.Object.FindAnyObjectByType(); + Assert.That(controller.TryAwardBondLedgerCurrency(500), Is.True); + yield return WaitForRouteAgents(2); + DefenseRouteAgent target = FindAgentWithImmunity(DefenseTowerKind.Direct); + Assert.That(target, Is.Not.Null); + yield return WaitForAgentReady(target); + FreezeEnemyControllers(FindRouteAgents()); + + DefenseTower cold = BuildTower(controller, player, 2, DefenseTowerKind.Cold); + DefenseTower support = BuildTower(controller, player, 5, + DefenseTowerKind.Empowering); + Assert.That(target.Enemy.TryReposition(cold.transform.position), Is.True); + float health = target.Enemy.CurrentHealth; + Assert.That(cold.TryPulse(), Is.True); + Assert.That(target.Enemy.CurrentHealth, Is.LessThan(health)); + Assert.That(target.Enemy.MovementSpeedMultiplier, Is.LessThan(1f)); + + Assert.That(controller.TryUpgradeTowerAtPad(5, DefenseTowerBranch.None), Is.True); + Assert.That(controller.TryUpgradeTowerAtPad(5, DefenseTowerBranch.None), Is.True); + Assert.That(controller.TryUpgradeTowerAtPad(5, DefenseTowerBranch.First), Is.True); + Assert.That(controller.GetTowerAttackIntervalMultiplier(cold), + Is.EqualTo(controller.Tuning.ImbuingAttackIntervalMultiplier)); + + DefenseTower seismic = BuildTower(controller, player, 4, + DefenseTowerKind.Seismic); + Assert.That(target.Enemy.TryReposition(seismic.transform.position), Is.True); + Assert.That(seismic.TryPulse(), Is.True); + Assert.That(target.Enemy.IsStunned, Is.True); + } [UnityTest] public IEnumerator DefensePrototype_TowerDamageUsesSightEfficiencyAndPortalImmunity() { @@ -452,15 +790,14 @@ namespace BumpCombat.PlayModeTests Assert.That(relicReward, Is.Not.Null); Assert.That(relicReward.DebugIsSelectionVisible, Is.True, "Collecting a cleared portal reward must open its passive relic choice."); - Assert.That(relicReward.DebugShownCount, Is.EqualTo(3), - "The first portal must offer all three phase-five relics."); + Assert.That(relicReward.DebugShownPassiveRelicCount, Is.EqualTo(3), + "The first portal must offer three eligible relics from the current catalog."); Assert.That(relicReward.DebugChooseOption(0), Is.True); yield return null; Assert.That( - player.GetComponent().Owns( - PassiveRelicId.TwinHornTalisman), - Is.True, - "Choosing the first portal option must acquire the shown passive relic."); + player.GetComponent().OwnedCount, + Is.EqualTo(1), + "Choosing the first portal option must acquire one passive relic."); Assert.That(RunManager.Instance.IsSelectionOpen, Is.False); Assert.That(RunManager.Instance.IsRunCleared, Is.False, "Collecting one portal reward must not finish the two-portal run."); @@ -473,10 +810,12 @@ namespace BumpCombat.PlayModeTests + controller.Tuning.PortalRewardCurrency * 2)); Assert.That(relicReward.DebugIsSelectionVisible, Is.True, "The second cleared portal must offer another passive relic choice."); - Assert.That(relicReward.DebugShownCount, Is.EqualTo(2), - "A relic already chosen at the first portal must not appear again."); + Assert.That(relicReward.DebugShownPassiveRelicCount, Is.EqualTo(3), + "The expanded catalog still offers three candidates at the second portal."); Assert.That(relicReward.DebugChooseOption(0), Is.True); yield return null; + Assert.That(player.GetComponent().OwnedCount, + Is.EqualTo(2)); Assert.That(RunManager.Instance.IsRunCleared, Is.True, "The run clears only after both portal relic choices are resolved."); } @@ -1022,6 +1361,419 @@ namespace BumpCombat.PlayModeTests "A Thunder Crash echo must not grant its manual invulnerability."); } + [UnityTest] + public IEnumerator WaveNail_UsesBaseBumpDamageAndDamagesEachEnemyOnce() + { + yield return PreparePassiveRelicFixture(null); + Assert.That(passiveRelicController.TryAcquire(PassiveRelicId.WaveNail), Is.True); + Vector2 origin = (Vector2)passiveRelicPlayer.transform.position + + new Vector2(4f, 4f); + EnemyController original = CreateRelicTestEnemy(origin, ArtifactColor.Red); + EnemyController nearby = CreateRelicTestEnemy(origin + Vector2.right, ArtifactColor.Red); + yield return null; + original.enabled = false; + nearby.enabled = false; + GameObject duplicateCollider = new("Wave duplicate collider"); + duplicateCollider.transform.SetParent(nearby.transform, false); + duplicateCollider.AddComponent().radius = 0.25f; + Physics2D.SyncTransforms(); + + float before = nearby.CurrentHealth; + CombatHitResult hit = new( + passiveRelicPlayer.gameObject, + original.gameObject, + false, + HitSide.Front, + 20f, + Vector2.right, + 0f, + origin, + baseBumpDamage: 10f); + CombatEvents.RaiseValidHit(hit); + float expected = DamageCalculator.FinalizeDamage( + 10f * GameplayConstants.Current.PassiveRelics.WaveNailDamageFraction); + Assert.That(nearby.CurrentHealth, Is.EqualTo(before - expected).Within(0.01f)); + CombatEvents.RaiseValidHit(hit); + Assert.That(nearby.CurrentHealth, Is.EqualTo(before - expected).Within(0.01f), + "One relic cooldown must cover the next valid hit."); + } + + [UnityTest] + public IEnumerator CapacitorRing_ExcludesOriginalAndLimitsChainToThree() + { + yield return PreparePassiveRelicFixture(null); + Assert.That(passiveRelicController.TryAcquire(PassiveRelicId.CapacitorRing), Is.True); + Vector2 origin = (Vector2)passiveRelicPlayer.transform.position + + new Vector2(4f, 4f); + EnemyController original = CreateRelicTestEnemy(origin, ArtifactColor.Red); + EnemyController[] neighbors = new EnemyController[4]; + for (int i = 0; i < neighbors.Length; i++) + { + neighbors[i] = CreateRelicTestEnemy( + origin + Vector2.right * (0.5f + i * 0.5f), + ArtifactColor.Red); + } + yield return null; + original.enabled = false; + foreach (EnemyController neighbor in neighbors) neighbor.enabled = false; + Physics2D.SyncTransforms(); + + float originalBefore = original.CurrentHealth; + float[] before = System.Array.ConvertAll(neighbors, enemy => enemy.CurrentHealth); + CombatEvents.RaiseValidHit(new CombatHitResult( + passiveRelicPlayer.gameObject, + original.gameObject, + false, + HitSide.Front, + 10f, + Vector2.right, + 0f, + origin, + baseBumpDamage: 10f)); + Assert.That(original.CurrentHealth, Is.EqualTo(originalBefore)); + float expected = DamageCalculator.FinalizeDamage( + 10f * GameplayConstants.Current.PassiveRelics.CapacitorRingDamageFraction); + for (int i = 0; i < neighbors.Length; i++) + { + Assert.That(neighbors[i].CurrentHealth, + Is.EqualTo(before[i] - (i < 3 ? expected : 0f)).Within(0.01f)); + } + } + + [UnityTest] + public IEnumerator EmberWick_AppliesExistingPhoenixIgnite() + { + yield return PreparePassiveRelicFixture(null); + Assert.That(passiveRelicController.TryAcquire(PassiveRelicId.EmberWick), Is.True); + Vector2 origin = (Vector2)passiveRelicPlayer.transform.position + + new Vector2(4f, 4f); + EnemyController target = CreateRelicTestEnemy(origin, ArtifactColor.Red); + yield return null; + target.enabled = false; + CombatEvents.RaiseValidHit(new CombatHitResult( + passiveRelicPlayer.gameObject, + target.gameObject, + false, + HitSide.Front, + 10f, + Vector2.right, + 0f, + origin, + baseBumpDamage: 10f)); + Assert.That(target.IsIgnited, Is.True); + ActiveArtifactDefinition phoenix = null; + for (int i = 0; i < passiveRelicArtifacts.CatalogCount; i++) + { + ActiveArtifactDefinition definition = passiveRelicArtifacts.GetCatalogArtifactAt(i); + if (definition != null && definition.Effect == ActiveArtifactEffect.Phoenix) + { + phoenix = definition; + break; + } + } + Assert.That(phoenix, Is.Not.Null); + Assert.That(target.IgniteRemaining, + Is.EqualTo(phoenix.NormalIgniteDuration).Within(0.01f)); + } + + [UnityTest] + public IEnumerator RuptureSeed_TriggersFromDirectRelicAttackKill() + { + yield return PreparePassiveRelicFixture(null); + Vector2 origin = (Vector2)passiveRelicPlayer.transform.position + new Vector2(4f, 4f); + EnemyController victim = CreateRelicTestEnemy(origin, ArtifactColor.Red); + EnemyController neighbor = CreateRelicTestEnemy(origin + Vector2.right, ArtifactColor.Red); + yield return null; + victim.enabled = false; + neighbor.enabled = false; + float before = neighbor.CurrentHealth; + Assert.That(victim.TryTakeDamage(victim.CurrentHealth + 1f, Vector2.zero, + 0f, false, false, EnemyDamageSource.PassiveRelicAttack), Is.True); + Assert.That(neighbor.CurrentHealth, Is.LessThan(before)); + } + [UnityTest] + public IEnumerator EmberCarrier_TransfersRemainingIgniteOnCreditedKill() + { + yield return PreparePassiveRelicFixture(ActiveArtifactEffect.Phoenix); + Assert.That(passiveRelicController.TryAcquire(PassiveRelicId.EmberCarrier), Is.True); + Vector2 origin = (Vector2)passiveRelicPlayer.transform.position + new Vector2(4f, 4f); + EnemyController victim = CreateRelicTestEnemy(origin, ArtifactColor.Red); + EnemyController neighbor = CreateRelicTestEnemy(origin + Vector2.right, ArtifactColor.Red); + yield return null; + victim.enabled = false; + neighbor.enabled = false; + Assert.That(victim.ApplyIgnite(3f, 1f, 2f), Is.True); + Assert.That(victim.TryTakeDamage(victim.CurrentHealth + 1f, Vector2.zero, 0f, + false, false, EnemyDamageSource.Player), Is.True); + Assert.That(neighbor.IsIgnited, Is.True); + Assert.That(neighbor.IgniteRemaining, Is.GreaterThan(0f)); + } + + [UnityTest] + public IEnumerator RiftWedge_ThreeOrdinaryHitsApplyExistingVulnerability() + { + yield return PreparePassiveRelicFixture(null); + Assert.That(passiveRelicController.TryAcquire(PassiveRelicId.RiftWedge), Is.True); + Vector2 origin = (Vector2)passiveRelicPlayer.transform.position + new Vector2(4f, 4f); + EnemyController target = CreateRelicTestEnemy(origin, ArtifactColor.Red); + yield return null; + target.enabled = false; + CombatHitResult hit = new(passiveRelicPlayer.gameObject, target.gameObject, + false, HitSide.Front, 1f, Vector2.right, 0f, origin, baseBumpDamage: 1f); + CombatEvents.RaiseValidHit(hit); + CombatEvents.RaiseValidHit(hit); + Assert.That(target.IsBumpVulnerable, Is.False); + CombatEvents.RaiseValidHit(hit); + Assert.That(target.IsBumpVulnerable, Is.True); + } + + [UnityTest] + public IEnumerator ExecutionerSigil_FinishesLowHealthNonBossAfterOrdinaryHit() + { + yield return PreparePassiveRelicFixture(null); + Assert.That(passiveRelicController.TryAcquire(PassiveRelicId.ExecutionerSigil), Is.True); + Vector2 origin = (Vector2)passiveRelicPlayer.transform.position + new Vector2(4f, 4f); + EnemyController target = CreateRelicTestEnemy(origin, ArtifactColor.Red); + yield return null; + target.enabled = false; + SetEnemyCurrentHealth(target, target.MaximumHealth * 0.09f); + CombatEvents.RaiseValidHit(new CombatHitResult(passiveRelicPlayer.gameObject, + target.gameObject, false, HitSide.Front, 1f, Vector2.right, 0f, + origin, baseBumpDamage: 1f)); + Assert.That(target.IsDead, Is.True); + } + + [UnityTest] + public IEnumerator BackflowHeart_GrantsShieldAfterActualHpLossAndAbsorbsNextHit() + { + yield return PreparePassiveRelicFixture(null); + Assert.That(passiveRelicController.TryAcquire(PassiveRelicId.BackflowHeart), Is.True); + PlayerHealth health = passiveRelicPlayer.GetComponent(); + SetTestField(health, "invulnerableUntil", Time.time - 1f); + Assert.That(health.TryTakeDamage(health.MaxHealth * 0.3f, + Vector2.right, 0f, fromEnemyAttack: false), Is.True); + Assert.That(health.CurrentShield, Is.Zero); + SetTestField(health, "invulnerableUntil", Time.time - 1f); + Assert.That(health.TryTakeDamage(health.MaxHealth * 0.4f, + Vector2.right, 0f), Is.True); + Assert.That(health.CurrentShield, Is.GreaterThan(0f)); + float hpAfter = health.CurrentHealth; + SetTestField(health, "invulnerableUntil", Time.time - 1f); + Assert.That(health.TryTakeDamage(1f, Vector2.right, 0f), Is.True); + Assert.That(health.CurrentHealth, Is.EqualTo(hpAfter)); + } + + [UnityTest] + public IEnumerator CounterBell_SlowsNearbyEnemyAfterActualHpLoss() + { + yield return PreparePassiveRelicFixture(null); + Assert.That(passiveRelicController.TryAcquire(PassiveRelicId.CounterBell), Is.True); + EnemyController target = CreateRelicTestEnemy( + (Vector2)passiveRelicPlayer.transform.position + Vector2.right, + ArtifactColor.Red); + yield return null; + target.enabled = false; + PlayerHealth health = passiveRelicPlayer.GetComponent(); + SetTestField(health, "invulnerableUntil", Time.time - 1f); + Assert.That(health.TryTakeDamage(10f, Vector2.right, 0f), Is.True); + Assert.That(target.MovementSpeedMultiplier, Is.EqualTo(0.7f).Within(0.001f)); + } + + [UnityTest] + public IEnumerator SprintWheel_ConsumesOneChargeOnNextOrdinaryBump() + { + yield return PreparePassiveRelicFixture(null); + Assert.That(passiveRelicController.TryAcquire(PassiveRelicId.SprintWheel), Is.True); + Vector2 origin = (Vector2)passiveRelicPlayer.transform.position + new Vector2(4f, 4f); + EnemyController original = CreateRelicTestEnemy(origin, ArtifactColor.Red); + EnemyController neighbor = CreateRelicTestEnemy(origin + Vector2.right, ArtifactColor.Red); + yield return null; + original.enabled = false; + neighbor.enabled = false; + Collider2D playerCollider = passiveRelicPlayer.GetComponent(); + float diameter = Mathf.Max(playerCollider.bounds.size.x, playerCollider.bounds.size.y); + passiveRelicController.AddVoluntaryMovement(diameter * + GameplayConstants.Current.PassiveRelics.SprintWheelRequiredBodyDiameters); + CombatHitResult hit = new(passiveRelicPlayer.gameObject, original.gameObject, + false, HitSide.Front, 10f, Vector2.right, 0f, origin, baseBumpDamage: 10f); + float before = neighbor.CurrentHealth; + CombatEvents.RaiseValidHit(hit); + Assert.That(neighbor.CurrentHealth, Is.LessThan(before)); + float after = neighbor.CurrentHealth; + CombatEvents.RaiseValidHit(hit); + Assert.That(neighbor.CurrentHealth, Is.EqualTo(after)); + } + [UnityTest] + public IEnumerator ConditionalRelics_RequireIgniteAndOwnedDamagingArtifact() + { + yield return PreparePassiveRelicFixture(null); + List owned = typeof(ActiveArtifactController) + .GetField("ownedArtifacts", BindingFlags.Instance | BindingFlags.NonPublic) + ?.GetValue(passiveRelicArtifacts) as List; + Assert.That(owned, Is.Not.Null); + owned.Clear(); + Assert.That(passiveRelicController.GetAvailableRelics(), + Has.None.EqualTo(PassiveRelicId.EmberCarrier)); + Assert.That(passiveRelicController.GetAvailableRelics(), + Has.None.EqualTo(PassiveRelicId.ResonantCrystal)); + Assert.That(passiveRelicController.TryAcquire(PassiveRelicId.EmberWick), Is.True); + Assert.That(passiveRelicController.GetAvailableRelics(), + Has.Some.EqualTo(PassiveRelicId.EmberCarrier)); + ActiveArtifactDefinition phoenix = null; + for (int i = 0; i < passiveRelicArtifacts.CatalogCount; i++) + { + ActiveArtifactDefinition definition = passiveRelicArtifacts.GetCatalogArtifactAt(i); + if (definition != null && definition.Effect == ActiveArtifactEffect.Phoenix) + { + phoenix = definition; + break; + } + } + Assert.That(phoenix, Is.Not.Null); + owned.Add(phoenix); + Assert.That(passiveRelicController.GetAvailableRelics(), + Has.Some.EqualTo(PassiveRelicId.ResonantCrystal)); + } + [UnityTest] + public IEnumerator HunterFeather_BoostsMoveSpeedAfterPlayerKill() + { + yield return PreparePassiveRelicFixture(null); + Assert.That(passiveRelicController.TryAcquire(PassiveRelicId.HunterFeather), Is.True); + PlayerStats stats = passiveRelicPlayer.GetComponent(); + float before = stats.MoveSpeed; + EnemyController victim = CreateRelicTestEnemy( + (Vector2)passiveRelicPlayer.transform.position + new Vector2(4f, 4f), + ArtifactColor.Red); + yield return null; + victim.enabled = false; + Assert.That(victim.TryTakeDamage(victim.CurrentHealth + 1f, Vector2.zero, + 0f, false, false, EnemyDamageSource.Player), Is.True); + Assert.That(stats.MoveSpeed, Is.GreaterThan(before)); + } + [UnityTest] + public IEnumerator BloodFang_HealsOnlyAfterValidHit() + { + yield return PreparePassiveRelicFixture(null); + Assert.That(passiveRelicController.TryAcquire(PassiveRelicId.BloodFang), Is.True); + PlayerHealth health = passiveRelicPlayer.GetComponent(); + SetTestField(health, "k__BackingField", health.MaxHealth - 20f); + Vector2 origin = (Vector2)passiveRelicPlayer.transform.position + new Vector2(4f, 4f); + EnemyController target = CreateRelicTestEnemy(origin, ArtifactColor.Red); + yield return null; + target.enabled = false; + float before = health.CurrentHealth; + CombatEvents.RaiseValidHit(new CombatHitResult(passiveRelicPlayer.gameObject, + target.gameObject, true, HitSide.Front, 1f, Vector2.right, 0f, + origin, baseBumpDamage: 1f)); + Assert.That(health.CurrentHealth, Is.GreaterThan(before)); + } + + [UnityTest] + public IEnumerator RecoveryFunnel_RestoresGaugeFromPlayerCreditedKill() + { + yield return PreparePassiveRelicFixture(null); + Assert.That(passiveRelicController.TryAcquire(PassiveRelicId.RecoveryFunnel), Is.True); + SetTestField(passiveRelicArtifacts, "k__BackingField", 0f); + EnemyController victim = CreateRelicTestEnemy( + (Vector2)passiveRelicPlayer.transform.position + new Vector2(4f, 4f), + ArtifactColor.Red); + yield return null; + victim.enabled = false; + Assert.That(victim.TryTakeDamage(victim.CurrentHealth + 1f, Vector2.zero, + 0f, false, false, EnemyDamageSource.Player), Is.True); + Assert.That(passiveRelicArtifacts.CurrentGauge, Is.GreaterThan(0f)); + } + + [UnityTest] + public IEnumerator PilgrimPouch_CreatesAtMostOneHealingOrb() + { + yield return PreparePassiveRelicFixture(null); + Assert.That(passiveRelicController.TryAcquire(PassiveRelicId.PilgrimPouch), Is.True); + Vector2 origin = (Vector2)passiveRelicPlayer.transform.position + new Vector2(4f, 4f); + EnemyController first = CreateRelicTestEnemy(origin, ArtifactColor.Red); + EnemyController second = CreateRelicTestEnemy(origin + Vector2.right * 5f, + ArtifactColor.Red); + yield return null; + first.enabled = false; + second.enabled = false; + Assert.That(first.TryTakeDamage(first.CurrentHealth + 1f, Vector2.zero, + 0f, false, false, EnemyDamageSource.Player), Is.True); + Assert.That(UnityEngine.Object.FindObjectsByType( + FindObjectsInactive.Exclude, FindObjectsSortMode.None).Length, Is.EqualTo(1)); + Assert.That(second.TryTakeDamage(second.CurrentHealth + 1f, Vector2.zero, + 0f, false, false, EnemyDamageSource.Player), Is.True); + Assert.That(UnityEngine.Object.FindObjectsByType( + FindObjectsInactive.Exclude, FindObjectsSortMode.None).Length, Is.EqualTo(1)); + PlayerHealth health = passiveRelicPlayer.GetComponent(); + SetTestField(health, "k__BackingField", health.MaxHealth - 20f); + float hpBefore = health.CurrentHealth; + Assert.That(passiveRelicPlayer.TryReposition(origin), Is.True); + yield return null; + Assert.That(health.CurrentHealth, Is.GreaterThan(hpBefore)); + } + + [UnityTest] + public IEnumerator BattleBaton_BoostsVisibleDamageTowersNotSummons() + { + yield return StartDefenseRun(); + DefensePrototypeController controller = DefensePrototypeController.Instance; + PlayerController player = UnityEngine.Object.FindAnyObjectByType(); + Assert.That(controller.TryAwardBondLedgerCurrency(100), Is.True); + DefenseTower direct = BuildTower(controller, player, 0, DefenseTowerKind.Direct); + DefenseTower summon = BuildTower(controller, player, 1, DefenseTowerKind.Summon); + DefenseTower lightning = BuildTower(controller, player, 4, + DefenseTowerKind.Lightning); + Assert.That(player.TryReposition(summon.transform.position), Is.True); + Assert.That(controller.TryBoostVisibleTowers(3f), Is.False, + "Only a summon tower is currently within sight."); + Assert.That(player.TryReposition(direct.transform.position), Is.True); + Assert.That(controller.TryBoostVisibleTowers(3f), Is.True); + Dictionary boosted = typeof(DefensePrototypeController) + .GetField("boostedTowers", BindingFlags.Instance | BindingFlags.NonPublic) + ?.GetValue(controller) as Dictionary; + Assert.That(boosted, Is.Not.Null); + Assert.That(boosted.ContainsKey(direct), Is.True); + Assert.That(boosted.ContainsKey(lightning), Is.True); + Assert.That(boosted.ContainsKey(summon), Is.False); + } + [UnityTest] + public IEnumerator BondLedger_AwardsCurrencyForRecentlyBumpedTowerKill() + { + yield return StartDefenseRun(); + passiveRelicPlayer = UnityEngine.Object.FindAnyObjectByType(); + passiveRelicController = passiveRelicPlayer.GetComponent(); + Assert.That(passiveRelicController.TryAcquire(PassiveRelicId.BondLedger), Is.True); +#if UNITY_EDITOR + PassiveRelicController.RandomRollForTests = () => 0f; +#endif + SpawnDirector director = UnityEngine.Object.FindAnyObjectByType(); + director.enabled = false; + EnemyController[] prefabs = typeof(SpawnDirector).GetField("enemyPrefabs", + BindingFlags.Instance | BindingFlags.NonPublic)?.GetValue(director) as EnemyController[]; + Assert.That(prefabs, Is.Not.Null); + foreach (EnemyController prefab in prefabs) + { + if (prefab != null && prefab.Definition != null + && prefab.Definition.Kind == EnemyKind.Skeleton) + { + passiveRelicEnemyPrefab = prefab; + break; + } + } + Assert.That(passiveRelicEnemyPrefab, Is.Not.Null); + Vector2 origin = (Vector2)passiveRelicPlayer.transform.position + new Vector2(4f, 4f); + EnemyController victim = CreateRelicTestEnemy(origin, ArtifactColor.Red); + yield return null; + victim.enabled = false; + CombatEvents.RaiseValidHit(new CombatHitResult(passiveRelicPlayer.gameObject, + victim.gameObject, false, HitSide.Front, 1f, Vector2.right, 0f, + origin, baseBumpDamage: 1f)); + int before = DefensePrototypeController.Instance.ConstructionCurrency; + Assert.That(victim.TryTakeDamage(victim.CurrentHealth + 1f, Vector2.zero, + 0f, false, false, EnemyDamageSource.Tower), Is.True); + Assert.That(DefensePrototypeController.Instance.ConstructionCurrency, + Is.EqualTo(before + 1)); + } private PassiveRelicController passiveRelicController; private ActiveArtifactController passiveRelicArtifacts; private PlayerController passiveRelicPlayer;