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().ServerCharacterStateReady || player.ownedProjectileCounts[ModContent.ProjectileType()] > 0 || player.GetModPlayer().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(); 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().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(); 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(); 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(); 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(); 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().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() .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(); bool bossTarget = IsBossLike(statusTarget); player.GetModPlayer().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().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().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().GrantInfernalMomentum(); if (data.ReaperHitKind == ReaperHitKind.Special) player.GetModPlayer().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(); 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().ApplyDeathReaperLifeSteal(damageDone, target.Center); if (data.ReaperHitKind == ReaperHitKind.Ultimate) { player.GetModPlayer().ApplyDeathUltimateHealing(damageDone); if (data.ReaperPhase < 60) { status.RecordDeathUltimateCut(statusTarget, player.whoAmI, data.ReaperActionId, damageDone); player.GetModPlayer() .QueueDeathDomainHarvestFromReaperHit(target, data.AttackAngle, data.ReaperActionId); } } if (data.ReaperHitKind == ReaperHitKind.Primary) { MyPlayer modPlayer = player.GetModPlayer(); 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().ServerCharacterStateReady && !player.dead && !player.CCed && !player.noItems && !player.GetModPlayer().IsReaperFormAssemblyActive() && player.ownedProjectileCounts[ModContent.ProjectileType()] <= 0; } private static void CancelPrimarySwingsForAction(Player player) { int swingType = ModContent.ProjectileType(); 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(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(), 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().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(); if (data.HasReaperSnapshot && data.ReaperSnapshot.Form == ReaperFormId.Death && data.ReaperHitKind == ReaperHitKind.PrimaryDerived && data.ReaperPhase == 5) { existing.Kill(); } } SpawnLegacyProjectile(source, owner, position, velocity, snapshot, 0.7f, ReaperHitKind.PrimaryDerived, phase); } }