using SoulHarvest.Items;
using SoulHarvest.Projectiles;
using Microsoft.Xna.Framework;
using System;
using Terraria;
using Terraria.DataStructures;
using Terraria.ID;
using Terraria.ModLoader;
namespace SoulHarvest.Common;
public static class ReaperCombatService
{
internal static void ResetPlayerState(int owner)
{
if (owner < 0 || owner >= Main.maxPlayers)
return;
for (int form = 0; form < ReaperCombatSystem.ComboPhase.GetLength(1); form++)
{
ReaperCombatSystem.ComboPhase[owner, form] = 0;
ReaperCombatSystem.LastComboTick[owner, form] = 0;
}
ReaperCombatSystem.ActivePrimaryProjectile[owner] = -1;
ReaperCombatSystem.HasActivePrimaryProjectile[owner] = false;
ReaperCombatSystem.NextPrimaryAuthorizationTick[owner] = 0;
ReaperCombatSystem.DeathPrimaryChain[owner] = 0;
ReaperCombatSystem.LastDeathPrimaryTick[owner] = 0;
ReaperCombatSystem.DeathPrimaryAssemblyReady[owner] = false;
}
internal static bool TryAuthorizePrimarySwing(Player player, Projectile projectile,
in SickleCombatSnapshot snapshot, out int phase, out float deathTempo,
out bool requireDeathAssembly)
{
phase = 0;
deathTempo = 1f;
requireDeathAssembly = false;
if (Main.netMode != NetmodeID.Server
|| !player.active
|| player.dead
|| player.HeldItem.ModItem is not NormalSickle
|| !player.GetModPlayer<MyPlayer>().ServerCharacterStateReady
|| player.ownedProjectileCounts[ModContent.ProjectileType<ReaperActionControllerProjectile>()] > 0
|| player.GetModPlayer<MyPlayer>().IsReaperFormAssemblyActive()
|| projectile.owner != player.whoAmI)
{
return false;
}
// The projectile packet can arrive after the very short item-use timer
// has already reached zero, especially once Death reaches its high-speed
// cap. Authorization is bounded by the authoritative per-owner cadence.
// Do not reject merely because the previous projectile is still alive on
// its harmless cleanup frame: at Death's fast cadence that frame overlaps
// the next legal request and used to turn one rejection into a 180-frame
// client authority wait.
int owner = player.whoAmI;
ulong now = Main.GameUpdateCount;
if (now < ReaperCombatSystem.NextPrimaryAuthorizationTick[owner])
return false;
// The cadence check above already admits at most one request for this
// owner in the current window. Replace the diagnostic active pointer with
// the newly authorized swing; the old projectile will finish naturally.
ReaperCombatSystem.HasActivePrimaryProjectile[owner] = false;
requireDeathAssembly = snapshot.Form == ReaperFormId.Death
&& !ReaperCombatSystem.DeathPrimaryAssemblyReady[owner];
phase = TakeNextPrimaryPhase(player, snapshot, out deathTempo);
ReaperCombatSystem.ActivePrimaryProjectile[owner] = projectile.whoAmI;
ReaperCombatSystem.HasActivePrimaryProjectile[owner] = true;
// Item reuse and the authoritative held projectile already enforce the
// real animation duration. This clock is only duplicate-packet protection.
// Basing it on the whole previous swing made normal network jitter reject
// the next legitimate owner projectile; the client then held its predicted
// terminal pose for the 180-frame authority timeout.
int minimumAuthorizationGap = snapshot.Form == ReaperFormId.Death ? 2 : 6;
ReaperCombatSystem.NextPrimaryAuthorizationTick[owner] = now
+ (ulong)minimumAuthorizationGap;
return true;
}
internal static bool RequiresDeathPrimaryAssembly(Player player)
{
int owner = player.whoAmI;
return owner < 0 || owner >= Main.maxPlayers
|| !ReaperCombatSystem.DeathPrimaryAssemblyReady[owner];
}
internal static void CompleteDeathPrimaryAssembly(Player player)
{
int owner = player.whoAmI;
if (owner >= 0 && owner < Main.maxPlayers)
ReaperCombatSystem.DeathPrimaryAssemblyReady[owner] = true;
}
internal static void ReleaseDeathPrimaryHold(Player player)
{
int owner = player.whoAmI;
if (owner < 0 || owner >= Main.maxPlayers)
return;
ReaperCombatSystem.DeathPrimaryAssemblyReady[owner] = false;
int swingType = ModContent.ProjectileType<SickleSwingProjectile>();
foreach (Projectile projectile in Main.ActiveProjectiles)
{
if (projectile.owner != owner || projectile.type != swingType
|| projectile.ModProjectile is not SickleSwingProjectile swing)
{
continue;
}
swing.RequestDeathAssemblyCancellation();
}
}
internal static int TakeNextPrimaryPhase(Player player, in SickleCombatSnapshot snapshot)
=> TakeNextPrimaryPhase(player, snapshot, out _);
internal static int TakeNextPrimaryPhase(Player player, in SickleCombatSnapshot snapshot,
out float deathTempo)
{
deathTempo = 1f;
int formIndex = (int)snapshot.Form;
int count = ReaperCombatRegistry.GetPrimaryPhaseCount(snapshot.Form, snapshot.Stage);
ulong now = Main.GameUpdateCount;
if (now - ReaperCombatSystem.LastComboTick[player.whoAmI, formIndex] > 90UL)
ReaperCombatSystem.ComboPhase[player.whoAmI, formIndex] = 0;
int phase = ReaperCombatSystem.ComboPhase[player.whoAmI, formIndex] % count;
ReaperCombatSystem.ComboPhase[player.whoAmI, formIndex] = (byte)((phase + 1) % count);
ReaperCombatSystem.LastComboTick[player.whoAmI, formIndex] = now;
if (snapshot.Form == ReaperFormId.Death)
{
int owner = player.whoAmI;
bool continuing = now - ReaperCombatSystem.LastDeathPrimaryTick[owner]
<= DeathPrimaryChainResetFrames;
int chain = continuing ? ReaperCombatSystem.DeathPrimaryChain[owner] + 1 : 1;
ReaperCombatSystem.DeathPrimaryChain[owner] = (byte)Math.Min(DeathPrimaryMaximumChain, chain);
ReaperCombatSystem.LastDeathPrimaryTick[owner] = now;
deathTempo = GetDeathTempo(ReaperCombatSystem.DeathPrimaryChain[owner],
snapshot.DeathPrimaryMaximumTempo);
}
return phase;
}
// Death takes a sustained uninterrupted chain to reach its separately
// upgraded primary cap. The held invocation keeps its own lower cap.
internal const int DeathPrimaryMaximumChain = 24;
private const ulong DeathPrimaryChainResetFrames = 46UL;
internal static float GetDeathPrimaryTempo(Player player)
{
if (player.whoAmI < 0 || player.whoAmI >= Main.maxPlayers
|| Main.GameUpdateCount - ReaperCombatSystem.LastDeathPrimaryTick[player.whoAmI]
> DeathPrimaryChainResetFrames)
{
return 1f;
}
int capLevel = player.GetModPlayer<MyPlayer>().ReaperProgression
.GetCommonNodeLevel(ReaperCommonNode.DeathTempoCap);
return GetDeathTempo(ReaperCombatSystem.DeathPrimaryChain[player.whoAmI],
ReaperDefinitions.GetDeathPrimaryTempoCap(capLevel));
}
private static float GetDeathTempo(int chain, float maximumTempo)
{
float progress = MathHelper.Clamp((chain - 1f) / (DeathPrimaryMaximumChain - 1f), 0f, 1f);
progress = progress * progress * (3f - 2f * progress);
return MathHelper.Lerp(1f, MathHelper.Clamp(maximumTempo, 1f,
ReaperCombatRegistry.DeathPrimaryMaximumTempo), progress);
}
public static bool TryStartSpecial(Player player, Vector2 aim)
{
if (Main.netMode == NetmodeID.MultiplayerClient || !CanStartAction(player, out NormalSickle? sickle))
return false;
MyPlayer modPlayer = player.GetModPlayer<MyPlayer>();
ReaperFormId form = ReaperCombatRegistry.ResolveUsableForm(modPlayer.ReaperProgression);
ReaperStage stage = ReaperDefinitions.IsBranchForm(form)
? modPlayer.ReaperProgression.GetStage(form)
: ReaperStage.StageIII;
// Death's invocation is a held channel. It ends only when the key is
// released and deliberately has no start or recovery cooldown.
if (form != ReaperFormId.Death
&& !modPlayer.TryStartReaperSpecialCooldown(
ReaperCombatRegistry.GetSpecialCooldown(form, stage)))
return false;
int damage = Math.Max(1, player.GetWeaponDamage(player.HeldItem));
float knockback = player.GetWeaponKnockback(player.HeldItem, player.HeldItem.knockBack);
SickleCombatSnapshot snapshot = SickleCombatSnapshot.Capture(player, damage, knockback);
if (!float.IsFinite(aim.X) || !float.IsFinite(aim.Y))
{
modPlayer.RefundFailedReaperSpecial();
return false;
}
// Specials retain the cursor offset as well as its direction. Death's
// summoned scythes need an actual world-space destination instead of a
// normalized vector that can only point at a stationary orbit.
float targetDistance = aim.Length();
if (targetDistance > 2000f)
aim *= 2000f / targetDistance;
else if (targetDistance < 2f)
aim = Vector2.UnitX * player.direction * 240f;
int controllerIndex = ReaperActionControllerProjectile.Spawn(
player.GetSource_Misc("SoulHarvest:ReaperSpecial"), player, snapshot, aim, ultimate: false);
if (controllerIndex < 0)
{
modPlayer.RefundFailedReaperSpecial();
return false;
}
CancelPrimarySwingsForAction(player);
return true;
}
public static bool TryReleaseSpecial(Player player)
{
if (Main.netMode == NetmodeID.MultiplayerClient || !player.active)
return false;
int controllerType = ModContent.ProjectileType<ReaperActionControllerProjectile>();
for (int index = 0; index < Main.maxProjectiles; index++)
{
Projectile projectile = Main.projectile[index];
if (!projectile.active || projectile.owner != player.whoAmI || projectile.type != controllerType
|| projectile.ModProjectile is not ReaperActionControllerProjectile controller)
continue;
return controller.RequestRelease();
}
return false;
}
public static bool TryUpdateSpecialTarget(Player player, Vector2 cursorOffset)
{
if (Main.netMode == NetmodeID.MultiplayerClient || !player.active
|| !float.IsFinite(cursorOffset.X) || !float.IsFinite(cursorOffset.Y))
{
return false;
}
int controllerType = ModContent.ProjectileType<ReaperActionControllerProjectile>();
foreach (Projectile projectile in Main.ActiveProjectiles)
{
if (projectile.owner != player.whoAmI || projectile.type != controllerType
|| projectile.ModProjectile is not ReaperActionControllerProjectile controller)
{
continue;
}
return controller.UpdateSpecialTarget(cursorOffset);
}
return false;
}
public static bool TryStartUltimate(Player player, Vector2 aim)
{
if (Main.netMode == NetmodeID.MultiplayerClient || !CanStartAction(player, out _))
return false;
if (!float.IsFinite(aim.X) || !float.IsFinite(aim.Y))
return false;
MyPlayer modPlayer = player.GetModPlayer<MyPlayer>();
ReaperFormId form = ReaperCombatRegistry.ResolveUsableForm(modPlayer.ReaperProgression);
if (form == ReaperFormId.Base)
return false;
if (ReaperDefinitions.IsBranchForm(form) && modPlayer.ReaperProgression.GetStage(form) != ReaperStage.StageIII)
return false;
if (!modPlayer.TryConsumeReaperUltimate(form, ReaperCombatRegistry.GetUltimateCooldown(form)))
return false;
int damage = Math.Max(1, player.GetWeaponDamage(player.HeldItem));
float knockback = player.GetWeaponKnockback(player.HeldItem, player.HeldItem.knockBack);
SickleCombatSnapshot snapshot = SickleCombatSnapshot.Capture(player, damage, knockback);
float targetDistance = aim.Length();
if (targetDistance > 2000f)
aim *= 2000f / targetDistance;
else if (targetDistance < 2f)
aim = Vector2.UnitX * player.direction * 120f;
int controllerIndex = ReaperActionControllerProjectile.Spawn(
player.GetSource_Misc("SoulHarvest:ReaperUltimate"), player, snapshot, aim, ultimate: true);
if (controllerIndex < 0)
{
modPlayer.RefundFailedReaperUltimate(form);
return false;
}
CancelPrimarySwingsForAction(player);
player.GetModPlayer<ReaperCombatPlayer>().BeginUltimate(form);
modPlayer.SyncReaperCombatRuntime();
return true;
}
internal static void OnPrimaryReleased(Projectile swing, in SickleCombatSnapshot snapshot, int phase, Vector2 origin, Vector2 aim)
{
if (Main.netMode == NetmodeID.MultiplayerClient)
return;
IEntitySource source = swing.GetSource_FromThis();
int lastPhase = ReaperCombatRegistry.GetPrimaryPhaseCount(snapshot.Form, snapshot.Stage) - 1;
switch (snapshot.Form)
{
case ReaperFormId.Bone when snapshot.Stage >= ReaperStage.StageIII && phase == lastPhase:
// Stage III closes the assembled execution frame in place. Keep the
// former wisp's damage budget, but do not launch a homing projectile
// whose path no longer matches the giant bone guillotine storyboard.
ReaperStrikeProjectile.Spawn(source, swing.owner, snapshot,
ReaperHitKind.PrimaryDerived, phase, origin + aim * 118f, aim,
ReaperStrikeShape.Cross, 118f, 27f, 0.75f, 10,
actionId: swing.identity);
break;
case ReaperFormId.Blood:
{
// Blood's learned unique attack is now one coherent projectile
// family: Lv.1/2/3 launches one/two/three of the same energy crescents on
// every primary swing. The ultimate uses this exact actor too.
int level = Math.Clamp(Math.Max(1, (int)snapshot.Skill1), 1, 3);
int lifetime = ReaperBloodBladeProjectile.GetScarLifetime(level);
Vector2 normal = aim.RotatedBy(MathHelper.PiOver2);
for (int blade = 0; blade < level; blade++)
{
float centered = blade - (level - 1) * 0.5f;
Vector2 launch = aim.RotatedBy(centered * 0.14f);
ReaperBloodBladeProjectile.Spawn(source, swing.owner, snapshot,
origin + normal * centered * 18f + launch * 24f,
launch * (13f + blade * 0.5f), phase * 3 + blade,
swing.identity, ReaperHitKind.PrimaryDerived,
damageMultiplier: 0.75f, maximumPasses: 2,
scarLifetime: lifetime);
}
Player player = Main.player[swing.owner];
if (player.active && player.GetModPlayer<ReaperCombatPlayer>()
.TryReserveSkillCue(ReaperFormId.Blood, 0, swing.identity))
{
ReaperSkillCueProjectile.Spawn(source, swing.owner, snapshot, 0,
origin + aim * 48f, aim, actionId: swing.identity);
}
break;
}
case ReaperFormId.Frost when snapshot.Stage >= ReaperStage.StageII && phase == lastPhase:
{
// Both evolved stages use the skill-level-driven single refracted
// crystal blade; Stage III no longer falls back to the old shard.
ReaperFrostShardProjectile.Spawn(source, swing.owner, snapshot, origin + aim * 24f, aim);
break;
}
case ReaperFormId.Soul:
{
float factor = snapshot.Skill1 switch { 2 => 0.625f, >= 3 => 0.75f, _ => 0.50f };
SoulSickleProjectile.Spawn(source, swing.owner, snapshot,
origin + aim * 42f, aim * (14f + snapshot.StageNumber), phase,
swing.identity, ReaperHitKind.PrimaryDerived, factor,
variant: phase);
ReaperSkillCueProjectile.Spawn(source, swing.owner, snapshot, 0,
origin + aim * 54f, aim, variant: phase,
actionId: swing.identity);
if (snapshot.Stage >= ReaperStage.StageII && phase == lastPhase)
ReaperSoulFamiliarProjectile.Spawn(source, swing.owner, snapshot, origin);
if (snapshot.Stage >= ReaperStage.StageIII && phase == lastPhase)
{
// The giant soul echo is a fixed execution beat, not another
// seeker. Preserve the removed projectile's total multiplier.
ReaperStrikeProjectile.Spawn(source, swing.owner, snapshot,
ReaperHitKind.PrimaryDerived, phase, origin + aim * 78f, aim,
ReaperStrikeShape.Circle, 108f, 28f, 0.65f, 12,
actionId: swing.identity);
}
break;
}
case ReaperFormId.Infernal when snapshot.Stage >= ReaperStage.StageII && phase == lastPhase:
// Stage III folds the former seeking crescent budget into the four
// visible track detonations so every damage event stays on the fire
// route drawn by the finisher.
float infernalDetonationFactor = snapshot.Stage >= ReaperStage.StageIII
? (0.88f + 0.58f) / 4f
: 0.22f;
for (int index = 0; index < 4; index++)
{
ReaperStrikeProjectile.Spawn(source, swing.owner, snapshot, ReaperHitKind.PrimaryDerived, phase,
origin + aim * (32f + index * 34f), aim, ReaperStrikeShape.Circle, 34f, 16f,
infernalDetonationFactor, 8 + index * 8, actionId: swing.identity);
}
break;
}
}
internal static void OnReaperHit(Projectile projectile, NPC target, int damageDone, MyGlobalProjectile data, int uniqueHitIndex)
{
if (Main.netMode == NetmodeID.MultiplayerClient || !data.HasReaperSnapshot
|| projectile.owner < 0 || projectile.owner >= Main.maxPlayers)
return;
Player player = Main.player[projectile.owner];
if (!player.active)
return;
SickleCombatSnapshot snapshot = data.ReaperSnapshot;
NPC statusTarget = GetStatusTarget(target);
ReaperCombatGlobalNPC status = statusTarget.GetGlobalNPC<ReaperCombatGlobalNPC>();
bool bossTarget = IsBossLike(statusTarget);
player.GetModPlayer<MyPlayer>().RecordReaperAttackHit(snapshot.Form, data.ReaperHitKind);
switch (snapshot.Form)
{
case ReaperFormId.Bone:
{
if (data.ReaperHitKind == ReaperHitKind.Ultimate && !bossTarget)
target.velocity = Vector2.Lerp(target.velocity, (player.Center - target.Center).SafeNormalize(Vector2.Zero) * 5f, 0.5f);
int amount = data.ReaperHitKind == ReaperHitKind.Special && data.ReaperPhase == 0 ? 2 : 1;
if (data.ReaperHitKind == ReaperHitKind.Primary
|| data.ReaperHitKind == ReaperHitKind.Special && data.ReaperPhase == 0)
status.AddBoneMarks(GetStatusTarget(target), player, snapshot, amount, data.ReaperActionId);
if (data.ReaperHitKind == ReaperHitKind.Special && data.ReaperPhase == 0 && !bossTarget)
target.velocity = Vector2.Lerp(target.velocity, (player.Center - target.Center).SafeNormalize(Vector2.Zero) * 8f, 0.65f);
if (data.ReaperHitKind == ReaperHitKind.Primary
&& data.ReaperPhase == ReaperCombatRegistry.GetPrimaryPhaseCount(snapshot.Form, snapshot.Stage) - 1
&& snapshot.Stage >= ReaperStage.StageII)
{
if (snapshot.Stage == ReaperStage.StageII)
{
ReaperStrikeProjectile.Spawn(projectile.GetSource_FromThis(), player.whoAmI, snapshot,
ReaperHitKind.PrimaryDerived, data.ReaperPhase, target.Center, Vector2.UnitY,
ReaperStrikeShape.Line, 130f, 24f, 0.55f, 14);
}
else
{
ReaperStrikeProjectile.Spawn(projectile.GetSource_FromThis(), player.whoAmI, snapshot,
ReaperHitKind.PrimaryDerived, data.ReaperPhase, target.Center, Vector2.UnitY,
ReaperStrikeShape.Line, 135f, 25f, 0.45f, 14);
ReaperStrikeProjectile.Spawn(projectile.GetSource_FromThis(), player.whoAmI, snapshot,
ReaperHitKind.PrimaryDerived, data.ReaperPhase, target.Center, Vector2.UnitX,
ReaperStrikeShape.Line, 135f, 25f, 0.45f, 15);
}
}
break;
}
case ReaperFormId.Blood:
bool bleedingBeforeHit = target.HasBuff(BuffID.Bleeding);
target.AddBuff(BuffID.Bleeding, 60 * 4);
if (data.ReaperHitKind == ReaperHitKind.Primary
&& projectile.ModProjectile is ReaperStrikeProjectile)
{
int lifetime = ReaperBloodBladeProjectile.GetScarLifetime(
Math.Clamp(Math.Max(1, (int)snapshot.Skill1), 1, 3));
ReaperBloodScarProjectile.Spawn(projectile.GetSource_FromThis(),
player.whoAmI, snapshot, target.Center,
data.AttackAngle.ToRotationVector2(), 190f, 22f,
lifetime, 0.25f, data.ReaperPhase, data.ReaperActionId);
}
if (data.ReaperHitKind != ReaperHitKind.DamageOverTime)
{
player.GetModPlayer<ReaperCombatPlayer>().ApplyBloodHealing(
snapshot, damageDone, target.Center);
}
if (bleedingBeforeHit
&& snapshot.Stage >= ReaperStage.StageII
&& data.ReaperHitKind == ReaperHitKind.Primary
&& data.ReaperPhase == ReaperCombatRegistry.GetPrimaryPhaseCount(snapshot.Form, snapshot.Stage) - 1)
{
player.GetModPlayer<ReaperCombatPlayer>().ApplyBloodFinisherHealing(
projectile.identity, target.Center, snapshot.Skill1);
}
break;
case ReaperFormId.Infernal:
target.AddBuff(BuffID.OnFire3, 60 * 5);
if (data.ReaperHitKind is ReaperHitKind.Primary or ReaperHitKind.Special)
{
player.GetModPlayer<ReaperCombatPlayer>().GrantInfernalMomentum();
if (data.ReaperHitKind == ReaperHitKind.Special)
player.GetModPlayer<ReaperCombatPlayer>().RecordInfernalSpecialHit(snapshot, data.ReaperPhase, projectile);
}
break;
case ReaperFormId.Frost:
if (data.ReaperHitKind == ReaperHitKind.Ultimate)
{
if (data.ReaperPhase == 0)
status.ApplyFrostFreeze(GetStatusTarget(target), 60);
if (data.ReaperPhase == 6)
status.ShatterFrost(GetStatusTarget(target), player, snapshot, data.ReaperActionId,
canChargeUltimate: false);
}
if (data.ReaperHitKind == ReaperHitKind.Primary)
status.AddFrostMark(GetStatusTarget(target), player, snapshot,
shatter: data.ReaperPhase >= ReaperCombatRegistry.GetPrimaryPhaseCount(snapshot.Form, snapshot.Stage) - 1,
data.ReaperActionId);
else if (data.ReaperHitKind == ReaperHitKind.Special && data.ReaperPhase == 0)
status.AddFrostMark(GetStatusTarget(target), player, snapshot, shatter: false,
data.ReaperActionId);
else if (data.ReaperHitKind == ReaperHitKind.Special && data.ReaperPhase == 2 && snapshot.Stage >= ReaperStage.StageIII)
{
bool wasFull = status.HasFullFrostMarks(player.whoAmI);
status.ShatterFrost(GetStatusTarget(target), player, snapshot, data.ReaperActionId);
if (wasFull && snapshot.Skill3 > 0)
{
float echo = snapshot.Skill3 switch { 2 => 0.55f, >= 3 => 0.75f, _ => 0.35f };
ReaperStrikeProjectile.Spawn(projectile.GetSource_FromThis(), player.whoAmI, snapshot,
ReaperHitKind.PrimaryDerived, data.ReaperPhase, target.Center, data.AttackAngle.ToRotationVector2(),
ReaperStrikeShape.Cross, 115f, 24f, echo, 12);
}
}
break;
case ReaperFormId.Soul:
if (data.ReaperHitKind is ReaperHitKind.Primary or ReaperHitKind.Special)
{
NPC soulTarget = GetStatusTarget(target);
ReaperCombatPlayer combatPlayer = player.GetModPlayer<ReaperCombatPlayer>();
if (combatPlayer.TryRecordSoulShard(data.ReaperActionId, soulTarget.whoAmI))
{
if (!status.TryTakeSoulShard(player.whoAmI))
{
combatPlayer.RollbackSoulShardReservation(data.ReaperActionId, soulTarget.whoAmI);
}
else if (!ReaperSoulShardProjectile.Spawn(
projectile.GetSource_FromThis(), player, target.Center))
{
status.RefundSoulShardCooldown(player.whoAmI);
combatPlayer.RollbackSoulShardReservation(data.ReaperActionId, soulTarget.whoAmI);
}
}
if (snapshot.Stage >= ReaperStage.StageIII)
status.AddFatedSoulStack(soulTarget, player, snapshot, data.ReaperActionId,
data.ReaperHitKind == ReaperHitKind.Special && data.ReaperPhase >= 8 ? 2 : 1);
}
break;
case ReaperFormId.Void:
if (data.ReaperHitKind is ReaperHitKind.Primary or ReaperHitKind.Special)
{
int visualActionId = ReaperVoidHitActionTokenGlobalProjectile.Resolve(
projectile, data.ReaperActionId);
ReaperVoidHitCueProjectile.Spawn(projectile.GetSource_FromThis(), player.whoAmI,
snapshot.Stage, visualActionId, target.whoAmI, target.Center,
data.AttackAngle.ToRotationVector2());
status.RecordVoidCut(GetStatusTarget(target), player, snapshot, data.AttackAngle, data.ReaperActionId);
if (data.ReaperHitKind == ReaperHitKind.Primary
&& projectile.ModProjectile is ReaperStrikeProjectile)
{
int skill = Math.Clamp(Math.Max(1, (int)snapshot.Skill2), 1, 3);
ReaperVoidRiftProjectile.Spawn(projectile.GetSource_FromThis(),
player.whoAmI, snapshot, target.Center,
data.AttackAngle.ToRotationVector2(), 190f,
10f + skill * 2.5f,
ReaperVoidArcRiftProjectile.GetLifetime(skill),
130f + skill * 42f, 0.25f, data.ReaperPhase,
data.ReaperActionId);
}
}
break;
case ReaperFormId.Death:
if (data.ReaperHitKind is ReaperHitKind.Primary or ReaperHitKind.PrimaryDerived)
player.GetModPlayer<MyPlayer>().ApplyDeathReaperLifeSteal(damageDone,
target.Center);
if (data.ReaperHitKind == ReaperHitKind.Ultimate)
{
player.GetModPlayer<ReaperCombatPlayer>().ApplyDeathUltimateHealing(damageDone);
if (data.ReaperPhase < 60)
{
status.RecordDeathUltimateCut(statusTarget, player.whoAmI,
data.ReaperActionId, damageDone);
player.GetModPlayer<MyPlayer>()
.QueueDeathDomainHarvestFromReaperHit(target,
data.AttackAngle, data.ReaperActionId);
}
}
if (data.ReaperHitKind == ReaperHitKind.Primary)
{
MyPlayer modPlayer = player.GetModPlayer<MyPlayer>();
modPlayer.RecordDeathCycleHit(data.ReaperPhase);
modPlayer.QueueDeathDomainHarvestFromReaperHit(target,
data.AttackAngle);
if (data.ReaperPhase == 2)
target.AddBuff(BuffID.OnFire3, 60 * 3);
if (data.ReaperPhase == 3 && !bossTarget)
target.velocity *= 0.72f;
}
break;
}
}
internal static NPC GetStatusTarget(NPC target)
{
return target.realLife >= 0 && target.realLife < Main.maxNPCs && Main.npc[target.realLife].active
? Main.npc[target.realLife]
: target;
}
private static bool IsBossLike(NPC npc)
{
return npc.boss
|| NPCID.Sets.ShouldBeCountedAsBoss[npc.type]
|| npc.realLife >= 0
&& npc.realLife < Main.maxNPCs
&& Main.npc[npc.realLife].active
&& (Main.npc[npc.realLife].boss
|| NPCID.Sets.ShouldBeCountedAsBoss[Main.npc[npc.realLife].type]);
}
private static bool CanStartAction(Player player, out NormalSickle? sickle)
{
sickle = player.HeldItem.ModItem as NormalSickle;
return sickle is not null
&& player.active
&& player.GetModPlayer<MyPlayer>().ServerCharacterStateReady
&& !player.dead
&& !player.CCed
&& !player.noItems
&& !player.GetModPlayer<MyPlayer>().IsReaperFormAssemblyActive()
&& player.ownedProjectileCounts[ModContent.ProjectileType<ReaperActionControllerProjectile>()] <= 0;
}
private static void CancelPrimarySwingsForAction(Player player)
{
int swingType = ModContent.ProjectileType<SickleSwingProjectile>();
foreach (Projectile projectile in Main.ActiveProjectiles)
{
if (projectile.owner == player.whoAmI && projectile.type == swingType)
projectile.Kill();
}
int owner = player.whoAmI;
if (owner >= 0 && owner < Main.maxPlayers)
{
ReaperCombatSystem.HasActivePrimaryProjectile[owner] = false;
ReaperCombatSystem.ActivePrimaryProjectile[owner] = -1;
}
player.itemAnimation = 0;
player.itemTime = 0;
}
private static void SpawnLegacyProjectile<T>(IEntitySource source, int owner, Vector2 position, Vector2 velocity,
in SickleCombatSnapshot snapshot, float damageFactor, ReaperHitKind kind, int phase,
float ai0 = 0f, float ai1 = 0f, float ai2 = 0f) where T : ModProjectile
{
int index = Projectile.NewProjectile(source, position, velocity, ModContent.ProjectileType<T>(),
Math.Max(1, (int)Math.Round(snapshot.Damage * damageFactor)), snapshot.Knockback, owner,
ai0, ai1, ai2);
if (index < 0 || index >= Main.maxProjectiles)
return;
Projectile projectile = Main.projectile[index];
projectile.originalDamage = snapshot.Damage;
projectile.CritChance = kind == ReaperHitKind.ReturningArc ? snapshot.CritChance : 0;
if (projectile.ModProjectile is BloodCrescentProjectile bloodCrescent)
bloodCrescent.AuthorizeServerMotion();
else if (projectile.ModProjectile is DeathSickleProjectile deathBlade)
deathBlade.AuthorizeServerMotion();
projectile.GetGlobalProjectile<MyGlobalProjectile>().ConfigureReaperProjectile(projectile, snapshot, kind, phase, velocity.ToRotation());
projectile.netUpdate = true;
}
private static void SpawnOrReplaceDeathBlade(IEntitySource source, int owner, Vector2 position, Vector2 velocity,
in SickleCombatSnapshot snapshot, int phase)
{
if (Main.netMode == NetmodeID.MultiplayerClient)
return;
// DeathSickleProjectile is also a legacy-compatible content type. Only replace
// the 3.0 Death form's sixth-phase derived blade; unrelated legacy instances
// deliberately have no matching Reaper snapshot and remain untouched.
for (int index = 0; index < Main.maxProjectiles; index++)
{
Projectile existing = Main.projectile[index];
if (!existing.active || existing.owner != owner
|| existing.ModProjectile is not DeathSickleProjectile)
{
continue;
}
MyGlobalProjectile data = existing.GetGlobalProjectile<MyGlobalProjectile>();
if (data.HasReaperSnapshot
&& data.ReaperSnapshot.Form == ReaperFormId.Death
&& data.ReaperHitKind == ReaperHitKind.PrimaryDerived
&& data.ReaperPhase == 5)
{
existing.Kill();
}
}
SpawnLegacyProjectile<DeathSickleProjectile>(source, owner, position, velocity,
snapshot, 0.7f, ReaperHitKind.PrimaryDerived, phase);
}
}
using SoulHarvest.Items;
using SoulHarvest.Projectiles;
using Microsoft.Xna.Framework;
using System;
using Terraria;
using Terraria.DataStructures;
using Terraria.ID;
using Terraria.ModLoader;
namespace SoulHarvest.Common;
public static class ReaperCombatService
{
internal static void ResetPlayerState(int owner)
{
if (owner < 0 || owner >= Main.maxPlayers)
return;
for (int form = 0; form < ReaperCombatSystem.ComboPhase.GetLength(1); form++)
{
ReaperCombatSystem.ComboPhase[owner, form] = 0;
ReaperCombatSystem.LastComboTick[owner, form] = 0;
}
ReaperCombatSystem.ActivePrimaryProjectile[owner] = -1;
ReaperCombatSystem.HasActivePrimaryProjectile[owner] = false;
ReaperCombatSystem.NextPrimaryAuthorizationTick[owner] = 0;
ReaperCombatSystem.DeathPrimaryChain[owner] = 0;
ReaperCombatSystem.LastDeathPrimaryTick[owner] = 0;
ReaperCombatSystem.DeathPrimaryAssemblyReady[owner] = false;
}
internal static bool TryAuthorizePrimarySwing(Player player, Projectile projectile,
in SickleCombatSnapshot snapshot, out int phase, out float deathTempo,
out bool requireDeathAssembly)
{
phase = 0;
deathTempo = 1f;
requireDeathAssembly = false;
if (Main.netMode != NetmodeID.Server
|| !player.active
|| player.dead
|| player.HeldItem.ModItem is not NormalSickle
|| !player.GetModPlayer<MyPlayer>().ServerCharacterStateReady
|| player.ownedProjectileCounts[ModContent.ProjectileType<ReaperActionControllerProjectile>()] > 0
|| player.GetModPlayer<MyPlayer>().IsReaperFormAssemblyActive()
|| projectile.owner != player.whoAmI)
{
return false;
}
// The projectile packet can arrive after the very short item-use timer
// has already reached zero, especially once Death reaches its high-speed
// cap. Authorization is bounded by the authoritative per-owner cadence.
// Do not reject merely because the previous projectile is still alive on
// its harmless cleanup frame: at Death's fast cadence that frame overlaps
// the next legal request and used to turn one rejection into a 180-frame
// client authority wait.
int owner = player.whoAmI;
ulong now = Main.GameUpdateCount;
if (now < ReaperCombatSystem.NextPrimaryAuthorizationTick[owner])
return false;
// The cadence check above already admits at most one request for this
// owner in the current window. Replace the diagnostic active pointer with
// the newly authorized swing; the old projectile will finish naturally.
ReaperCombatSystem.HasActivePrimaryProjectile[owner] = false;
requireDeathAssembly = snapshot.Form == ReaperFormId.Death
&& !ReaperCombatSystem.DeathPrimaryAssemblyReady[owner];
phase = TakeNextPrimaryPhase(player, snapshot, out deathTempo);
ReaperCombatSystem.ActivePrimaryProjectile[owner] = projectile.whoAmI;
ReaperCombatSystem.HasActivePrimaryProjectile[owner] = true;
// Item reuse and the authoritative held projectile already enforce the
// real animation duration. This clock is only duplicate-packet protection.
// Basing it on the whole previous swing made normal network jitter reject
// the next legitimate owner projectile; the client then held its predicted
// terminal pose for the 180-frame authority timeout.
int minimumAuthorizationGap = snapshot.Form == ReaperFormId.Death ? 2 : 6;
ReaperCombatSystem.NextPrimaryAuthorizationTick[owner] = now
+ (ulong)minimumAuthorizationGap;
return true;
}
internal static bool RequiresDeathPrimaryAssembly(Player player)
{
int owner = player.whoAmI;
return owner < 0 || owner >= Main.maxPlayers
|| !ReaperCombatSystem.DeathPrimaryAssemblyReady[owner];
}
internal static void CompleteDeathPrimaryAssembly(Player player)
{
int owner = player.whoAmI;
if (owner >= 0 && owner < Main.maxPlayers)
ReaperCombatSystem.DeathPrimaryAssemblyReady[owner] = true;
}
internal static void ReleaseDeathPrimaryHold(Player player)
{
int owner = player.whoAmI;
if (owner < 0 || owner >= Main.maxPlayers)
return;
ReaperCombatSystem.DeathPrimaryAssemblyReady[owner] = false;
int swingType = ModContent.ProjectileType<SickleSwingProjectile>();
foreach (Projectile projectile in Main.ActiveProjectiles)
{
if (projectile.owner != owner || projectile.type != swingType
|| projectile.ModProjectile is not SickleSwingProjectile swing)
{
continue;
}
swing.RequestDeathAssemblyCancellation();
}
}
internal static int TakeNextPrimaryPhase(Player player, in SickleCombatSnapshot snapshot)
=> TakeNextPrimaryPhase(player, snapshot, out _);
internal static int TakeNextPrimaryPhase(Player player, in SickleCombatSnapshot snapshot,
out float deathTempo)
{
deathTempo = 1f;
int formIndex = (int)snapshot.Form;
int count = ReaperCombatRegistry.GetPrimaryPhaseCount(snapshot.Form, snapshot.Stage);
ulong now = Main.GameUpdateCount;
if (now - ReaperCombatSystem.LastComboTick[player.whoAmI, formIndex] > 90UL)
ReaperCombatSystem.ComboPhase[player.whoAmI, formIndex] = 0;
int phase = ReaperCombatSystem.ComboPhase[player.whoAmI, formIndex] % count;
ReaperCombatSystem.ComboPhase[player.whoAmI, formIndex] = (byte)((phase + 1) % count);
ReaperCombatSystem.LastComboTick[player.whoAmI, formIndex] = now;
if (snapshot.Form == ReaperFormId.Death)
{
int owner = player.whoAmI;
bool continuing = now - ReaperCombatSystem.LastDeathPrimaryTick[owner]
<= DeathPrimaryChainResetFrames;
int chain = continuing ? ReaperCombatSystem.DeathPrimaryChain[owner] + 1 : 1;
ReaperCombatSystem.DeathPrimaryChain[owner] = (byte)Math.Min(DeathPrimaryMaximumChain, chain);
ReaperCombatSystem.LastDeathPrimaryTick[owner] = now;
deathTempo = GetDeathTempo(ReaperCombatSystem.DeathPrimaryChain[owner],
snapshot.DeathPrimaryMaximumTempo);
}
return phase;
}
// Death takes a sustained uninterrupted chain to reach its separately
// upgraded primary cap. The held invocation keeps its own lower cap.
internal const int DeathPrimaryMaximumChain = 24;
private const ulong DeathPrimaryChainResetFrames = 46UL;
internal static float GetDeathPrimaryTempo(Player player)
{
if (player.whoAmI < 0 || player.whoAmI >= Main.maxPlayers
|| Main.GameUpdateCount - ReaperCombatSystem.LastDeathPrimaryTick[player.whoAmI]
> DeathPrimaryChainResetFrames)
{
return 1f;
}
int capLevel = player.GetModPlayer<MyPlayer>().ReaperProgression
.GetCommonNodeLevel(ReaperCommonNode.DeathTempoCap);
return GetDeathTempo(ReaperCombatSystem.DeathPrimaryChain[player.whoAmI],
ReaperDefinitions.GetDeathPrimaryTempoCap(capLevel));
}
private static float GetDeathTempo(int chain, float maximumTempo)
{
float progress = MathHelper.Clamp((chain - 1f) / (DeathPrimaryMaximumChain - 1f), 0f, 1f);
progress = progress * progress * (3f - 2f * progress);
return MathHelper.Lerp(1f, MathHelper.Clamp(maximumTempo, 1f,
ReaperCombatRegistry.DeathPrimaryMaximumTempo), progress);
}
public static bool TryStartSpecial(Player player, Vector2 aim)
{
if (Main.netMode == NetmodeID.MultiplayerClient || !CanStartAction(player, out NormalSickle? sickle))
return false;
MyPlayer modPlayer = player.GetModPlayer<MyPlayer>();
ReaperFormId form = ReaperCombatRegistry.ResolveUsableForm(modPlayer.ReaperProgression);
ReaperStage stage = ReaperDefinitions.IsBranchForm(form)
? modPlayer.ReaperProgression.GetStage(form)
: ReaperStage.StageIII;
// Death's invocation is a held channel. It ends only when the key is
// released and deliberately has no start or recovery cooldown.
if (form != ReaperFormId.Death
&& !modPlayer.TryStartReaperSpecialCooldown(
ReaperCombatRegistry.GetSpecialCooldown(form, stage)))
return false;
int damage = Math.Max(1, player.GetWeaponDamage(player.HeldItem));
float knockback = player.GetWeaponKnockback(player.HeldItem, player.HeldItem.knockBack);
SickleCombatSnapshot snapshot = SickleCombatSnapshot.Capture(player, damage, knockback);
if (!float.IsFinite(aim.X) || !float.IsFinite(aim.Y))
{
modPlayer.RefundFailedReaperSpecial();
return false;
}
// Specials retain the cursor offset as well as its direction. Death's
// summoned scythes need an actual world-space destination instead of a
// normalized vector that can only point at a stationary orbit.
float targetDistance = aim.Length();
if (targetDistance > 2000f)
aim *= 2000f / targetDistance;
else if (targetDistance < 2f)
aim = Vector2.UnitX * player.direction * 240f;
int controllerIndex = ReaperActionControllerProjectile.Spawn(
player.GetSource_Misc("SoulHarvest:ReaperSpecial"), player, snapshot, aim, ultimate: false);
if (controllerIndex < 0)
{
modPlayer.RefundFailedReaperSpecial();
return false;
}
CancelPrimarySwingsForAction(player);
return true;
}
public static bool TryReleaseSpecial(Player player)
{
if (Main.netMode == NetmodeID.MultiplayerClient || !player.active)
return false;
int controllerType = ModContent.ProjectileType<ReaperActionControllerProjectile>();
for (int index = 0; index < Main.maxProjectiles; index++)
{
Projectile projectile = Main.projectile[index];
if (!projectile.active || projectile.owner != player.whoAmI || projectile.type != controllerType
|| projectile.ModProjectile is not ReaperActionControllerProjectile controller)
continue;
return controller.RequestRelease();
}
return false;
}
public static bool TryUpdateSpecialTarget(Player player, Vector2 cursorOffset)
{
if (Main.netMode == NetmodeID.MultiplayerClient || !player.active
|| !float.IsFinite(cursorOffset.X) || !float.IsFinite(cursorOffset.Y))
{
return false;
}
int controllerType = ModContent.ProjectileType<ReaperActionControllerProjectile>();
foreach (Projectile projectile in Main.ActiveProjectiles)
{
if (projectile.owner != player.whoAmI || projectile.type != controllerType
|| projectile.ModProjectile is not ReaperActionControllerProjectile controller)
{
continue;
}
return controller.UpdateSpecialTarget(cursorOffset);
}
return false;
}
public static bool TryStartUltimate(Player player, Vector2 aim)
{
if (Main.netMode == NetmodeID.MultiplayerClient || !CanStartAction(player, out _))
return false;
if (!float.IsFinite(aim.X) || !float.IsFinite(aim.Y))
return false;
MyPlayer modPlayer = player.GetModPlayer<MyPlayer>();
ReaperFormId form = ReaperCombatRegistry.ResolveUsableForm(modPlayer.ReaperProgression);
if (form == ReaperFormId.Base)
return false;
if (ReaperDefinitions.IsBranchForm(form) && modPlayer.ReaperProgression.GetStage(form) != ReaperStage.StageIII)
return false;
if (!modPlayer.TryConsumeReaperUltimate(form, ReaperCombatRegistry.GetUltimateCooldown(form)))
return false;
int damage = Math.Max(1, player.GetWeaponDamage(player.HeldItem));
float knockback = player.GetWeaponKnockback(player.HeldItem, player.HeldItem.knockBack);
SickleCombatSnapshot snapshot = SickleCombatSnapshot.Capture(player, damage, knockback);
float targetDistance = aim.Length();
if (targetDistance > 2000f)
aim *= 2000f / targetDistance;
else if (targetDistance < 2f)
aim = Vector2.UnitX * player.direction * 120f;
int controllerIndex = ReaperActionControllerProjectile.Spawn(
player.GetSource_Misc("SoulHarvest:ReaperUltimate"), player, snapshot, aim, ultimate: true);
if (controllerIndex < 0)
{
modPlayer.RefundFailedReaperUltimate(form);
return false;
}
CancelPrimarySwingsForAction(player);
player.GetModPlayer<ReaperCombatPlayer>().BeginUltimate(form);
modPlayer.SyncReaperCombatRuntime();
return true;
}
internal static void OnPrimaryReleased(Projectile swing, in SickleCombatSnapshot snapshot, int phase, Vector2 origin, Vector2 aim)
{
if (Main.netMode == NetmodeID.MultiplayerClient)
return;
IEntitySource source = swing.GetSource_FromThis();
int lastPhase = ReaperCombatRegistry.GetPrimaryPhaseCount(snapshot.Form, snapshot.Stage) - 1;
switch (snapshot.Form)
{
case ReaperFormId.Bone when snapshot.Stage >= ReaperStage.StageIII && phase == lastPhase:
// Stage III closes the assembled execution frame in place. Keep the
// former wisp's damage budget, but do not launch a homing projectile
// whose path no longer matches the giant bone guillotine storyboard.
ReaperStrikeProjectile.Spawn(source, swing.owner, snapshot,
ReaperHitKind.PrimaryDerived, phase, origin + aim * 118f, aim,
ReaperStrikeShape.Cross, 118f, 27f, 0.75f, 10,
actionId: swing.identity);
break;
case ReaperFormId.Blood:
{
// Blood's learned unique attack is now one coherent projectile
// family: Lv.1/2/3 launches one/two/three of the same energy crescents on
// every primary swing. The ultimate uses this exact actor too.
int level = Math.Clamp(Math.Max(1, (int)snapshot.Skill1), 1, 3);
int lifetime = ReaperBloodBladeProjectile.GetScarLifetime(level);
Vector2 normal = aim.RotatedBy(MathHelper.PiOver2);
for (int blade = 0; blade < level; blade++)
{
float centered = blade - (level - 1) * 0.5f;
Vector2 launch = aim.RotatedBy(centered * 0.14f);
ReaperBloodBladeProjectile.Spawn(source, swing.owner, snapshot,
origin + normal * centered * 18f + launch * 24f,
launch * (13f + blade * 0.5f), phase * 3 + blade,
swing.identity, ReaperHitKind.PrimaryDerived,
damageMultiplier: 0.75f, maximumPasses: 2,
scarLifetime: lifetime);
}
Player player = Main.player[swing.owner];
if (player.active && player.GetModPlayer<ReaperCombatPlayer>()
.TryReserveSkillCue(ReaperFormId.Blood, 0, swing.identity))
{
ReaperSkillCueProjectile.Spawn(source, swing.owner, snapshot, 0,
origin + aim * 48f, aim, actionId: swing.identity);
}
break;
}
case ReaperFormId.Frost when snapshot.Stage >= ReaperStage.StageII && phase == lastPhase:
{
// Both evolved stages use the skill-level-driven single refracted
// crystal blade; Stage III no longer falls back to the old shard.
ReaperFrostShardProjectile.Spawn(source, swing.owner, snapshot, origin + aim * 24f, aim);
break;
}
case ReaperFormId.Soul:
{
float factor = snapshot.Skill1 switch { 2 => 0.625f, >= 3 => 0.75f, _ => 0.50f };
SoulSickleProjectile.Spawn(source, swing.owner, snapshot,
origin + aim * 42f, aim * (14f + snapshot.StageNumber), phase,
swing.identity, ReaperHitKind.PrimaryDerived, factor,
variant: phase);
ReaperSkillCueProjectile.Spawn(source, swing.owner, snapshot, 0,
origin + aim * 54f, aim, variant: phase,
actionId: swing.identity);
if (snapshot.Stage >= ReaperStage.StageII && phase == lastPhase)
ReaperSoulFamiliarProjectile.Spawn(source, swing.owner, snapshot, origin);
if (snapshot.Stage >= ReaperStage.StageIII && phase == lastPhase)
{
// The giant soul echo is a fixed execution beat, not another
// seeker. Preserve the removed projectile's total multiplier.
ReaperStrikeProjectile.Spawn(source, swing.owner, snapshot,
ReaperHitKind.PrimaryDerived, phase, origin + aim * 78f, aim,
ReaperStrikeShape.Circle, 108f, 28f, 0.65f, 12,
actionId: swing.identity);
}
break;
}
case ReaperFormId.Infernal when snapshot.Stage >= ReaperStage.StageII && phase == lastPhase:
// Stage III folds the former seeking crescent budget into the four
// visible track detonations so every damage event stays on the fire
// route drawn by the finisher.
float infernalDetonationFactor = snapshot.Stage >= ReaperStage.StageIII
? (0.88f + 0.58f) / 4f
: 0.22f;
for (int index = 0; index < 4; index++)
{
ReaperStrikeProjectile.Spawn(source, swing.owner, snapshot, ReaperHitKind.PrimaryDerived, phase,
origin + aim * (32f + index * 34f), aim, ReaperStrikeShape.Circle, 34f, 16f,
infernalDetonationFactor, 8 + index * 8, actionId: swing.identity);
}
break;
}
}
internal static void OnReaperHit(Projectile projectile, NPC target, int damageDone, MyGlobalProjectile data, int uniqueHitIndex)
{
if (Main.netMode == NetmodeID.MultiplayerClient || !data.HasReaperSnapshot
|| projectile.owner < 0 || projectile.owner >= Main.maxPlayers)
return;
Player player = Main.player[projectile.owner];
if (!player.active)
return;
SickleCombatSnapshot snapshot = data.ReaperSnapshot;
NPC statusTarget = GetStatusTarget(target);
ReaperCombatGlobalNPC status = statusTarget.GetGlobalNPC<ReaperCombatGlobalNPC>();
bool bossTarget = IsBossLike(statusTarget);
player.GetModPlayer<MyPlayer>().RecordReaperAttackHit(snapshot.Form, data.ReaperHitKind);
switch (snapshot.Form)
{
case ReaperFormId.Bone:
{
if (data.ReaperHitKind == ReaperHitKind.Ultimate && !bossTarget)
target.velocity = Vector2.Lerp(target.velocity, (player.Center - target.Center).SafeNormalize(Vector2.Zero) * 5f, 0.5f);
int amount = data.ReaperHitKind == ReaperHitKind.Special && data.ReaperPhase == 0 ? 2 : 1;
if (data.ReaperHitKind == ReaperHitKind.Primary
|| data.ReaperHitKind == ReaperHitKind.Special && data.ReaperPhase == 0)
status.AddBoneMarks(GetStatusTarget(target), player, snapshot, amount, data.ReaperActionId);
if (data.ReaperHitKind == ReaperHitKind.Special && data.ReaperPhase == 0 && !bossTarget)
target.velocity = Vector2.Lerp(target.velocity, (player.Center - target.Center).SafeNormalize(Vector2.Zero) * 8f, 0.65f);
if (data.ReaperHitKind == ReaperHitKind.Primary
&& data.ReaperPhase == ReaperCombatRegistry.GetPrimaryPhaseCount(snapshot.Form, snapshot.Stage) - 1
&& snapshot.Stage >= ReaperStage.StageII)
{
if (snapshot.Stage == ReaperStage.StageII)
{
ReaperStrikeProjectile.Spawn(projectile.GetSource_FromThis(), player.whoAmI, snapshot,
ReaperHitKind.PrimaryDerived, data.ReaperPhase, target.Center, Vector2.UnitY,
ReaperStrikeShape.Line, 130f, 24f, 0.55f, 14);
}
else
{
ReaperStrikeProjectile.Spawn(projectile.GetSource_FromThis(), player.whoAmI, snapshot,
ReaperHitKind.PrimaryDerived, data.ReaperPhase, target.Center, Vector2.UnitY,
ReaperStrikeShape.Line, 135f, 25f, 0.45f, 14);
ReaperStrikeProjectile.Spawn(projectile.GetSource_FromThis(), player.whoAmI, snapshot,
ReaperHitKind.PrimaryDerived, data.ReaperPhase, target.Center, Vector2.UnitX,
ReaperStrikeShape.Line, 135f, 25f, 0.45f, 15);
}
}
break;
}
case ReaperFormId.Blood:
bool bleedingBeforeHit = target.HasBuff(BuffID.Bleeding);
target.AddBuff(BuffID.Bleeding, 60 * 4);
if (data.ReaperHitKind == ReaperHitKind.Primary
&& projectile.ModProjectile is ReaperStrikeProjectile)
{
int lifetime = ReaperBloodBladeProjectile.GetScarLifetime(
Math.Clamp(Math.Max(1, (int)snapshot.Skill1), 1, 3));
ReaperBloodScarProjectile.Spawn(projectile.GetSource_FromThis(),
player.whoAmI, snapshot, target.Center,
data.AttackAngle.ToRotationVector2(), 190f, 22f,
lifetime, 0.25f, data.ReaperPhase, data.ReaperActionId);
}
if (data.ReaperHitKind != ReaperHitKind.DamageOverTime)
{
player.GetModPlayer<ReaperCombatPlayer>().ApplyBloodHealing(
snapshot, damageDone, target.Center);
}
if (bleedingBeforeHit
&& snapshot.Stage >= ReaperStage.StageII
&& data.ReaperHitKind == ReaperHitKind.Primary
&& data.ReaperPhase == ReaperCombatRegistry.GetPrimaryPhaseCount(snapshot.Form, snapshot.Stage) - 1)
{
player.GetModPlayer<ReaperCombatPlayer>().ApplyBloodFinisherHealing(
projectile.identity, target.Center, snapshot.Skill1);
}
break;
case ReaperFormId.Infernal:
target.AddBuff(BuffID.OnFire3, 60 * 5);
if (data.ReaperHitKind is ReaperHitKind.Primary or ReaperHitKind.Special)
{
player.GetModPlayer<ReaperCombatPlayer>().GrantInfernalMomentum();
if (data.ReaperHitKind == ReaperHitKind.Special)
player.GetModPlayer<ReaperCombatPlayer>().RecordInfernalSpecialHit(snapshot, data.ReaperPhase, projectile);
}
break;
case ReaperFormId.Frost:
if (data.ReaperHitKind == ReaperHitKind.Ultimate)
{
if (data.ReaperPhase == 0)
status.ApplyFrostFreeze(GetStatusTarget(target), 60);
if (data.ReaperPhase == 6)
status.ShatterFrost(GetStatusTarget(target), player, snapshot, data.ReaperActionId,
canChargeUltimate: false);
}
if (data.ReaperHitKind == ReaperHitKind.Primary)
status.AddFrostMark(GetStatusTarget(target), player, snapshot,
shatter: data.ReaperPhase >= ReaperCombatRegistry.GetPrimaryPhaseCount(snapshot.Form, snapshot.Stage) - 1,
data.ReaperActionId);
else if (data.ReaperHitKind == ReaperHitKind.Special && data.ReaperPhase == 0)
status.AddFrostMark(GetStatusTarget(target), player, snapshot, shatter: false,
data.ReaperActionId);
else if (data.ReaperHitKind == ReaperHitKind.Special && data.ReaperPhase == 2 && snapshot.Stage >= ReaperStage.StageIII)
{
bool wasFull = status.HasFullFrostMarks(player.whoAmI);
status.ShatterFrost(GetStatusTarget(target), player, snapshot, data.ReaperActionId);
if (wasFull && snapshot.Skill3 > 0)
{
float echo = snapshot.Skill3 switch { 2 => 0.55f, >= 3 => 0.75f, _ => 0.35f };
ReaperStrikeProjectile.Spawn(projectile.GetSource_FromThis(), player.whoAmI, snapshot,
ReaperHitKind.PrimaryDerived, data.ReaperPhase, target.Center, data.AttackAngle.ToRotationVector2(),
ReaperStrikeShape.Cross, 115f, 24f, echo, 12);
}
}
break;
case ReaperFormId.Soul:
if (data.ReaperHitKind is ReaperHitKind.Primary or ReaperHitKind.Special)
{
NPC soulTarget = GetStatusTarget(target);
ReaperCombatPlayer combatPlayer = player.GetModPlayer<ReaperCombatPlayer>();
if (combatPlayer.TryRecordSoulShard(data.ReaperActionId, soulTarget.whoAmI))
{
if (!status.TryTakeSoulShard(player.whoAmI))
{
combatPlayer.RollbackSoulShardReservation(data.ReaperActionId, soulTarget.whoAmI);
}
else if (!ReaperSoulShardProjectile.Spawn(
projectile.GetSource_FromThis(), player, target.Center))
{
status.RefundSoulShardCooldown(player.whoAmI);
combatPlayer.RollbackSoulShardReservation(data.ReaperActionId, soulTarget.whoAmI);
}
}
if (snapshot.Stage >= ReaperStage.StageIII)
status.AddFatedSoulStack(soulTarget, player, snapshot, data.ReaperActionId,
data.ReaperHitKind == ReaperHitKind.Special && data.ReaperPhase >= 8 ? 2 : 1);
}
break;
case ReaperFormId.Void:
if (data.ReaperHitKind is ReaperHitKind.Primary or ReaperHitKind.Special)
{
int visualActionId = ReaperVoidHitActionTokenGlobalProjectile.Resolve(
projectile, data.ReaperActionId);
ReaperVoidHitCueProjectile.Spawn(projectile.GetSource_FromThis(), player.whoAmI,
snapshot.Stage, visualActionId, target.whoAmI, target.Center,
data.AttackAngle.ToRotationVector2());
status.RecordVoidCut(GetStatusTarget(target), player, snapshot, data.AttackAngle, data.ReaperActionId);
if (data.ReaperHitKind == ReaperHitKind.Primary
&& projectile.ModProjectile is ReaperStrikeProjectile)
{
int skill = Math.Clamp(Math.Max(1, (int)snapshot.Skill2), 1, 3);
ReaperVoidRiftProjectile.Spawn(projectile.GetSource_FromThis(),
player.whoAmI, snapshot, target.Center,
data.AttackAngle.ToRotationVector2(), 190f,
10f + skill * 2.5f,
ReaperVoidArcRiftProjectile.GetLifetime(skill),
130f + skill * 42f, 0.25f, data.ReaperPhase,
data.ReaperActionId);
}
}
break;
case ReaperFormId.Death:
if (data.ReaperHitKind is ReaperHitKind.Primary or ReaperHitKind.PrimaryDerived)
player.GetModPlayer<MyPlayer>().ApplyDeathReaperLifeSteal(damageDone,
target.Center);
if (data.ReaperHitKind == ReaperHitKind.Ultimate)
{
player.GetModPlayer<ReaperCombatPlayer>().ApplyDeathUltimateHealing(damageDone);
if (data.ReaperPhase < 60)
{
status.RecordDeathUltimateCut(statusTarget, player.whoAmI,
data.ReaperActionId, damageDone);
player.GetModPlayer<MyPlayer>()
.QueueDeathDomainHarvestFromReaperHit(target,
data.AttackAngle, data.ReaperActionId);
}
}
if (data.ReaperHitKind == ReaperHitKind.Primary)
{
MyPlayer modPlayer = player.GetModPlayer<MyPlayer>();
modPlayer.RecordDeathCycleHit(data.ReaperPhase);
modPlayer.QueueDeathDomainHarvestFromReaperHit(target,
data.AttackAngle);
if (data.ReaperPhase == 2)
target.AddBuff(BuffID.OnFire3, 60 * 3);
if (data.ReaperPhase == 3 && !bossTarget)
target.velocity *= 0.72f;
}
break;
}
}
internal static NPC GetStatusTarget(NPC target)
{
return target.realLife >= 0 && target.realLife < Main.maxNPCs && Main.npc[target.realLife].active
? Main.npc[target.realLife]
: target;
}
private static bool IsBossLike(NPC npc)
{
return npc.boss
|| NPCID.Sets.ShouldBeCountedAsBoss[npc.type]
|| npc.realLife >= 0
&& npc.realLife < Main.maxNPCs
&& Main.npc[npc.realLife].active
&& (Main.npc[npc.realLife].boss
|| NPCID.Sets.ShouldBeCountedAsBoss[Main.npc[npc.realLife].type]);
}
private static bool CanStartAction(Player player, out NormalSickle? sickle)
{
sickle = player.HeldItem.ModItem as NormalSickle;
return sickle is not null
&& player.active
&& player.GetModPlayer<MyPlayer>().ServerCharacterStateReady
&& !player.dead
&& !player.CCed
&& !player.noItems
&& !player.GetModPlayer<MyPlayer>().IsReaperFormAssemblyActive()
&& player.ownedProjectileCounts[ModContent.ProjectileType<ReaperActionControllerProjectile>()] <= 0;
}
private static void CancelPrimarySwingsForAction(Player player)
{
int swingType = ModContent.ProjectileType<SickleSwingProjectile>();
foreach (Projectile projectile in Main.ActiveProjectiles)
{
if (projectile.owner == player.whoAmI && projectile.type == swingType)
projectile.Kill();
}
int owner = player.whoAmI;
if (owner >= 0 && owner < Main.maxPlayers)
{
ReaperCombatSystem.HasActivePrimaryProjectile[owner] = false;
ReaperCombatSystem.ActivePrimaryProjectile[owner] = -1;
}
player.itemAnimation = 0;
player.itemTime = 0;
}
private static void SpawnLegacyProjectile<T>(IEntitySource source, int owner, Vector2 position, Vector2 velocity,
in SickleCombatSnapshot snapshot, float damageFactor, ReaperHitKind kind, int phase,
float ai0 = 0f, float ai1 = 0f, float ai2 = 0f) where T : ModProjectile
{
int index = Projectile.NewProjectile(source, position, velocity, ModContent.ProjectileType<T>(),
Math.Max(1, (int)Math.Round(snapshot.Damage * damageFactor)), snapshot.Knockback, owner,
ai0, ai1, ai2);
if (index < 0 || index >= Main.maxProjectiles)
return;
Projectile projectile = Main.projectile[index];
projectile.originalDamage = snapshot.Damage;
projectile.CritChance = kind == ReaperHitKind.ReturningArc ? snapshot.CritChance : 0;
if (projectile.ModProjectile is BloodCrescentProjectile bloodCrescent)
bloodCrescent.AuthorizeServerMotion();
else if (projectile.ModProjectile is DeathSickleProjectile deathBlade)
deathBlade.AuthorizeServerMotion();
projectile.GetGlobalProjectile<MyGlobalProjectile>().ConfigureReaperProjectile(projectile, snapshot, kind, phase, velocity.ToRotation());
projectile.netUpdate = true;
}
private static void SpawnOrReplaceDeathBlade(IEntitySource source, int owner, Vector2 position, Vector2 velocity,
in SickleCombatSnapshot snapshot, int phase)
{
if (Main.netMode == NetmodeID.MultiplayerClient)
return;
// DeathSickleProjectile is also a legacy-compatible content type. Only replace
// the 3.0 Death form's sixth-phase derived blade; unrelated legacy instances
// deliberately have no matching Reaper snapshot and remain untouched.
for (int index = 0; index < Main.maxProjectiles; index++)
{
Projectile existing = Main.projectile[index];
if (!existing.active || existing.owner != owner
|| existing.ModProjectile is not DeathSickleProjectile)
{
continue;
}
MyGlobalProjectile data = existing.GetGlobalProjectile<MyGlobalProjectile>();
if (data.HasReaperSnapshot
&& data.ReaperSnapshot.Form == ReaperFormId.Death
&& data.ReaperHitKind == ReaperHitKind.PrimaryDerived
&& data.ReaperPhase == 5)
{
existing.Kill();
}
}
SpawnLegacyProjectile<DeathSickleProjectile>(source, owner, position, velocity,
snapshot, 0.7f, ReaperHitKind.PrimaryDerived, phase);
}
}