using SoulHarvest.Projectiles;
using Microsoft.Xna.Framework;
using System;
using System.Collections.Generic;
using System.IO;
using Terraria;
using Terraria.ID;
using Terraria.ModLoader;
namespace SoulHarvest.Common;
public sealed class ReaperCombatPlayer : ModPlayer
{
private int bloodShield;
private int bloodShieldIdle;
private uint bloodShieldRevision;
private int pendingMirroredBloodShieldAbsorption;
private int pendingMirroredBloodShieldTimer;
private int bloodHealingThisSecond;
private int bloodHealingWindow;
private int infernalMomentumTimer;
private int ultimateTimer;
private ReaperFormId ultimateForm;
private bool bloodFrenzyLatched;
private int lastBloodFinisherIdentity = -1;
private int bloodFinisherTargets;
private int deathUltimateHealing;
private byte infernalSpecialMask;
private int infernalSpecialMaskTimer;
private int lastSoulActionId;
private readonly HashSet<int> soulActionTargets = [];
private int bloodDrinkVisualCooldown;
private int bloodShieldVisualCooldown;
private int bloodFrenzyVisualRefresh;
private int bloodShieldCounterCooldown;
private int infernalScorchExecutionCooldown;
private int infernalMomentumVisualCooldown;
private SickleCombatSnapshot bloodShieldSnapshot;
private bool hasBloodShieldSnapshot;
private readonly int[,] lastSkillCueAction = new int[8, ReaperDefinitions.SkillsPerBranch];
private readonly ulong[,] lastSkillCueTick = new ulong[8, ReaperDefinitions.SkillsPerBranch];
private readonly bool[,] hasSkillCueAction = new bool[8, ReaperDefinitions.SkillsPerBranch];
private readonly byte[,] skillCueCenterSamples = new byte[8, ReaperDefinitions.SkillsPerBranch];
public bool BloodFrenzyActive => bloodFrenzyLatched;
internal int BloodShieldAmount => bloodShield;
internal bool FreezesWorldForDeathUltimate
{
get
{
if (ultimateForm != ReaperFormId.Death || ultimateTimer <= 0)
return false;
int elapsed = ReaperDeathUltimateGeometry.Duration - ultimateTimer;
return elapsed >= ReaperDeathUltimateGeometry.FirstCutTick
&& elapsed <= ReaperDeathUltimateGeometry.ShatterTick;
}
}
public override void Initialize()
{
ClearRuntime();
}
internal void ClearRuntime()
{
bloodShield = 0;
bloodShieldIdle = 0;
if (Main.netMode == NetmodeID.Server)
AdvanceBloodShieldRevision();
else
bloodShieldRevision = 0;
pendingMirroredBloodShieldAbsorption = 0;
pendingMirroredBloodShieldTimer = 0;
bloodHealingThisSecond = 0;
bloodHealingWindow = 0;
infernalMomentumTimer = 0;
ultimateTimer = 0;
ultimateForm = ReaperFormId.Base;
bloodFrenzyLatched = false;
lastBloodFinisherIdentity = -1;
bloodFinisherTargets = 0;
deathUltimateHealing = 0;
infernalSpecialMask = 0;
infernalSpecialMaskTimer = 0;
lastSoulActionId = int.MinValue;
soulActionTargets.Clear();
bloodDrinkVisualCooldown = 0;
bloodShieldVisualCooldown = 0;
bloodFrenzyVisualRefresh = 0;
bloodShieldCounterCooldown = 0;
infernalScorchExecutionCooldown = 0;
infernalMomentumVisualCooldown = 0;
bloodShieldSnapshot = default;
hasBloodShieldSnapshot = false;
Array.Clear(lastSkillCueAction);
Array.Clear(lastSkillCueTick);
Array.Clear(hasSkillCueAction);
Array.Clear(skillCueCenterSamples);
}
internal void WriteOwnerRuntime(BinaryWriter writer)
{
writer.Write((ushort)Math.Clamp(bloodShield, 0, ushort.MaxValue));
writer.Write((ushort)Math.Clamp(bloodShieldIdle, 0, ushort.MaxValue));
writer.Write(bloodShieldRevision);
writer.Write((ushort)Math.Clamp(infernalMomentumTimer, 0, ushort.MaxValue));
writer.Write((ushort)Math.Clamp(ultimateTimer, 0, ushort.MaxValue));
writer.Write((byte)ultimateForm);
}
internal void ReceiveOwnerRuntime(BinaryReader reader)
{
bloodShield = reader.ReadUInt16();
bloodShieldIdle = reader.ReadUInt16();
bloodShieldRevision = reader.ReadUInt32();
infernalMomentumTimer = reader.ReadUInt16();
ultimateTimer = reader.ReadUInt16();
ReaperFormId receivedForm = (ReaperFormId)reader.ReadByte();
ultimateForm = Enum.IsDefined(receivedForm) ? receivedForm : ReaperFormId.Base;
}
public override void PostUpdate()
{
if (++bloodHealingWindow >= 60)
{
bloodHealingWindow = 0;
bloodHealingThisSecond = 0;
}
if (bloodShield > 0 && ++bloodShieldIdle >= 300)
{
ReleaseBloodShield(Vector2.UnitY * -1f);
if (Main.netMode == NetmodeID.Server)
{
AdvanceBloodShieldRevision();
Player.GetModPlayer<MyPlayer>().SyncReaperCombatRuntime();
}
}
if (pendingMirroredBloodShieldTimer > 0)
pendingMirroredBloodShieldTimer--;
else
pendingMirroredBloodShieldAbsorption = 0;
if (infernalMomentumTimer > 0)
infernalMomentumTimer--;
if (ultimateTimer > 0)
ultimateTimer--;
if (bloodDrinkVisualCooldown > 0)
bloodDrinkVisualCooldown--;
if (bloodShieldVisualCooldown > 0)
bloodShieldVisualCooldown--;
if (bloodFrenzyVisualRefresh > 0)
bloodFrenzyVisualRefresh--;
if (bloodShieldCounterCooldown > 0)
bloodShieldCounterCooldown--;
if (infernalScorchExecutionCooldown > 0)
infernalScorchExecutionCooldown--;
if (infernalMomentumVisualCooldown > 0)
infernalMomentumVisualCooldown--;
ReaperProgressionState state = Player.GetModPlayer<MyPlayer>().ReaperProgression;
bool frenzyWasActive = bloodFrenzyLatched;
if (state.CurrentForm != ReaperFormId.Blood || Player.statLife <= 0)
bloodFrenzyLatched = false;
else if (!bloodFrenzyLatched && Player.statLife * 2 < Player.statLifeMax2)
bloodFrenzyLatched = true;
else if (bloodFrenzyLatched && Player.statLife * 100 >= Player.statLifeMax2 * 55)
bloodFrenzyLatched = false;
if (bloodFrenzyLatched && Main.netMode != NetmodeID.MultiplayerClient
&& (!frenzyWasActive || bloodFrenzyVisualRefresh <= 0))
{
int level = state.GetSkillLevel(ReaperFormId.Blood, 1);
if (level > 0)
{
ReaperSkillCueProjectile.Spawn(Player.GetSource_Misc("SoulHarvest:BloodFrenzyVisual"),
Player.whoAmI, ReaperFormId.Blood, state.GetStage(ReaperFormId.Blood),
1, level, Player.MountedCenter, Vector2.UnitY * -1f);
bloodFrenzyVisualRefresh = 90;
}
}
else if (frenzyWasActive && !bloodFrenzyLatched
&& Main.netMode != NetmodeID.MultiplayerClient)
{
EmitCurrentBloodVisual(1, Player.MountedCenter, Vector2.UnitY * -1f, variant: 1);
bloodFrenzyVisualRefresh = 0;
}
if (infernalSpecialMaskTimer > 0)
infernalSpecialMaskTimer--;
else
infernalSpecialMask = 0;
}
public override void PostUpdateRunSpeeds()
{
if (ultimateTimer > 0 && ultimateForm == ReaperFormId.Blood)
{
Player.moveSpeed *= 0.6f;
Player.maxRunSpeed *= 0.6f;
Player.accRunSpeed *= 0.6f;
Player.runAcceleration *= 0.6f;
}
if (infernalMomentumTimer <= 0)
return;
ReaperProgressionState state = Player.GetModPlayer<MyPlayer>().ReaperProgression;
if (state.CurrentForm != ReaperFormId.Infernal)
return;
int level = state.GetSkillLevel(ReaperFormId.Infernal, 0);
float bonus = level switch { 2 => 0.12f, >= 3 => 0.16f, _ => 0.08f };
Player.moveSpeed += bonus;
Player.maxRunSpeed *= 1f + bonus;
}
public override void ModifyHurt(ref Player.HurtModifiers modifiers)
{
if (ultimateTimer > 0)
{
modifiers.FinalDamage *= ultimateForm == ReaperFormId.Death ? 0.20f : 0.30f;
modifiers.Knockback *= 0f;
}
if (bloodShield > 0
|| Main.netMode == NetmodeID.Server && pendingMirroredBloodShieldAbsorption > 0)
modifiers.ModifyHurtInfo += ConsumeBloodShield;
}
private void ConsumeBloodShield(ref Player.HurtInfo info)
{
if (info.Damage <= 0)
return;
if (Main.netMode == NetmodeID.Server && pendingMirroredBloodShieldAbsorption > 0)
{
// If the owner's revisioned report arrived before this server-side Hurt
// hook, its delta already changed the authoritative shield. Mirror only
// the damage reduction here instead of charging that shield a second time.
int mirrored = Math.Min(pendingMirroredBloodShieldAbsorption, info.Damage);
pendingMirroredBloodShieldAbsorption -= mirrored;
if (pendingMirroredBloodShieldAbsorption <= 0)
pendingMirroredBloodShieldTimer = 0;
info.Damage -= mirrored;
}
if (bloodShield <= 0 || info.Damage <= 0)
return;
int absorbed = Math.Min(bloodShield, info.Damage);
uint consumedRevision = bloodShieldRevision;
bloodShield -= absorbed;
info.Damage -= absorbed;
if (Main.netMode != NetmodeID.Server)
CombatText.NewText(Player.Hitbox, new Color(245, 70, 125), absorbed);
if (Main.netMode == NetmodeID.MultiplayerClient)
{
SoulHarvest.SendBloodShieldConsumption(Player, consumedRevision, absorbed);
AdvanceBloodShieldRevision();
}
else if (Main.netMode == NetmodeID.Server)
{
AdvanceBloodShieldRevision();
Player.GetModPlayer<MyPlayer>().SyncReaperCombatRuntime();
}
if (Main.netMode != NetmodeID.MultiplayerClient)
OnBloodShieldAbsorbed(absorbed, ResolveCounterDirection(info.HitDirection));
}
internal void AcceptOwnerBloodShieldConsumption(uint revision, int absorbed)
{
if (Main.netMode != NetmodeID.Server)
return;
if (revision == bloodShieldRevision && absorbed > 0)
{
int previousShield = bloodShield;
bloodShield = Math.Max(0, bloodShield - absorbed);
if (bloodShield != previousShield)
{
int acceptedAbsorption = previousShield - bloodShield;
pendingMirroredBloodShieldAbsorption = Math.Min(
Math.Max(1, Player.statLifeMax2),
pendingMirroredBloodShieldAbsorption + acceptedAbsorption);
pendingMirroredBloodShieldTimer = 15;
AdvanceBloodShieldRevision();
OnBloodShieldAbsorbed(acceptedAbsorption, new Vector2(Player.direction, 0f));
}
}
// Always answer, including stale/malformed revisions, so the owner cannot
// retain a predicted shield state that the server rejected.
Player.GetModPlayer<MyPlayer>().SyncReaperCombatRuntime();
}
internal void ApplyBloodHealing(in SickleCombatSnapshot snapshot, int damageDone, Vector2 visualSource)
{
int level = snapshot.Skill1;
if (level <= 0 || damageDone <= 0 || Player.dead)
return;
float rate = level switch { 2 => 0.03f, >= 3 => 0.04f, _ => 0.02f };
int secondCap = Math.Max(1, (int)(Player.statLifeMax2 * (level switch { 2 => 0.06f, >= 3 => 0.08f, _ => 0.04f })));
int potential = Math.Max(1, (int)Math.Ceiling(damageDone * rate));
int permitted = Math.Min(potential, Math.Max(0, secondCap - bloodHealingThisSecond));
int missing = Math.Max(0, Player.statLifeMax2 - Player.statLife);
int healed = Math.Min(missing, permitted);
if (healed > 0)
{
Player.Heal(healed);
bloodHealingThisSecond += healed;
// Character-owned Blood Covenant healing bypasses the legacy item
// lifesteal path, so it must explicitly create the returning blood-soul
// feedback on the authoritative side. The projectile is then synced to
// clients like every other combat visual.
LifeStealVisuals.Spawn(visualSource, Player.whoAmI, level, healed);
if (bloodDrinkVisualCooldown <= 0 && Main.netMode != NetmodeID.MultiplayerClient)
{
ReaperSkillCueProjectile.Spawn(Player.GetSource_Misc("SoulHarvest:BloodDrinkVisual"),
Player.whoAmI, snapshot, 0, visualSource,
(Player.MountedCenter - visualSource).SafeNormalize(Vector2.UnitY * -1f));
bloodDrinkVisualCooldown = 10;
}
}
if (snapshot.Skill3 <= 0 || permitted <= healed)
return;
float shieldRatio = snapshot.Skill3 switch { 2 => 0.06f, >= 3 => 0.08f, _ => 0.04f };
int shieldCap = Math.Max(1, (int)(Player.statLifeMax2 * shieldRatio));
int previousShield = bloodShield;
bloodShield = Math.Min(shieldCap, bloodShield + permitted - healed);
if (bloodShield != previousShield)
{
bloodShieldSnapshot = snapshot;
hasBloodShieldSnapshot = true;
bloodShieldIdle = 0;
LifeStealVisuals.Spawn(visualSource, Player.whoAmI, level, bloodShield - previousShield);
if (Main.netMode == NetmodeID.Server)
{
AdvanceBloodShieldRevision();
Player.GetModPlayer<MyPlayer>().SyncReaperCombatRuntime();
}
if (bloodShieldVisualCooldown <= 0 && Main.netMode != NetmodeID.MultiplayerClient)
{
ReaperSkillCueProjectile.Spawn(Player.GetSource_Misc("SoulHarvest:BloodShieldVisual"),
Player.whoAmI, snapshot, 2, Player.MountedCenter, Vector2.UnitY * -1f);
bloodShieldVisualCooldown = 18;
}
}
}
private void OnBloodShieldAbsorbed(int absorbed, Vector2 direction)
{
if (absorbed <= 0 || !hasBloodShieldSnapshot)
return;
direction = direction.SafeNormalize(new Vector2(Player.direction, 0f));
int level = bloodShieldSnapshot.Skill3;
if (level >= 2 && bloodShieldCounterCooldown <= 0
&& (bloodShield > 0 || level == 2))
{
bloodShieldCounterCooldown = 42;
float counterFactor = level >= 3 ? 0.20f : 0.16f;
ReaperStrikeProjectile.Spawn(Player.GetSource_Misc("SoulHarvest:BloodShieldCounter"),
Player.whoAmI, bloodShieldSnapshot, ReaperHitKind.PrimaryDerived, 0,
Player.MountedCenter + direction * 12f, direction, ReaperStrikeShape.Line,
level >= 3 ? 142f : 118f, level >= 3 ? 31f : 25f, counterFactor, 3);
ReaperSkillCueProjectile.Spawn(Player.GetSource_Misc("SoulHarvest:BloodShieldCounterVisual"),
Player.whoAmI, bloodShieldSnapshot, 2, Player.MountedCenter, direction, variant: 2);
}
if (bloodShield <= 0)
ReleaseBloodShield(direction);
}
private void ReleaseBloodShield(Vector2 direction)
{
SickleCombatSnapshot releasedSnapshot = bloodShieldSnapshot;
bool canExecute = Main.netMode != NetmodeID.MultiplayerClient
&& hasBloodShieldSnapshot
&& releasedSnapshot.Skill3 >= 3;
bloodShield = 0;
bloodShieldIdle = 0;
hasBloodShieldSnapshot = false;
bloodShieldSnapshot = default;
if (Main.netMode == NetmodeID.MultiplayerClient)
return;
direction = direction.SafeNormalize(Vector2.UnitY * -1f);
if (canExecute)
{
// The cocoon's final ring is one bounded area hit, not twelve radial
// projectiles. This keeps the shield's defensive budget predictable.
ReaperStrikeProjectile.Spawn(Player.GetSource_Misc("SoulHarvest:BloodShieldRelease"),
Player.whoAmI, releasedSnapshot, ReaperHitKind.PrimaryDerived, 0,
Player.MountedCenter, direction, ReaperStrikeShape.Circle,
112f, 30f, 0.32f, 2);
ReaperSkillCueProjectile.Spawn(Player.GetSource_Misc("SoulHarvest:BloodShieldReleaseVisual"),
Player.whoAmI, releasedSnapshot, 2, Player.MountedCenter, direction, variant: 1);
}
else
{
EmitCurrentBloodVisual(2, Player.MountedCenter, direction, variant: 1);
}
}
private Vector2 ResolveCounterDirection(int hitDirection)
{
int direction = hitDirection == 0 ? Player.direction : hitDirection;
return new Vector2(direction == 0 ? 1 : direction, 0f);
}
internal bool TryTriggerInfernalScorchExecution(in SickleCombatSnapshot snapshot, NPC target, int actionId)
{
if (Main.netMode == NetmodeID.MultiplayerClient
|| snapshot.Skill2 < 3
|| infernalScorchExecutionCooldown > 0
|| !target.active)
{
return false;
}
infernalScorchExecutionCooldown = 75;
Vector2 direction = (target.Center - Player.MountedCenter)
.SafeNormalize(new Vector2(Player.direction, 0f));
ReaperStrikeProjectile.Spawn(target.GetSource_FromAI(), Player.whoAmI, snapshot,
ReaperHitKind.PrimaryDerived, 0, target.Center, direction,
ReaperStrikeShape.Circle, Math.Max(88f, target.width * 0.75f), 28f,
0.42f, 9, actionId);
if (TryReserveSkillCue(ReaperFormId.Infernal, 1, actionId))
{
ReaperSkillCueProjectile.Spawn(target.GetSource_FromAI(), Player.whoAmI, snapshot,
1, target.Center, direction, variant: 1, actionId: actionId);
}
return true;
}
internal bool TryReserveSkillCue(ReaperFormId form, int skillIndex, int actionId)
{
int formIndex = (int)form;
if (formIndex < 0 || formIndex >= lastSkillCueAction.GetLength(0)
|| skillIndex < 0 || skillIndex >= ReaperDefinitions.SkillsPerBranch)
{
return false;
}
ulong now = Main.GameUpdateCount;
if (hasSkillCueAction[formIndex, skillIndex]
&& lastSkillCueAction[formIndex, skillIndex] == actionId
&& now - lastSkillCueTick[formIndex, skillIndex] <= 300UL)
{
// Same-tick multi-target hits may send a handful of lightweight
// center samples. Clients merge them into the one existing visual,
// producing an actual group focus instead of using whichever NPC the
// server happened to enumerate first. Later ticks remain suppressed.
if (now != lastSkillCueTick[formIndex, skillIndex]
|| skillCueCenterSamples[formIndex, skillIndex] >= 12)
{
return false;
}
skillCueCenterSamples[formIndex, skillIndex]++;
return true;
}
hasSkillCueAction[formIndex, skillIndex] = true;
lastSkillCueAction[formIndex, skillIndex] = actionId;
lastSkillCueTick[formIndex, skillIndex] = now;
skillCueCenterSamples[formIndex, skillIndex] = 1;
return true;
}
private void AdvanceBloodShieldRevision()
{
bloodShieldRevision++;
if (bloodShieldRevision == 0)
bloodShieldRevision++;
}
internal void ApplyDeathBloodHealing(int damageDone)
{
if (damageDone <= 0 || Player.dead)
return;
int amount = Math.Min(Math.Max(0, Player.statLifeMax2 - Player.statLife),
Math.Min(Math.Max(1, damageDone / 100), Math.Max(1, Player.statLifeMax2 / 100)));
if (amount > 0)
Player.Heal(amount);
}
internal void ApplyBloodFinisherHealing(int projectileIdentity, Vector2 visualSource, int visualLevel)
{
if (projectileIdentity != lastBloodFinisherIdentity)
{
lastBloodFinisherIdentity = projectileIdentity;
bloodFinisherTargets = 0;
}
if (bloodFinisherTargets >= 3 || Player.dead)
return;
bloodFinisherTargets++;
int amount = Math.Max(1, (int)Math.Ceiling(Player.statLifeMax2 * 0.0075f));
amount = Math.Min(amount, Math.Max(0, Player.statLifeMax2 - Player.statLife));
if (amount <= 0)
return;
Player.Heal(amount);
LifeStealVisuals.Spawn(visualSource, Player.whoAmI, Math.Max(1, visualLevel), amount);
}
internal void ApplyDeathUltimateHealing(int damageDone)
{
if (ultimateForm != ReaperFormId.Death || ultimateTimer <= 0 || damageDone <= 0 || Player.dead)
return;
int cap = Math.Max(1, (int)Math.Ceiling(Player.statLifeMax2 * 0.08f));
int amount = Math.Min(Math.Max(0, cap - deathUltimateHealing), Math.Max(1, damageDone / 100));
amount = Math.Min(amount, Math.Max(0, Player.statLifeMax2 - Player.statLife));
if (amount <= 0)
return;
Player.Heal(amount);
deathUltimateHealing += amount;
}
internal void RecordInfernalSpecialHit(in SickleCombatSnapshot snapshot, int phase, Projectile source)
{
if (snapshot.Stage < ReaperStage.StageIII || snapshot.Skill3 <= 0 || phase < 0 || phase > 2)
return;
infernalSpecialMask |= (byte)(1 << phase);
infernalSpecialMaskTimer = 90;
if (infernalSpecialMask != 0x07)
return;
infernalSpecialMask = 0;
float factor = snapshot.Skill3 switch { 2 => 0.75f, >= 3 => 1f, _ => 0.5f };
int refund = snapshot.Skill3 switch { 2 => 75, >= 3 => 105, _ => 45 };
ReaperStrikeProjectile.Spawn(source.GetSource_FromThis(), Player.whoAmI, snapshot,
ReaperHitKind.PrimaryDerived, phase, Player.Center, Vector2.UnitX,
ReaperStrikeShape.Circle, 125f, 32f, factor, 1);
ReaperSkillCueProjectile.Spawn(source.GetSource_FromThis(), Player.whoAmI, snapshot,
2, Player.MountedCenter, source.velocity.SafeNormalize(Vector2.UnitX), actionId: source.identity);
Player.GetModPlayer<MyPlayer>().ReduceReaperSpecialCooldown(refund);
}
internal bool TryRecordSoulShard(int actionId, int targetRoot)
{
if (actionId != lastSoulActionId)
{
lastSoulActionId = actionId;
soulActionTargets.Clear();
}
if (soulActionTargets.Count >= 2 || !soulActionTargets.Add(targetRoot))
return false;
return true;
}
internal void RollbackSoulShardReservation(int actionId, int targetRoot)
{
if (actionId == lastSoulActionId)
soulActionTargets.Remove(targetRoot);
}
internal void GrantInfernalMomentum()
{
bool wasInactive = infernalMomentumTimer <= 0;
infernalMomentumTimer = 120;
if ((wasInactive || infernalMomentumVisualCooldown <= 0) && Main.netMode != NetmodeID.MultiplayerClient)
{
ReaperProgressionState state = Player.GetModPlayer<MyPlayer>().ReaperProgression;
int level = state.GetSkillLevel(ReaperFormId.Infernal, 0);
if (level > 0)
{
ReaperSkillCueProjectile.Spawn(Player.GetSource_Misc("SoulHarvest:InfernalMomentumVisual"),
Player.whoAmI, ReaperFormId.Infernal, state.GetStage(ReaperFormId.Infernal),
0, level, Player.MountedCenter, new Vector2(Player.direction, 0f));
infernalMomentumVisualCooldown = 18;
}
}
if (Main.netMode == NetmodeID.Server)
Player.GetModPlayer<MyPlayer>().SyncReaperCombatRuntime();
}
internal void BeginUltimate(ReaperFormId form)
{
ultimateForm = form;
deathUltimateHealing = 0;
ultimateTimer = form switch
{
ReaperFormId.Bone => 216,
ReaperFormId.Blood => 210,
ReaperFormId.Infernal => 204,
ReaperFormId.Frost => 120,
ReaperFormId.Soul => 116,
ReaperFormId.Void => 220,
ReaperFormId.Death => ReaperDeathUltimateGeometry.Duration,
_ => 0
};
}
private void EmitCurrentBloodVisual(int skillIndex, Vector2 center, Vector2 direction, int variant = 0)
{
ReaperProgressionState state = Player.GetModPlayer<MyPlayer>().ReaperProgression;
int level = state.GetSkillLevel(ReaperFormId.Blood, skillIndex);
if (level <= 0)
return;
ReaperSkillCueProjectile.Spawn(Player.GetSource_Misc("SoulHarvest:BloodSkillVisual"),
Player.whoAmI, ReaperFormId.Blood, state.GetStage(ReaperFormId.Blood),
skillIndex, level, center, direction, variant);
}
}
using SoulHarvest.Projectiles;
using Microsoft.Xna.Framework;
using System;
using System.Collections.Generic;
using System.IO;
using Terraria;
using Terraria.ID;
using Terraria.ModLoader;
namespace SoulHarvest.Common;
public sealed class ReaperCombatPlayer : ModPlayer
{
private int bloodShield;
private int bloodShieldIdle;
private uint bloodShieldRevision;
private int pendingMirroredBloodShieldAbsorption;
private int pendingMirroredBloodShieldTimer;
private int bloodHealingThisSecond;
private int bloodHealingWindow;
private int infernalMomentumTimer;
private int ultimateTimer;
private ReaperFormId ultimateForm;
private bool bloodFrenzyLatched;
private int lastBloodFinisherIdentity = -1;
private int bloodFinisherTargets;
private int deathUltimateHealing;
private byte infernalSpecialMask;
private int infernalSpecialMaskTimer;
private int lastSoulActionId;
private readonly HashSet<int> soulActionTargets = [];
private int bloodDrinkVisualCooldown;
private int bloodShieldVisualCooldown;
private int bloodFrenzyVisualRefresh;
private int bloodShieldCounterCooldown;
private int infernalScorchExecutionCooldown;
private int infernalMomentumVisualCooldown;
private SickleCombatSnapshot bloodShieldSnapshot;
private bool hasBloodShieldSnapshot;
private readonly int[,] lastSkillCueAction = new int[8, ReaperDefinitions.SkillsPerBranch];
private readonly ulong[,] lastSkillCueTick = new ulong[8, ReaperDefinitions.SkillsPerBranch];
private readonly bool[,] hasSkillCueAction = new bool[8, ReaperDefinitions.SkillsPerBranch];
private readonly byte[,] skillCueCenterSamples = new byte[8, ReaperDefinitions.SkillsPerBranch];
public bool BloodFrenzyActive => bloodFrenzyLatched;
internal int BloodShieldAmount => bloodShield;
internal bool FreezesWorldForDeathUltimate
{
get
{
if (ultimateForm != ReaperFormId.Death || ultimateTimer <= 0)
return false;
int elapsed = ReaperDeathUltimateGeometry.Duration - ultimateTimer;
return elapsed >= ReaperDeathUltimateGeometry.FirstCutTick
&& elapsed <= ReaperDeathUltimateGeometry.ShatterTick;
}
}
public override void Initialize()
{
ClearRuntime();
}
internal void ClearRuntime()
{
bloodShield = 0;
bloodShieldIdle = 0;
if (Main.netMode == NetmodeID.Server)
AdvanceBloodShieldRevision();
else
bloodShieldRevision = 0;
pendingMirroredBloodShieldAbsorption = 0;
pendingMirroredBloodShieldTimer = 0;
bloodHealingThisSecond = 0;
bloodHealingWindow = 0;
infernalMomentumTimer = 0;
ultimateTimer = 0;
ultimateForm = ReaperFormId.Base;
bloodFrenzyLatched = false;
lastBloodFinisherIdentity = -1;
bloodFinisherTargets = 0;
deathUltimateHealing = 0;
infernalSpecialMask = 0;
infernalSpecialMaskTimer = 0;
lastSoulActionId = int.MinValue;
soulActionTargets.Clear();
bloodDrinkVisualCooldown = 0;
bloodShieldVisualCooldown = 0;
bloodFrenzyVisualRefresh = 0;
bloodShieldCounterCooldown = 0;
infernalScorchExecutionCooldown = 0;
infernalMomentumVisualCooldown = 0;
bloodShieldSnapshot = default;
hasBloodShieldSnapshot = false;
Array.Clear(lastSkillCueAction);
Array.Clear(lastSkillCueTick);
Array.Clear(hasSkillCueAction);
Array.Clear(skillCueCenterSamples);
}
internal void WriteOwnerRuntime(BinaryWriter writer)
{
writer.Write((ushort)Math.Clamp(bloodShield, 0, ushort.MaxValue));
writer.Write((ushort)Math.Clamp(bloodShieldIdle, 0, ushort.MaxValue));
writer.Write(bloodShieldRevision);
writer.Write((ushort)Math.Clamp(infernalMomentumTimer, 0, ushort.MaxValue));
writer.Write((ushort)Math.Clamp(ultimateTimer, 0, ushort.MaxValue));
writer.Write((byte)ultimateForm);
}
internal void ReceiveOwnerRuntime(BinaryReader reader)
{
bloodShield = reader.ReadUInt16();
bloodShieldIdle = reader.ReadUInt16();
bloodShieldRevision = reader.ReadUInt32();
infernalMomentumTimer = reader.ReadUInt16();
ultimateTimer = reader.ReadUInt16();
ReaperFormId receivedForm = (ReaperFormId)reader.ReadByte();
ultimateForm = Enum.IsDefined(receivedForm) ? receivedForm : ReaperFormId.Base;
}
public override void PostUpdate()
{
if (++bloodHealingWindow >= 60)
{
bloodHealingWindow = 0;
bloodHealingThisSecond = 0;
}
if (bloodShield > 0 && ++bloodShieldIdle >= 300)
{
ReleaseBloodShield(Vector2.UnitY * -1f);
if (Main.netMode == NetmodeID.Server)
{
AdvanceBloodShieldRevision();
Player.GetModPlayer<MyPlayer>().SyncReaperCombatRuntime();
}
}
if (pendingMirroredBloodShieldTimer > 0)
pendingMirroredBloodShieldTimer--;
else
pendingMirroredBloodShieldAbsorption = 0;
if (infernalMomentumTimer > 0)
infernalMomentumTimer--;
if (ultimateTimer > 0)
ultimateTimer--;
if (bloodDrinkVisualCooldown > 0)
bloodDrinkVisualCooldown--;
if (bloodShieldVisualCooldown > 0)
bloodShieldVisualCooldown--;
if (bloodFrenzyVisualRefresh > 0)
bloodFrenzyVisualRefresh--;
if (bloodShieldCounterCooldown > 0)
bloodShieldCounterCooldown--;
if (infernalScorchExecutionCooldown > 0)
infernalScorchExecutionCooldown--;
if (infernalMomentumVisualCooldown > 0)
infernalMomentumVisualCooldown--;
ReaperProgressionState state = Player.GetModPlayer<MyPlayer>().ReaperProgression;
bool frenzyWasActive = bloodFrenzyLatched;
if (state.CurrentForm != ReaperFormId.Blood || Player.statLife <= 0)
bloodFrenzyLatched = false;
else if (!bloodFrenzyLatched && Player.statLife * 2 < Player.statLifeMax2)
bloodFrenzyLatched = true;
else if (bloodFrenzyLatched && Player.statLife * 100 >= Player.statLifeMax2 * 55)
bloodFrenzyLatched = false;
if (bloodFrenzyLatched && Main.netMode != NetmodeID.MultiplayerClient
&& (!frenzyWasActive || bloodFrenzyVisualRefresh <= 0))
{
int level = state.GetSkillLevel(ReaperFormId.Blood, 1);
if (level > 0)
{
ReaperSkillCueProjectile.Spawn(Player.GetSource_Misc("SoulHarvest:BloodFrenzyVisual"),
Player.whoAmI, ReaperFormId.Blood, state.GetStage(ReaperFormId.Blood),
1, level, Player.MountedCenter, Vector2.UnitY * -1f);
bloodFrenzyVisualRefresh = 90;
}
}
else if (frenzyWasActive && !bloodFrenzyLatched
&& Main.netMode != NetmodeID.MultiplayerClient)
{
EmitCurrentBloodVisual(1, Player.MountedCenter, Vector2.UnitY * -1f, variant: 1);
bloodFrenzyVisualRefresh = 0;
}
if (infernalSpecialMaskTimer > 0)
infernalSpecialMaskTimer--;
else
infernalSpecialMask = 0;
}
public override void PostUpdateRunSpeeds()
{
if (ultimateTimer > 0 && ultimateForm == ReaperFormId.Blood)
{
Player.moveSpeed *= 0.6f;
Player.maxRunSpeed *= 0.6f;
Player.accRunSpeed *= 0.6f;
Player.runAcceleration *= 0.6f;
}
if (infernalMomentumTimer <= 0)
return;
ReaperProgressionState state = Player.GetModPlayer<MyPlayer>().ReaperProgression;
if (state.CurrentForm != ReaperFormId.Infernal)
return;
int level = state.GetSkillLevel(ReaperFormId.Infernal, 0);
float bonus = level switch { 2 => 0.12f, >= 3 => 0.16f, _ => 0.08f };
Player.moveSpeed += bonus;
Player.maxRunSpeed *= 1f + bonus;
}
public override void ModifyHurt(ref Player.HurtModifiers modifiers)
{
if (ultimateTimer > 0)
{
modifiers.FinalDamage *= ultimateForm == ReaperFormId.Death ? 0.20f : 0.30f;
modifiers.Knockback *= 0f;
}
if (bloodShield > 0
|| Main.netMode == NetmodeID.Server && pendingMirroredBloodShieldAbsorption > 0)
modifiers.ModifyHurtInfo += ConsumeBloodShield;
}
private void ConsumeBloodShield(ref Player.HurtInfo info)
{
if (info.Damage <= 0)
return;
if (Main.netMode == NetmodeID.Server && pendingMirroredBloodShieldAbsorption > 0)
{
// If the owner's revisioned report arrived before this server-side Hurt
// hook, its delta already changed the authoritative shield. Mirror only
// the damage reduction here instead of charging that shield a second time.
int mirrored = Math.Min(pendingMirroredBloodShieldAbsorption, info.Damage);
pendingMirroredBloodShieldAbsorption -= mirrored;
if (pendingMirroredBloodShieldAbsorption <= 0)
pendingMirroredBloodShieldTimer = 0;
info.Damage -= mirrored;
}
if (bloodShield <= 0 || info.Damage <= 0)
return;
int absorbed = Math.Min(bloodShield, info.Damage);
uint consumedRevision = bloodShieldRevision;
bloodShield -= absorbed;
info.Damage -= absorbed;
if (Main.netMode != NetmodeID.Server)
CombatText.NewText(Player.Hitbox, new Color(245, 70, 125), absorbed);
if (Main.netMode == NetmodeID.MultiplayerClient)
{
SoulHarvest.SendBloodShieldConsumption(Player, consumedRevision, absorbed);
AdvanceBloodShieldRevision();
}
else if (Main.netMode == NetmodeID.Server)
{
AdvanceBloodShieldRevision();
Player.GetModPlayer<MyPlayer>().SyncReaperCombatRuntime();
}
if (Main.netMode != NetmodeID.MultiplayerClient)
OnBloodShieldAbsorbed(absorbed, ResolveCounterDirection(info.HitDirection));
}
internal void AcceptOwnerBloodShieldConsumption(uint revision, int absorbed)
{
if (Main.netMode != NetmodeID.Server)
return;
if (revision == bloodShieldRevision && absorbed > 0)
{
int previousShield = bloodShield;
bloodShield = Math.Max(0, bloodShield - absorbed);
if (bloodShield != previousShield)
{
int acceptedAbsorption = previousShield - bloodShield;
pendingMirroredBloodShieldAbsorption = Math.Min(
Math.Max(1, Player.statLifeMax2),
pendingMirroredBloodShieldAbsorption + acceptedAbsorption);
pendingMirroredBloodShieldTimer = 15;
AdvanceBloodShieldRevision();
OnBloodShieldAbsorbed(acceptedAbsorption, new Vector2(Player.direction, 0f));
}
}
// Always answer, including stale/malformed revisions, so the owner cannot
// retain a predicted shield state that the server rejected.
Player.GetModPlayer<MyPlayer>().SyncReaperCombatRuntime();
}
internal void ApplyBloodHealing(in SickleCombatSnapshot snapshot, int damageDone, Vector2 visualSource)
{
int level = snapshot.Skill1;
if (level <= 0 || damageDone <= 0 || Player.dead)
return;
float rate = level switch { 2 => 0.03f, >= 3 => 0.04f, _ => 0.02f };
int secondCap = Math.Max(1, (int)(Player.statLifeMax2 * (level switch { 2 => 0.06f, >= 3 => 0.08f, _ => 0.04f })));
int potential = Math.Max(1, (int)Math.Ceiling(damageDone * rate));
int permitted = Math.Min(potential, Math.Max(0, secondCap - bloodHealingThisSecond));
int missing = Math.Max(0, Player.statLifeMax2 - Player.statLife);
int healed = Math.Min(missing, permitted);
if (healed > 0)
{
Player.Heal(healed);
bloodHealingThisSecond += healed;
// Character-owned Blood Covenant healing bypasses the legacy item
// lifesteal path, so it must explicitly create the returning blood-soul
// feedback on the authoritative side. The projectile is then synced to
// clients like every other combat visual.
LifeStealVisuals.Spawn(visualSource, Player.whoAmI, level, healed);
if (bloodDrinkVisualCooldown <= 0 && Main.netMode != NetmodeID.MultiplayerClient)
{
ReaperSkillCueProjectile.Spawn(Player.GetSource_Misc("SoulHarvest:BloodDrinkVisual"),
Player.whoAmI, snapshot, 0, visualSource,
(Player.MountedCenter - visualSource).SafeNormalize(Vector2.UnitY * -1f));
bloodDrinkVisualCooldown = 10;
}
}
if (snapshot.Skill3 <= 0 || permitted <= healed)
return;
float shieldRatio = snapshot.Skill3 switch { 2 => 0.06f, >= 3 => 0.08f, _ => 0.04f };
int shieldCap = Math.Max(1, (int)(Player.statLifeMax2 * shieldRatio));
int previousShield = bloodShield;
bloodShield = Math.Min(shieldCap, bloodShield + permitted - healed);
if (bloodShield != previousShield)
{
bloodShieldSnapshot = snapshot;
hasBloodShieldSnapshot = true;
bloodShieldIdle = 0;
LifeStealVisuals.Spawn(visualSource, Player.whoAmI, level, bloodShield - previousShield);
if (Main.netMode == NetmodeID.Server)
{
AdvanceBloodShieldRevision();
Player.GetModPlayer<MyPlayer>().SyncReaperCombatRuntime();
}
if (bloodShieldVisualCooldown <= 0 && Main.netMode != NetmodeID.MultiplayerClient)
{
ReaperSkillCueProjectile.Spawn(Player.GetSource_Misc("SoulHarvest:BloodShieldVisual"),
Player.whoAmI, snapshot, 2, Player.MountedCenter, Vector2.UnitY * -1f);
bloodShieldVisualCooldown = 18;
}
}
}
private void OnBloodShieldAbsorbed(int absorbed, Vector2 direction)
{
if (absorbed <= 0 || !hasBloodShieldSnapshot)
return;
direction = direction.SafeNormalize(new Vector2(Player.direction, 0f));
int level = bloodShieldSnapshot.Skill3;
if (level >= 2 && bloodShieldCounterCooldown <= 0
&& (bloodShield > 0 || level == 2))
{
bloodShieldCounterCooldown = 42;
float counterFactor = level >= 3 ? 0.20f : 0.16f;
ReaperStrikeProjectile.Spawn(Player.GetSource_Misc("SoulHarvest:BloodShieldCounter"),
Player.whoAmI, bloodShieldSnapshot, ReaperHitKind.PrimaryDerived, 0,
Player.MountedCenter + direction * 12f, direction, ReaperStrikeShape.Line,
level >= 3 ? 142f : 118f, level >= 3 ? 31f : 25f, counterFactor, 3);
ReaperSkillCueProjectile.Spawn(Player.GetSource_Misc("SoulHarvest:BloodShieldCounterVisual"),
Player.whoAmI, bloodShieldSnapshot, 2, Player.MountedCenter, direction, variant: 2);
}
if (bloodShield <= 0)
ReleaseBloodShield(direction);
}
private void ReleaseBloodShield(Vector2 direction)
{
SickleCombatSnapshot releasedSnapshot = bloodShieldSnapshot;
bool canExecute = Main.netMode != NetmodeID.MultiplayerClient
&& hasBloodShieldSnapshot
&& releasedSnapshot.Skill3 >= 3;
bloodShield = 0;
bloodShieldIdle = 0;
hasBloodShieldSnapshot = false;
bloodShieldSnapshot = default;
if (Main.netMode == NetmodeID.MultiplayerClient)
return;
direction = direction.SafeNormalize(Vector2.UnitY * -1f);
if (canExecute)
{
// The cocoon's final ring is one bounded area hit, not twelve radial
// projectiles. This keeps the shield's defensive budget predictable.
ReaperStrikeProjectile.Spawn(Player.GetSource_Misc("SoulHarvest:BloodShieldRelease"),
Player.whoAmI, releasedSnapshot, ReaperHitKind.PrimaryDerived, 0,
Player.MountedCenter, direction, ReaperStrikeShape.Circle,
112f, 30f, 0.32f, 2);
ReaperSkillCueProjectile.Spawn(Player.GetSource_Misc("SoulHarvest:BloodShieldReleaseVisual"),
Player.whoAmI, releasedSnapshot, 2, Player.MountedCenter, direction, variant: 1);
}
else
{
EmitCurrentBloodVisual(2, Player.MountedCenter, direction, variant: 1);
}
}
private Vector2 ResolveCounterDirection(int hitDirection)
{
int direction = hitDirection == 0 ? Player.direction : hitDirection;
return new Vector2(direction == 0 ? 1 : direction, 0f);
}
internal bool TryTriggerInfernalScorchExecution(in SickleCombatSnapshot snapshot, NPC target, int actionId)
{
if (Main.netMode == NetmodeID.MultiplayerClient
|| snapshot.Skill2 < 3
|| infernalScorchExecutionCooldown > 0
|| !target.active)
{
return false;
}
infernalScorchExecutionCooldown = 75;
Vector2 direction = (target.Center - Player.MountedCenter)
.SafeNormalize(new Vector2(Player.direction, 0f));
ReaperStrikeProjectile.Spawn(target.GetSource_FromAI(), Player.whoAmI, snapshot,
ReaperHitKind.PrimaryDerived, 0, target.Center, direction,
ReaperStrikeShape.Circle, Math.Max(88f, target.width * 0.75f), 28f,
0.42f, 9, actionId);
if (TryReserveSkillCue(ReaperFormId.Infernal, 1, actionId))
{
ReaperSkillCueProjectile.Spawn(target.GetSource_FromAI(), Player.whoAmI, snapshot,
1, target.Center, direction, variant: 1, actionId: actionId);
}
return true;
}
internal bool TryReserveSkillCue(ReaperFormId form, int skillIndex, int actionId)
{
int formIndex = (int)form;
if (formIndex < 0 || formIndex >= lastSkillCueAction.GetLength(0)
|| skillIndex < 0 || skillIndex >= ReaperDefinitions.SkillsPerBranch)
{
return false;
}
ulong now = Main.GameUpdateCount;
if (hasSkillCueAction[formIndex, skillIndex]
&& lastSkillCueAction[formIndex, skillIndex] == actionId
&& now - lastSkillCueTick[formIndex, skillIndex] <= 300UL)
{
// Same-tick multi-target hits may send a handful of lightweight
// center samples. Clients merge them into the one existing visual,
// producing an actual group focus instead of using whichever NPC the
// server happened to enumerate first. Later ticks remain suppressed.
if (now != lastSkillCueTick[formIndex, skillIndex]
|| skillCueCenterSamples[formIndex, skillIndex] >= 12)
{
return false;
}
skillCueCenterSamples[formIndex, skillIndex]++;
return true;
}
hasSkillCueAction[formIndex, skillIndex] = true;
lastSkillCueAction[formIndex, skillIndex] = actionId;
lastSkillCueTick[formIndex, skillIndex] = now;
skillCueCenterSamples[formIndex, skillIndex] = 1;
return true;
}
private void AdvanceBloodShieldRevision()
{
bloodShieldRevision++;
if (bloodShieldRevision == 0)
bloodShieldRevision++;
}
internal void ApplyDeathBloodHealing(int damageDone)
{
if (damageDone <= 0 || Player.dead)
return;
int amount = Math.Min(Math.Max(0, Player.statLifeMax2 - Player.statLife),
Math.Min(Math.Max(1, damageDone / 100), Math.Max(1, Player.statLifeMax2 / 100)));
if (amount > 0)
Player.Heal(amount);
}
internal void ApplyBloodFinisherHealing(int projectileIdentity, Vector2 visualSource, int visualLevel)
{
if (projectileIdentity != lastBloodFinisherIdentity)
{
lastBloodFinisherIdentity = projectileIdentity;
bloodFinisherTargets = 0;
}
if (bloodFinisherTargets >= 3 || Player.dead)
return;
bloodFinisherTargets++;
int amount = Math.Max(1, (int)Math.Ceiling(Player.statLifeMax2 * 0.0075f));
amount = Math.Min(amount, Math.Max(0, Player.statLifeMax2 - Player.statLife));
if (amount <= 0)
return;
Player.Heal(amount);
LifeStealVisuals.Spawn(visualSource, Player.whoAmI, Math.Max(1, visualLevel), amount);
}
internal void ApplyDeathUltimateHealing(int damageDone)
{
if (ultimateForm != ReaperFormId.Death || ultimateTimer <= 0 || damageDone <= 0 || Player.dead)
return;
int cap = Math.Max(1, (int)Math.Ceiling(Player.statLifeMax2 * 0.08f));
int amount = Math.Min(Math.Max(0, cap - deathUltimateHealing), Math.Max(1, damageDone / 100));
amount = Math.Min(amount, Math.Max(0, Player.statLifeMax2 - Player.statLife));
if (amount <= 0)
return;
Player.Heal(amount);
deathUltimateHealing += amount;
}
internal void RecordInfernalSpecialHit(in SickleCombatSnapshot snapshot, int phase, Projectile source)
{
if (snapshot.Stage < ReaperStage.StageIII || snapshot.Skill3 <= 0 || phase < 0 || phase > 2)
return;
infernalSpecialMask |= (byte)(1 << phase);
infernalSpecialMaskTimer = 90;
if (infernalSpecialMask != 0x07)
return;
infernalSpecialMask = 0;
float factor = snapshot.Skill3 switch { 2 => 0.75f, >= 3 => 1f, _ => 0.5f };
int refund = snapshot.Skill3 switch { 2 => 75, >= 3 => 105, _ => 45 };
ReaperStrikeProjectile.Spawn(source.GetSource_FromThis(), Player.whoAmI, snapshot,
ReaperHitKind.PrimaryDerived, phase, Player.Center, Vector2.UnitX,
ReaperStrikeShape.Circle, 125f, 32f, factor, 1);
ReaperSkillCueProjectile.Spawn(source.GetSource_FromThis(), Player.whoAmI, snapshot,
2, Player.MountedCenter, source.velocity.SafeNormalize(Vector2.UnitX), actionId: source.identity);
Player.GetModPlayer<MyPlayer>().ReduceReaperSpecialCooldown(refund);
}
internal bool TryRecordSoulShard(int actionId, int targetRoot)
{
if (actionId != lastSoulActionId)
{
lastSoulActionId = actionId;
soulActionTargets.Clear();
}
if (soulActionTargets.Count >= 2 || !soulActionTargets.Add(targetRoot))
return false;
return true;
}
internal void RollbackSoulShardReservation(int actionId, int targetRoot)
{
if (actionId == lastSoulActionId)
soulActionTargets.Remove(targetRoot);
}
internal void GrantInfernalMomentum()
{
bool wasInactive = infernalMomentumTimer <= 0;
infernalMomentumTimer = 120;
if ((wasInactive || infernalMomentumVisualCooldown <= 0) && Main.netMode != NetmodeID.MultiplayerClient)
{
ReaperProgressionState state = Player.GetModPlayer<MyPlayer>().ReaperProgression;
int level = state.GetSkillLevel(ReaperFormId.Infernal, 0);
if (level > 0)
{
ReaperSkillCueProjectile.Spawn(Player.GetSource_Misc("SoulHarvest:InfernalMomentumVisual"),
Player.whoAmI, ReaperFormId.Infernal, state.GetStage(ReaperFormId.Infernal),
0, level, Player.MountedCenter, new Vector2(Player.direction, 0f));
infernalMomentumVisualCooldown = 18;
}
}
if (Main.netMode == NetmodeID.Server)
Player.GetModPlayer<MyPlayer>().SyncReaperCombatRuntime();
}
internal void BeginUltimate(ReaperFormId form)
{
ultimateForm = form;
deathUltimateHealing = 0;
ultimateTimer = form switch
{
ReaperFormId.Bone => 216,
ReaperFormId.Blood => 210,
ReaperFormId.Infernal => 204,
ReaperFormId.Frost => 120,
ReaperFormId.Soul => 116,
ReaperFormId.Void => 220,
ReaperFormId.Death => ReaperDeathUltimateGeometry.Duration,
_ => 0
};
}
private void EmitCurrentBloodVisual(int skillIndex, Vector2 center, Vector2 direction, int variant = 0)
{
ReaperProgressionState state = Player.GetModPlayer<MyPlayer>().ReaperProgression;
int level = state.GetSkillLevel(ReaperFormId.Blood, skillIndex);
if (level <= 0)
return;
ReaperSkillCueProjectile.Spawn(Player.GetSource_Misc("SoulHarvest:BloodSkillVisual"),
Player.whoAmI, ReaperFormId.Blood, state.GetStage(ReaperFormId.Blood),
skillIndex, level, center, direction, variant);
}
}