Implement passive relics and six-family defense towers
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 =>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<EnemyController, DefenseRouteAgent> 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<DefenseTower, float> 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<DefenseTower>();
|
||||
@@ -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(
|
||||
|| (!ignoreTowerImmunity
|
||||
&& DefensePrototypeRules.IsTowerImmune(
|
||||
sourceKind,
|
||||
agent.ImmuneTowerKind))
|
||||
agent.ImmuneTowerKind)))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -382,6 +487,133 @@ namespace BumpCombat.Defense
|
||||
return nearest;
|
||||
}
|
||||
|
||||
public List<DefenseRouteAgent> GetTargetsInRange(Vector2 position, float radius)
|
||||
{
|
||||
List<DefenseRouteAgent> 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<Text>();
|
||||
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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<SpriteRenderer>();
|
||||
Sprite[] frames = Resources.LoadAll<Sprite>("Tutorial/Swordsman_Idle");
|
||||
if (frames != null && frames.Length > 0)
|
||||
idleFrames ??= Resources.LoadAll<Sprite>("Tutorial/Swordsman_Idle");
|
||||
attackFrames ??= Resources.LoadAll<Sprite>("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))
|
||||
PlayAttackSprite();
|
||||
owner.ShowSummonAttackEffect(transform.position, targetPosition);
|
||||
attackCooldown = controller.Tuning.SummonAttackInterval
|
||||
/ (1f + 0.15f * (owner.Tier - 1))
|
||||
* controller.GetTowerAttackIntervalMultiplier(owner);
|
||||
}
|
||||
}
|
||||
|
||||
private void PlayAttackSprite()
|
||||
{
|
||||
attackCooldown = controller.Tuning.SummonAttackInterval;
|
||||
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];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
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<DefenseSummonUnit>();
|
||||
unit.Configure(this, controller, unitObject.transform.localPosition);
|
||||
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)
|
||||
{
|
||||
foreach (DefenseSummonUnit unit in GetComponentsInChildren<DefenseSummonUnit>())
|
||||
{
|
||||
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<LineRenderer>();
|
||||
effectRange.useWorldSpace = false;
|
||||
effectRange.loop = true;
|
||||
effectRange.positionCount = 48;
|
||||
effectRange.sharedMaterial = GetEffectMaterial();
|
||||
effectRange.sortingOrder = 3;
|
||||
Color color = GetComponent<SpriteRenderer>().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<SpriteRenderer>().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<SpriteRenderer>();
|
||||
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<LineRenderer>();
|
||||
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<DefenseSummonUnit>();
|
||||
unit.Configure(this, owner, unitObject.transform.localPosition);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,9 +485,15 @@ namespace BumpCombat.Defense
|
||||
renderer = gameObject.AddComponent<SpriteRenderer>();
|
||||
}
|
||||
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<TextMesh>();
|
||||
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<MeshRenderer>().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",
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -6,5 +6,7 @@ namespace BumpCombat.Enemies
|
||||
Player,
|
||||
Tower,
|
||||
PassiveRelicExplosion,
|
||||
PassiveRelicAttack,
|
||||
PassiveRelicTransferredIgnite,
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<DashController>();
|
||||
activeArtifactController = GetComponent<ActiveArtifactController>();
|
||||
playerHealth = GetComponent<PlayerHealth>();
|
||||
passiveRelics = GetComponent<PassiveRelicController>();
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
@@ -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<Vector2, float> OnDamaged;
|
||||
/// <summary>Raised only for positive health actually lost after mitigation and lethal clamping.</summary>
|
||||
public event Action<float> OnDamageTaken;
|
||||
public event Action<float> OnEnemyDamageTaken;
|
||||
public event Action<int> OnGuardAttackBlocked;
|
||||
public event Action<Vector2> 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;
|
||||
|
||||
@@ -554,11 +554,13 @@ namespace BumpCombat.Progression
|
||||
return;
|
||||
}
|
||||
|
||||
List<PassiveRelicId> candidates = new(available);
|
||||
for (int i = 0; i < shownCount; i++)
|
||||
{
|
||||
shownRelics[i] = available[i];
|
||||
int choiceIndex = Random.Range(0, candidates.Count);
|
||||
shownRelics[i] = candidates[choiceIndex];
|
||||
candidates.RemoveAt(choiceIndex);
|
||||
}
|
||||
|
||||
showingPassiveRelicReward = true;
|
||||
selectedIndex = 0;
|
||||
titleText.text = "PASSIVE RELIC - CHOOSE ONE";
|
||||
@@ -572,6 +574,23 @@ namespace BumpCombat.Progression
|
||||
PassiveRelicId.TwinHornTalisman => "01 TWIN HORN TALISMAN\n15% chance: ordinary bump deals 2x damage",
|
||||
PassiveRelicId.ResonanceCore => "02 RESONANCE CORE\n8% chance: echo current artifact at 50% damage",
|
||||
PassiveRelicId.RuptureSeed => "03 RUPTURE SEED\n25% chance: player kill explodes for 10% max HP",
|
||||
PassiveRelicId.WaveNail => "04 WAVE NAIL\n20% hit: 60% bump shockwave",
|
||||
PassiveRelicId.CapacitorRing => "05 CAPACITOR RING\n20% hit: chain to 3 for 35% bump",
|
||||
PassiveRelicId.EmberWick => "06 EMBER WICK\n25% hit: ignite",
|
||||
PassiveRelicId.EmberCarrier => "07 EMBER CARRIER\n30% ignited kill: spread remaining ignite",
|
||||
PassiveRelicId.RiftWedge => "08 RIFT WEDGE\n3 bumps on one enemy: vulnerability",
|
||||
PassiveRelicId.ExecutionerSigil => "09 EXECUTIONER SIGIL\n25% bump: execute under 10% HP",
|
||||
PassiveRelicId.HunterFeather => "10 HUNTER FEATHER\n20% kill: +20% speed for 4s",
|
||||
PassiveRelicId.BloodFang => "11 BLOOD FANG\n20% hit: heal 2% max HP",
|
||||
PassiveRelicId.RecoveryFunnel => "12 RECOVERY FUNNEL\n25% kill: restore 5% max gauge",
|
||||
PassiveRelicId.TrophyMask => "13 TROPHY MASK\nElite kill: +20% bump, +10% speed for 8s",
|
||||
PassiveRelicId.ResonantCrystal => "14 RESONANT CRYSTAL\n20% manual use: repeat after 0.5s",
|
||||
PassiveRelicId.BackflowHeart => "15 BACKFLOW HEART\nDamage taken: temporary shield",
|
||||
PassiveRelicId.CounterBell => "16 COUNTER BELL\n20% HP hit: slow nearby enemies",
|
||||
PassiveRelicId.SprintWheel => "17 SPRINT WHEEL\nMove 8 body widths: next bump shockwave",
|
||||
PassiveRelicId.BattleBaton => "18 BATTLE BATON\nManual use: boost visible towers",
|
||||
PassiveRelicId.BondLedger => "19 BOND LEDGER\nTower kills bumped enemy: +1 build",
|
||||
PassiveRelicId.PilgrimPouch => "20 PILGRIM POUCH\n5% kill: healing orb",
|
||||
_ => id.ToString(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using BumpCombat.Combat;
|
||||
using BumpCombat.Constants;
|
||||
using BumpCombat.Core;
|
||||
using BumpCombat.Defense;
|
||||
using BumpCombat.Enemies;
|
||||
using BumpCombat.Player;
|
||||
using UnityEngine;
|
||||
@@ -13,6 +16,23 @@ namespace BumpCombat.Progression
|
||||
TwinHornTalisman,
|
||||
ResonanceCore,
|
||||
RuptureSeed,
|
||||
WaveNail,
|
||||
CapacitorRing,
|
||||
EmberWick,
|
||||
EmberCarrier,
|
||||
RiftWedge,
|
||||
ExecutionerSigil,
|
||||
HunterFeather,
|
||||
BloodFang,
|
||||
RecoveryFunnel,
|
||||
TrophyMask,
|
||||
ResonantCrystal,
|
||||
BackflowHeart,
|
||||
CounterBell,
|
||||
SprintWheel,
|
||||
BattleBaton,
|
||||
BondLedger,
|
||||
PilgrimPouch,
|
||||
}
|
||||
|
||||
public static class PassiveRelicRules
|
||||
@@ -22,6 +42,23 @@ namespace BumpCombat.Progression
|
||||
PassiveRelicId.TwinHornTalisman,
|
||||
PassiveRelicId.ResonanceCore,
|
||||
PassiveRelicId.RuptureSeed,
|
||||
PassiveRelicId.WaveNail,
|
||||
PassiveRelicId.CapacitorRing,
|
||||
PassiveRelicId.EmberWick,
|
||||
PassiveRelicId.EmberCarrier,
|
||||
PassiveRelicId.RiftWedge,
|
||||
PassiveRelicId.ExecutionerSigil,
|
||||
PassiveRelicId.HunterFeather,
|
||||
PassiveRelicId.BloodFang,
|
||||
PassiveRelicId.RecoveryFunnel,
|
||||
PassiveRelicId.TrophyMask,
|
||||
PassiveRelicId.ResonantCrystal,
|
||||
PassiveRelicId.BackflowHeart,
|
||||
PassiveRelicId.CounterBell,
|
||||
PassiveRelicId.SprintWheel,
|
||||
PassiveRelicId.BattleBaton,
|
||||
PassiveRelicId.BondLedger,
|
||||
PassiveRelicId.PilgrimPouch,
|
||||
};
|
||||
|
||||
public static bool RollsChance(float chance, float roll) =>
|
||||
@@ -64,12 +101,21 @@ namespace BumpCombat.Progression
|
||||
Mathf.Max(0f, bodyDiameter) * Mathf.Max(0f, diameterMultiplier);
|
||||
|
||||
public static bool IsPlayerCreditedKill(EnemyDamageSource source) =>
|
||||
source == EnemyDamageSource.Player;
|
||||
source == EnemyDamageSource.Player
|
||||
|| source == EnemyDamageSource.PassiveRelicAttack;
|
||||
|
||||
public static bool IsPlayerRewardKill(EnemyDamageSource source) =>
|
||||
source == EnemyDamageSource.Player
|
||||
|| source == EnemyDamageSource.PassiveRelicExplosion
|
||||
|| source == EnemyDamageSource.PassiveRelicAttack
|
||||
|| source == EnemyDamageSource.PassiveRelicTransferredIgnite;
|
||||
}
|
||||
|
||||
public sealed class PassiveRelicController : MonoBehaviour
|
||||
{
|
||||
public const string EchoSourceId = "passive_relic_echo";
|
||||
private const string HunterBuffSourceId = "relic.hunter-feather";
|
||||
private const string TrophyBuffSourceId = "relic.trophy-mask";
|
||||
|
||||
private readonly HashSet<PassiveRelicId> owned = new();
|
||||
private readonly HashSet<int> processedDeaths = new();
|
||||
@@ -77,11 +123,33 @@ namespace BumpCombat.Progression
|
||||
private PlayerController player;
|
||||
private Collider2D playerCollider;
|
||||
private PlayerHealth playerHealth;
|
||||
private PlayerStats playerStats;
|
||||
private float nextBloodAllowedAt;
|
||||
private float nextRecoveryAllowedAt;
|
||||
private float hunterExpiresAt;
|
||||
private float trophyExpiresAt;
|
||||
private float heartAccumulatedDamage;
|
||||
private float nextHeartAllowedAt;
|
||||
private float nextBellAllowedAt;
|
||||
private float nextRepeatAllowedAt;
|
||||
private bool repeatPending;
|
||||
private float voluntaryMovementDistance;
|
||||
private bool wheelCharged;
|
||||
private float nextBatonAllowedAt;
|
||||
private float nextLedgerAllowedAt;
|
||||
private float nextPouchAllowedAt;
|
||||
private readonly Dictionary<int, float> recentBumps = new();
|
||||
private PassiveRelicHealingOrb healingOrb;
|
||||
private bool active;
|
||||
private float nextEchoAllowedAt;
|
||||
private float nextWaveAllowedAt;
|
||||
private float nextChainAllowedAt;
|
||||
private float nextCarrierAllowedAt;
|
||||
private readonly Dictionary<int, (int hits, float lastHitAt)> riftHits = new();
|
||||
|
||||
public static PassiveRelicController Instance { get; private set; }
|
||||
public int OwnedCount => owned.Count;
|
||||
public bool IsRunActive => active;
|
||||
public bool Owns(PassiveRelicId id) => owned.Contains(id);
|
||||
public int AvailableCount => GetAvailableRelics().Length;
|
||||
|
||||
@@ -109,6 +177,15 @@ 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;
|
||||
}
|
||||
@@ -126,20 +203,104 @@ private void OnDestroy()
|
||||
owned.Clear();
|
||||
processedDeaths.Clear();
|
||||
nextEchoAllowedAt = 0f;
|
||||
nextWaveAllowedAt = 0f;
|
||||
nextChainAllowedAt = 0f;
|
||||
nextCarrierAllowedAt = 0f;
|
||||
riftHits.Clear();
|
||||
recentBumps.Clear();
|
||||
voluntaryMovementDistance = 0f;
|
||||
wheelCharged = false;
|
||||
nextBatonAllowedAt = 0f;
|
||||
nextLedgerAllowedAt = 0f;
|
||||
nextPouchAllowedAt = 0f;
|
||||
if (healingOrb != null)
|
||||
{
|
||||
Destroy(healingOrb.gameObject);
|
||||
healingOrb = null;
|
||||
}
|
||||
player = GetComponent<PlayerController>();
|
||||
player?.SetPassiveRelicController(this);
|
||||
playerCollider = GetComponent<Collider2D>();
|
||||
playerHealth = GetComponent<PlayerHealth>();
|
||||
playerStats = GetComponent<PlayerStats>();
|
||||
nextBloodAllowedAt = 0f;
|
||||
nextRecoveryAllowedAt = 0f;
|
||||
ClearTemporaryBuffs();
|
||||
heartAccumulatedDamage = 0f;
|
||||
nextHeartAllowedAt = 0f;
|
||||
nextBellAllowedAt = 0f;
|
||||
playerHealth.OnEnemyDamageTaken -= HandleDamageTaken;
|
||||
playerHealth.OnEnemyDamageTaken += HandleDamageTaken;
|
||||
artifacts = GetComponent<ActiveArtifactController>();
|
||||
if (artifacts != null)
|
||||
{
|
||||
artifacts.OnArtifactUseSucceeded -= HandleArtifactUseSucceeded;
|
||||
artifacts.OnArtifactUseSucceeded += HandleArtifactUseSucceeded;
|
||||
}
|
||||
nextRepeatAllowedAt = 0f;
|
||||
repeatPending = false;
|
||||
active = true;
|
||||
}
|
||||
|
||||
public void EndDefenseRun()
|
||||
{
|
||||
active = false;
|
||||
StopAllCoroutines();
|
||||
repeatPending = false;
|
||||
if (healingOrb != null)
|
||||
{
|
||||
Destroy(healingOrb.gameObject);
|
||||
healingOrb = null;
|
||||
}
|
||||
if (artifacts != null)
|
||||
{
|
||||
artifacts.OnArtifactUseSucceeded -= HandleArtifactUseSucceeded;
|
||||
}
|
||||
if (playerHealth != null)
|
||||
{
|
||||
playerHealth.OnEnemyDamageTaken -= HandleDamageTaken;
|
||||
}
|
||||
ClearTemporaryBuffs();
|
||||
}
|
||||
|
||||
public PassiveRelicId[] GetAvailableRelics() =>
|
||||
PassiveRelicRules.GetAvailableRelics(owned);
|
||||
PassiveRelicRules.GetAvailableRelics(owned)
|
||||
.Where(CanOfferRelic)
|
||||
.ToArray();
|
||||
|
||||
private bool CanOfferRelic(PassiveRelicId id)
|
||||
{
|
||||
if (id == PassiveRelicId.EmberCarrier)
|
||||
{
|
||||
ActiveArtifactDefinition phoenix = FindArtifactDefinition(
|
||||
ActiveArtifactEffect.Phoenix);
|
||||
return owned.Contains(PassiveRelicId.EmberWick)
|
||||
|| (phoenix != null
|
||||
&& artifacts != null
|
||||
&& artifacts.OwnsArtifact(phoenix));
|
||||
}
|
||||
|
||||
if (id == PassiveRelicId.ResonantCrystal)
|
||||
{
|
||||
if (artifacts == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
for (int i = 0; i < artifacts.OwnedArtifactCount; i++)
|
||||
{
|
||||
ActiveArtifactDefinition definition = artifacts.GetArtifactAt(i);
|
||||
if (definition != null
|
||||
&& (definition.NormalDamage > 0f
|
||||
|| definition.ChargedDamage > 0f))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool TryAcquire(PassiveRelicId id)
|
||||
{
|
||||
@@ -151,6 +312,40 @@ private void OnDestroy()
|
||||
return owned.Add(id);
|
||||
}
|
||||
|
||||
public void AddVoluntaryMovement(float actualDistance)
|
||||
{
|
||||
if (!active
|
||||
|| !owned.Contains(PassiveRelicId.SprintWheel)
|
||||
|| wheelCharged
|
||||
|| actualDistance <= 0f
|
||||
|| playerHealth == null
|
||||
|| playerHealth.CurrentHealth <= 0f)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
float diameter = playerCollider != null
|
||||
? Mathf.Max(playerCollider.bounds.size.x, playerCollider.bounds.size.y)
|
||||
: 0f;
|
||||
if (diameter <= 0f)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
voluntaryMovementDistance += actualDistance;
|
||||
wheelCharged = voluntaryMovementDistance >= diameter
|
||||
* GameplayConstants.Current.PassiveRelics.SprintWheelRequiredBodyDiameters;
|
||||
}
|
||||
|
||||
public float ApplyTrophyBumpDamage(float damage, bool ordinaryBump)
|
||||
{
|
||||
return active
|
||||
&& ordinaryBump
|
||||
&& Time.time < trophyExpiresAt
|
||||
? damage * (1f + GameplayConstants.Current.PassiveRelics.TrophyMaskBumpDamageIncrease)
|
||||
: damage;
|
||||
}
|
||||
|
||||
public float ApplyOrdinaryBumpDamageMultiplier(
|
||||
float damage,
|
||||
bool isOrdinaryBump,
|
||||
@@ -172,14 +367,39 @@ private void OnDestroy()
|
||||
private void HandleValidHit(CombatHitResult hit)
|
||||
{
|
||||
if (!active
|
||||
|| !owned.Contains(PassiveRelicId.ResonanceCore)
|
||||
|| hit.Attacker != gameObject
|
||||
|| hit.Target == null
|
||||
|| hit.Damage <= 0f
|
||||
|| playerHealth == null
|
||||
|| playerHealth.CurrentHealth <= 0f
|
||||
|| hit.SourceId == EchoSourceId
|
||||
|| hit.SourceId == EchoSourceId)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
PassiveRelicConstants tuning = GameplayConstants.Current.PassiveRelics;
|
||||
if (hit.IsOrdinaryBump && !hit.IsArtifactHit)
|
||||
{
|
||||
EnemyController bumped = hit.Target.GetComponentInParent<EnemyController>();
|
||||
if (bumped != null && owned.Contains(PassiveRelicId.BondLedger))
|
||||
{
|
||||
recentBumps[bumped.SpawnLifetimeIdentity] = Time.time;
|
||||
}
|
||||
TrySprintWheel(hit, tuning);
|
||||
}
|
||||
if (hit.BaseBumpDamage > 0f)
|
||||
{
|
||||
TryWaveNail(hit, tuning);
|
||||
TryCapacitorRing(hit, tuning);
|
||||
}
|
||||
TryEmberWick(hit, tuning);
|
||||
TryRiftWedge(hit, tuning);
|
||||
TryExecutionerSigil(hit, tuning);
|
||||
TryBloodFang(tuning);
|
||||
|
||||
if (!owned.Contains(PassiveRelicId.ResonanceCore)
|
||||
|| Time.time < nextEchoAllowedAt
|
||||
|| !RollsChance(GameplayConstants.Current.PassiveRelics.ResonanceEchoChance)
|
||||
|| !RollsChance(tuning.ResonanceEchoChance)
|
||||
|| artifacts == null)
|
||||
{
|
||||
return;
|
||||
@@ -188,24 +408,272 @@ private void OnDestroy()
|
||||
if (artifacts.TryEmitPassiveRelicEcho(
|
||||
hit.HitPosition,
|
||||
hit.KnockbackDirection,
|
||||
GameplayConstants.Current.PassiveRelics.ResonanceEchoDamageMultiplier))
|
||||
tuning.ResonanceEchoDamageMultiplier))
|
||||
{
|
||||
nextEchoAllowedAt = Time.time
|
||||
+ GameplayConstants.Current.PassiveRelics.ResonanceEchoCooldownSeconds;
|
||||
+ tuning.ResonanceEchoCooldownSeconds;
|
||||
}
|
||||
}
|
||||
|
||||
private void TryWaveNail(CombatHitResult hit, PassiveRelicConstants tuning)
|
||||
{
|
||||
if (!owned.Contains(PassiveRelicId.WaveNail)
|
||||
|| Time.time < nextWaveAllowedAt
|
||||
|| !RollsChance(tuning.WaveNailChance))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
float damage = hit.BaseBumpDamage * tuning.WaveNailDamageFraction;
|
||||
if (damage <= 0f)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
bool applied = false;
|
||||
foreach (EnemyController enemy in FindNearbyEnemies(hit.HitPosition))
|
||||
{
|
||||
applied |= TryApplyRelicDamage(enemy, hit.HitPosition, damage);
|
||||
}
|
||||
if (applied)
|
||||
{
|
||||
nextWaveAllowedAt = Time.time + tuning.WaveNailCooldownSeconds;
|
||||
}
|
||||
}
|
||||
|
||||
private void TryCapacitorRing(CombatHitResult hit, PassiveRelicConstants tuning)
|
||||
{
|
||||
if (!owned.Contains(PassiveRelicId.CapacitorRing)
|
||||
|| Time.time < nextChainAllowedAt
|
||||
|| !RollsChance(tuning.CapacitorRingChance))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
float damage = hit.BaseBumpDamage * tuning.CapacitorRingDamageFraction;
|
||||
if (damage <= 0f)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
bool applied = false;
|
||||
int hitCount = 0;
|
||||
foreach (EnemyController enemy in FindNearbyEnemies(hit.HitPosition)
|
||||
.OrderBy(enemy => ((Vector2)enemy.transform.position - hit.HitPosition).sqrMagnitude))
|
||||
{
|
||||
if (enemy.gameObject == hit.Target || hitCount >= tuning.CapacitorRingMaxTargets)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (TryApplyRelicDamage(enemy, hit.HitPosition, damage))
|
||||
{
|
||||
applied = true;
|
||||
hitCount++;
|
||||
}
|
||||
}
|
||||
if (applied)
|
||||
{
|
||||
nextChainAllowedAt = Time.time + tuning.CapacitorRingCooldownSeconds;
|
||||
}
|
||||
}
|
||||
|
||||
private void TryEmberWick(CombatHitResult hit, PassiveRelicConstants tuning)
|
||||
{
|
||||
if (!owned.Contains(PassiveRelicId.EmberWick)
|
||||
|| !RollsChance(tuning.EmberWickChance)
|
||||
|| artifacts == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
EnemyController enemy = hit.Target.GetComponentInParent<EnemyController>();
|
||||
if (enemy == null || enemy.IsDead)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ActiveArtifactDefinition phoenix = FindArtifactDefinition(
|
||||
ActiveArtifactEffect.Phoenix);
|
||||
if (phoenix != null)
|
||||
{
|
||||
enemy.ApplyIgnite(
|
||||
phoenix.NormalIgniteDuration,
|
||||
phoenix.NormalIgniteInterval,
|
||||
playerStats.CalculateDamage(
|
||||
phoenix.NormalIgniteTickDamage,
|
||||
phoenix.DamageTag));
|
||||
}
|
||||
}
|
||||
private List<EnemyController> FindNearbyEnemies(Vector2 position)
|
||||
{
|
||||
float diameter = playerCollider != null
|
||||
? Mathf.Max(playerCollider.bounds.size.x, playerCollider.bounds.size.y)
|
||||
: 0f;
|
||||
float radius = PassiveRelicRules.GetExplosionRadius(diameter, 3f);
|
||||
List<EnemyController> nearby = new();
|
||||
HashSet<EnemyController> seen = new();
|
||||
foreach (Collider2D collider in Physics2D.OverlapCircleAll(position, radius))
|
||||
{
|
||||
EnemyController enemy = collider != null
|
||||
? collider.GetComponentInParent<EnemyController>()
|
||||
: null;
|
||||
if (enemy != null && !enemy.IsDead && seen.Add(enemy))
|
||||
{
|
||||
nearby.Add(enemy);
|
||||
}
|
||||
}
|
||||
return nearby;
|
||||
}
|
||||
|
||||
private static bool TryApplyRelicDamage(EnemyController enemy, Vector2 origin, float damage)
|
||||
{
|
||||
Vector2 direction = ((Vector2)enemy.transform.position - origin).normalized;
|
||||
return enemy.TryTakeDamage(
|
||||
damage,
|
||||
direction,
|
||||
0f,
|
||||
true,
|
||||
false,
|
||||
EnemyDamageSource.PassiveRelicAttack);
|
||||
}
|
||||
private void TrySprintWheel(CombatHitResult hit, PassiveRelicConstants tuning)
|
||||
{
|
||||
if (!wheelCharged || hit.BaseBumpDamage <= 0f)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
wheelCharged = false;
|
||||
voluntaryMovementDistance = 0f;
|
||||
float damage = hit.BaseBumpDamage
|
||||
* tuning.SprintWheelShockwaveDamageFraction;
|
||||
foreach (EnemyController enemy in FindNearbyEnemies(hit.HitPosition))
|
||||
{
|
||||
TryApplyRelicDamage(enemy, hit.HitPosition, damage);
|
||||
}
|
||||
}
|
||||
|
||||
private void TryRiftWedge(CombatHitResult hit, PassiveRelicConstants tuning)
|
||||
{
|
||||
if (!owned.Contains(PassiveRelicId.RiftWedge)
|
||||
|| !hit.IsOrdinaryBump
|
||||
|| hit.IsArtifactHit)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
EnemyController enemy = hit.Target.GetComponentInParent<EnemyController>();
|
||||
if (enemy == null || enemy.IsDead)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
int key = enemy.SpawnLifetimeIdentity;
|
||||
riftHits.TryGetValue(key, out var progress);
|
||||
int count = Time.time - progress.lastHitAt > tuning.RiftWedgeMaxHitGapSeconds
|
||||
? 1
|
||||
: progress.hits + 1;
|
||||
if (count < tuning.RiftWedgeRequiredHits)
|
||||
{
|
||||
riftHits[key] = (count, Time.time);
|
||||
return;
|
||||
}
|
||||
|
||||
riftHits.Remove(key);
|
||||
ActiveArtifactDefinition thunder = FindArtifactDefinition(
|
||||
ActiveArtifactEffect.ThunderCrash);
|
||||
if (thunder != null)
|
||||
{
|
||||
enemy.ApplyBumpVulnerability(
|
||||
thunder.NormalVulnerabilityDuration,
|
||||
thunder.NormalVulnerabilityIncrease);
|
||||
}
|
||||
}
|
||||
|
||||
private void TryExecutionerSigil(CombatHitResult hit, PassiveRelicConstants tuning)
|
||||
{
|
||||
if (!owned.Contains(PassiveRelicId.ExecutionerSigil)
|
||||
|| !hit.IsOrdinaryBump
|
||||
|| hit.IsArtifactHit
|
||||
|| !RollsChance(tuning.ExecutionerSigilChance))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
EnemyController enemy = hit.Target.GetComponentInParent<EnemyController>();
|
||||
if (enemy == null
|
||||
|| enemy.IsDead
|
||||
|| enemy.GroggyTier == BumpCombat.Core.RunTimedEvent.MidBoss
|
||||
|| enemy.GroggyTier == BumpCombat.Core.RunTimedEvent.FinalBoss
|
||||
|| enemy.CurrentHealth > enemy.MaximumHealth
|
||||
* tuning.ExecutionerSigilHealthThreshold)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
enemy.TryTakeDamage(
|
||||
enemy.MaximumHealth * 100f,
|
||||
hit.KnockbackDirection,
|
||||
0f,
|
||||
true,
|
||||
false,
|
||||
EnemyDamageSource.PassiveRelicAttack);
|
||||
}
|
||||
|
||||
private ActiveArtifactDefinition FindArtifactDefinition(ActiveArtifactEffect effect)
|
||||
{
|
||||
if (artifacts == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
for (int i = 0; i < artifacts.CatalogCount; i++)
|
||||
{
|
||||
ActiveArtifactDefinition definition = artifacts.GetCatalogArtifactAt(i);
|
||||
if (definition != null && definition.Effect == effect)
|
||||
{
|
||||
return definition;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private void HandleEnemyDied(GameObject target)
|
||||
{
|
||||
if (!active || !owned.Contains(PassiveRelicId.RuptureSeed) || target == null)
|
||||
if (!active || target == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
EnemyController victim = target.GetComponent<EnemyController>();
|
||||
if (victim == null
|
||||
|| !PassiveRelicRules.IsPlayerCreditedKill(victim.LastDamageSource)
|
||||
|| !processedDeaths.Add(victim.SpawnLifetimeIdentity)
|
||||
|| playerHealth == null
|
||||
|| playerHealth.CurrentHealth <= 0f)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
riftHits.Remove(victim.SpawnLifetimeIdentity);
|
||||
TryBondLedger(victim);
|
||||
recentBumps.Remove(victim.SpawnLifetimeIdentity);
|
||||
if (!PassiveRelicRules.IsPlayerRewardKill(victim.LastDamageSource))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
TryHunterFeather();
|
||||
TryRecoveryFunnel();
|
||||
TryTrophyMask(victim);
|
||||
TryPilgrimPouch(victim);
|
||||
if (!PassiveRelicRules.IsPlayerCreditedKill(victim.LastDamageSource))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
TryEmberCarrier(victim);
|
||||
if (!owned.Contains(PassiveRelicId.RuptureSeed)
|
||||
|| !RollsChance(GameplayConstants.Current.PassiveRelics.RuptureSeedChance))
|
||||
{
|
||||
return;
|
||||
@@ -220,7 +688,7 @@ private void OnDestroy()
|
||||
float radius = PassiveRelicRules.GetExplosionRadius(
|
||||
diameter,
|
||||
GameplayConstants.Current.PassiveRelics.RuptureExplosionPlayerBodyDiameters);
|
||||
if (damage <= 0f || radius <= 0f || playerHealth == null || playerHealth.CurrentHealth <= 0f)
|
||||
if (damage <= 0f || radius <= 0f)
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -249,6 +717,321 @@ private void OnDestroy()
|
||||
}
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
if (playerStats == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!active || playerHealth == null || playerHealth.CurrentHealth <= 0f)
|
||||
{
|
||||
ClearTemporaryBuffs();
|
||||
return;
|
||||
}
|
||||
|
||||
if (hunterExpiresAt > 0f && Time.time >= hunterExpiresAt)
|
||||
{
|
||||
playerStats.RemoveModifiersFromSource(HunterBuffSourceId);
|
||||
hunterExpiresAt = 0f;
|
||||
}
|
||||
if (trophyExpiresAt > 0f && Time.time >= trophyExpiresAt)
|
||||
{
|
||||
playerStats.RemoveModifiersFromSource(TrophyBuffSourceId);
|
||||
trophyExpiresAt = 0f;
|
||||
}
|
||||
}
|
||||
|
||||
private void ClearTemporaryBuffs()
|
||||
{
|
||||
playerStats?.RemoveModifiersFromSource(HunterBuffSourceId);
|
||||
playerStats?.RemoveModifiersFromSource(TrophyBuffSourceId);
|
||||
hunterExpiresAt = 0f;
|
||||
trophyExpiresAt = 0f;
|
||||
}
|
||||
|
||||
private void TryBloodFang(PassiveRelicConstants tuning)
|
||||
{
|
||||
if (!owned.Contains(PassiveRelicId.BloodFang)
|
||||
|| Time.time < nextBloodAllowedAt
|
||||
|| playerHealth.CurrentHealth >= playerHealth.MaxHealth
|
||||
|| !RollsChance(tuning.BloodFangChance))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
float before = playerHealth.CurrentHealth;
|
||||
playerHealth.Heal(playerHealth.MaxHealth * tuning.BloodFangHealMaxHealthFraction);
|
||||
if (playerHealth.CurrentHealth > before)
|
||||
{
|
||||
nextBloodAllowedAt = Time.time + tuning.BloodFangCooldownSeconds;
|
||||
}
|
||||
}
|
||||
|
||||
private void TryHunterFeather()
|
||||
{
|
||||
PassiveRelicConstants tuning = GameplayConstants.Current.PassiveRelics;
|
||||
if (!owned.Contains(PassiveRelicId.HunterFeather)
|
||||
|| !RollsChance(tuning.HunterFeatherChance))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (hunterExpiresAt <= Time.time)
|
||||
{
|
||||
playerStats.AddModifier(new StatModifier(
|
||||
HunterBuffSourceId,
|
||||
CharacterStat.MoveSpeed,
|
||||
ModifierOperation.Increased,
|
||||
tuning.HunterFeatherMoveSpeedIncrease));
|
||||
}
|
||||
hunterExpiresAt = Time.time + tuning.HunterFeatherDurationSeconds;
|
||||
}
|
||||
|
||||
private void TryRecoveryFunnel()
|
||||
{
|
||||
PassiveRelicConstants tuning = GameplayConstants.Current.PassiveRelics;
|
||||
if (!owned.Contains(PassiveRelicId.RecoveryFunnel)
|
||||
|| Time.time < nextRecoveryAllowedAt
|
||||
|| artifacts == null
|
||||
|| !RollsChance(tuning.RecoveryFunnelChance))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (artifacts.TryRestorePassiveRelicGauge(
|
||||
tuning.RecoveryFunnelGaugeFraction))
|
||||
{
|
||||
nextRecoveryAllowedAt = Time.time
|
||||
+ tuning.RecoveryFunnelCooldownSeconds;
|
||||
}
|
||||
}
|
||||
|
||||
private void TryTrophyMask(EnemyController victim)
|
||||
{
|
||||
if (!owned.Contains(PassiveRelicId.TrophyMask)
|
||||
|| victim.GroggyTier != RunTimedEvent.Elite)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
PassiveRelicConstants tuning = GameplayConstants.Current.PassiveRelics;
|
||||
if (trophyExpiresAt <= Time.time)
|
||||
{
|
||||
|
||||
playerStats.AddModifier(new StatModifier(
|
||||
TrophyBuffSourceId,
|
||||
CharacterStat.MoveSpeed,
|
||||
ModifierOperation.Increased,
|
||||
tuning.TrophyMaskMoveSpeedIncrease));
|
||||
}
|
||||
trophyExpiresAt = Time.time + tuning.TrophyMaskDurationSeconds;
|
||||
}
|
||||
|
||||
private void TryBattleBaton(PassiveRelicConstants tuning)
|
||||
{
|
||||
if (!active
|
||||
|| !owned.Contains(PassiveRelicId.BattleBaton)
|
||||
|| Time.time < nextBatonAllowedAt)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (DefensePrototypeController.Instance?.TryBoostVisibleTowers(
|
||||
tuning.BattleBatonDurationSeconds) == true)
|
||||
{
|
||||
nextBatonAllowedAt = Time.time
|
||||
+ tuning.BattleBatonCooldownSeconds;
|
||||
}
|
||||
}
|
||||
|
||||
private void TryBondLedger(EnemyController victim)
|
||||
{
|
||||
PassiveRelicConstants tuning = GameplayConstants.Current.PassiveRelics;
|
||||
if (!owned.Contains(PassiveRelicId.BondLedger)
|
||||
|| victim.LastDamageSource != EnemyDamageSource.Tower
|
||||
|| Time.time < nextLedgerAllowedAt
|
||||
|| !recentBumps.TryGetValue(
|
||||
victim.SpawnLifetimeIdentity,
|
||||
out float bumpedAt)
|
||||
|| Time.time - bumpedAt > tuning.BondLedgerBumpWindowSeconds
|
||||
|| !RollsChance(tuning.BondLedgerChance))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (DefensePrototypeController.Instance?.TryAwardBondLedgerCurrency(
|
||||
tuning.BondLedgerCurrency) == true)
|
||||
{
|
||||
nextLedgerAllowedAt = Time.time
|
||||
+ tuning.BondLedgerCooldownSeconds;
|
||||
}
|
||||
}
|
||||
|
||||
private void TryPilgrimPouch(EnemyController victim)
|
||||
{
|
||||
PassiveRelicConstants tuning = GameplayConstants.Current.PassiveRelics;
|
||||
if (!owned.Contains(PassiveRelicId.PilgrimPouch)
|
||||
|| healingOrb != null
|
||||
|| Time.time < nextPouchAllowedAt
|
||||
|| !RollsChance(tuning.PilgrimPouchChance))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
GameObject orbObject = new("Pilgrim Healing Orb");
|
||||
orbObject.transform.position = victim.transform.position;
|
||||
healingOrb = orbObject.AddComponent<PassiveRelicHealingOrb>();
|
||||
healingOrb.Initialize(
|
||||
this,
|
||||
playerHealth,
|
||||
tuning.PilgrimPouchHealMaxHealthFraction,
|
||||
tuning.PilgrimPouchOrbLifetimeSeconds);
|
||||
nextPouchAllowedAt = Time.time + tuning.PilgrimPouchCooldownSeconds;
|
||||
}
|
||||
|
||||
private void HandleArtifactUseSucceeded(
|
||||
ActiveArtifactDefinition definition,
|
||||
bool charged)
|
||||
{
|
||||
PassiveRelicConstants tuning = GameplayConstants.Current.PassiveRelics;
|
||||
TryBattleBaton(tuning);
|
||||
if (!active
|
||||
|| !owned.Contains(PassiveRelicId.ResonantCrystal)
|
||||
|| repeatPending
|
||||
|| Time.time < nextRepeatAllowedAt
|
||||
|| playerHealth == null
|
||||
|| playerHealth.CurrentHealth <= 0f
|
||||
|| !RollsChance(tuning.ResonantCrystalChance))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Vector2 origin = artifacts.GetPassiveRelicUseOrigin();
|
||||
Vector2 direction = artifacts.GetPassiveRelicUseDirection();
|
||||
repeatPending = true;
|
||||
StartCoroutine(RepeatArtifactAfterDelay(
|
||||
definition,
|
||||
charged,
|
||||
origin,
|
||||
direction,
|
||||
tuning));
|
||||
}
|
||||
|
||||
private IEnumerator RepeatArtifactAfterDelay(
|
||||
ActiveArtifactDefinition definition,
|
||||
bool charged,
|
||||
Vector2 origin,
|
||||
Vector2 direction,
|
||||
PassiveRelicConstants tuning)
|
||||
{
|
||||
yield return new WaitForSeconds(tuning.ResonantCrystalDelaySeconds);
|
||||
repeatPending = false;
|
||||
if (!active
|
||||
|| playerHealth == null
|
||||
|| playerHealth.CurrentHealth <= 0f
|
||||
|| artifacts == null)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
if (artifacts.TryEmitPassiveRelicRepeat(
|
||||
definition,
|
||||
charged,
|
||||
origin,
|
||||
direction,
|
||||
tuning.ResonantCrystalDamageFraction))
|
||||
{
|
||||
nextRepeatAllowedAt = Time.time
|
||||
+ tuning.ResonantCrystalCooldownSeconds;
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleDamageTaken(float actualLoss)
|
||||
{
|
||||
if (!active
|
||||
|| actualLoss <= 0f
|
||||
|| playerHealth == null
|
||||
|| playerHealth.CurrentHealth <= 0f)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
PassiveRelicConstants tuning = GameplayConstants.Current.PassiveRelics;
|
||||
if (owned.Contains(PassiveRelicId.BackflowHeart)
|
||||
&& Time.time >= nextHeartAllowedAt)
|
||||
{
|
||||
heartAccumulatedDamage += actualLoss;
|
||||
if (heartAccumulatedDamage >= playerHealth.MaxHealth
|
||||
* tuning.BackflowHeartDamageThreshold)
|
||||
{
|
||||
heartAccumulatedDamage = 0f;
|
||||
if (playerHealth.GrantPassiveRelicShield(
|
||||
playerHealth.MaxHealth * tuning.BackflowHeartShieldFraction,
|
||||
tuning.BackflowHeartShieldDurationSeconds))
|
||||
{
|
||||
nextHeartAllowedAt = Time.time
|
||||
+ tuning.BackflowHeartCooldownSeconds;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!owned.Contains(PassiveRelicId.CounterBell)
|
||||
|| Time.time < nextBellAllowedAt
|
||||
|| !RollsChance(tuning.CounterBellChance))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
bool applied = false;
|
||||
foreach (EnemyController enemy in FindNearbyEnemies(transform.position))
|
||||
{
|
||||
applied |= enemy.ApplySlow(
|
||||
tuning.CounterBellSlowDurationSeconds,
|
||||
tuning.CounterBellSlowFraction);
|
||||
}
|
||||
if (applied)
|
||||
{
|
||||
nextBellAllowedAt = Time.time + tuning.CounterBellCooldownSeconds;
|
||||
}
|
||||
}
|
||||
|
||||
private void TryEmberCarrier(EnemyController victim)
|
||||
{
|
||||
PassiveRelicConstants tuning = GameplayConstants.Current.PassiveRelics;
|
||||
if (!owned.Contains(PassiveRelicId.EmberCarrier)
|
||||
|| victim.IgniteRemainingAtDeath <= 0f
|
||||
|| victim.IgniteTickIntervalAtDeath <= 0f
|
||||
|| victim.IgniteTickDamageAtDeath <= 0f
|
||||
|| Time.time < nextCarrierAllowedAt
|
||||
|| !RollsChance(tuning.EmberCarrierChance))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
int count = 0;
|
||||
foreach (EnemyController enemy in FindNearbyEnemies(victim.transform.position)
|
||||
.OrderBy(enemy => ((Vector2)enemy.transform.position - (Vector2)victim.transform.position).sqrMagnitude))
|
||||
{
|
||||
if (enemy == victim || count >= tuning.EmberCarrierMaxTargets)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (enemy.ApplyIgnite(
|
||||
victim.IgniteRemainingAtDeath,
|
||||
victim.IgniteTickIntervalAtDeath,
|
||||
victim.IgniteTickDamageAtDeath,
|
||||
EnemyDamageSource.PassiveRelicTransferredIgnite))
|
||||
{
|
||||
count++;
|
||||
}
|
||||
}
|
||||
if (count > 0)
|
||||
{
|
||||
nextCarrierAllowedAt = Time.time + tuning.EmberCarrierCooldownSeconds;
|
||||
}
|
||||
}
|
||||
private static float GetRandomRoll()
|
||||
{
|
||||
#if UNITY_EDITOR
|
||||
@@ -265,4 +1048,50 @@ private void OnDestroy()
|
||||
return PassiveRelicRules.RollsChance(chance, GetRandomRoll());
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class PassiveRelicHealingOrb : MonoBehaviour
|
||||
{
|
||||
private PassiveRelicController owner;
|
||||
private PlayerHealth playerHealth;
|
||||
private float healFraction;
|
||||
private float expiresAt;
|
||||
|
||||
public void Initialize(
|
||||
PassiveRelicController relicOwner,
|
||||
PlayerHealth health,
|
||||
float fraction,
|
||||
float lifetime)
|
||||
{
|
||||
owner = relicOwner;
|
||||
playerHealth = health;
|
||||
healFraction = fraction;
|
||||
expiresAt = Time.time + lifetime;
|
||||
SpriteRenderer renderer = gameObject.AddComponent<SpriteRenderer>();
|
||||
renderer.sprite = DefensePrototypeController.WhiteSprite;
|
||||
renderer.color = new Color(0.35f, 1f, 0.55f);
|
||||
renderer.sortingOrder = 12;
|
||||
transform.localScale = Vector3.one * 0.3f;
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
if (owner == null || !owner.IsRunActive || Time.time >= expiresAt)
|
||||
{
|
||||
Destroy(gameObject);
|
||||
return;
|
||||
}
|
||||
|
||||
if (playerHealth == null
|
||||
|| playerHealth.CurrentHealth <= 0f
|
||||
|| Vector2.Distance(
|
||||
transform.position,
|
||||
playerHealth.transform.position)
|
||||
> GameplayConstants.Current.Items.ExperienceOrbPickupRadius)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
playerHealth.Heal(playerHealth.MaxHealth * healFraction);
|
||||
Destroy(gameObject);
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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<T>(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>());
|
||||
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)]
|
||||
|
||||
@@ -151,6 +151,344 @@ namespace BumpCombat.PlayModeTests
|
||||
}
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator DefensePrototype_DefaultBuildsCanDamageTheirPortalLanes()
|
||||
{
|
||||
yield return StartDefenseRun();
|
||||
DefensePrototypeController controller = DefensePrototypeController.Instance;
|
||||
PlayerController player = UnityEngine.Object.FindAnyObjectByType<PlayerController>();
|
||||
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<PlayerController>();
|
||||
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<LineRenderer>();
|
||||
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<LineRenderer>();
|
||||
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<PlayerController>();
|
||||
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<SpriteRenderer>();
|
||||
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<PlayerController>();
|
||||
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<PlayerController>();
|
||||
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<DefenseSummonUnit>();
|
||||
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<SpriteRenderer>().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<PlayerController>();
|
||||
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<PlayerController>();
|
||||
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<DefenseSummonUnit>().Length,
|
||||
Is.EqualTo(1));
|
||||
Assert.That(scout.GetComponentsInChildren<DefenseSummonUnit>().Length,
|
||||
Is.EqualTo(6));
|
||||
}
|
||||
[UnityTest]
|
||||
public IEnumerator DefensePrototype_SmotheringBoostsTowerDamageInsideSupportAura()
|
||||
{
|
||||
yield return StartDefenseRun();
|
||||
DefensePrototypeController controller = DefensePrototypeController.Instance;
|
||||
PlayerController player = UnityEngine.Object.FindAnyObjectByType<PlayerController>();
|
||||
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<PlayerController>();
|
||||
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<PlayerController>();
|
||||
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<PassiveRelicController>().Owns(
|
||||
PassiveRelicId.TwinHornTalisman),
|
||||
Is.True,
|
||||
"Choosing the first portal option must acquire the shown passive relic.");
|
||||
player.GetComponent<PassiveRelicController>().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<PassiveRelicController>().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<CircleCollider2D>().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<PlayerHealth>();
|
||||
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<PlayerHealth>();
|
||||
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<Collider2D>();
|
||||
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<ActiveArtifactDefinition> owned = typeof(ActiveArtifactController)
|
||||
.GetField("ownedArtifacts", BindingFlags.Instance | BindingFlags.NonPublic)
|
||||
?.GetValue(passiveRelicArtifacts) as List<ActiveArtifactDefinition>;
|
||||
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<PlayerStats>();
|
||||
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<PlayerHealth>();
|
||||
SetTestField(health, "<CurrentHealth>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, "<CurrentGauge>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<PassiveRelicHealingOrb>(
|
||||
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<PassiveRelicHealingOrb>(
|
||||
FindObjectsInactive.Exclude, FindObjectsSortMode.None).Length, Is.EqualTo(1));
|
||||
PlayerHealth health = passiveRelicPlayer.GetComponent<PlayerHealth>();
|
||||
SetTestField(health, "<CurrentHealth>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<PlayerController>();
|
||||
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<DefenseTower, float> boosted = typeof(DefensePrototypeController)
|
||||
.GetField("boostedTowers", BindingFlags.Instance | BindingFlags.NonPublic)
|
||||
?.GetValue(controller) as Dictionary<DefenseTower, float>;
|
||||
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<PlayerController>();
|
||||
passiveRelicController = passiveRelicPlayer.GetComponent<PassiveRelicController>();
|
||||
Assert.That(passiveRelicController.TryAcquire(PassiveRelicId.BondLedger), Is.True);
|
||||
#if UNITY_EDITOR
|
||||
PassiveRelicController.RandomRollForTests = () => 0f;
|
||||
#endif
|
||||
SpawnDirector director = UnityEngine.Object.FindAnyObjectByType<SpawnDirector>();
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user