XFE Git
XFE Studio Git
Git 首页 全局搜索
XFE 主站 文档 NuGet

DeathMod

【Terraria】DeathMod (now aka Soul Harvest)

公开
关注 0 Fork 0 Star 0
UTF-8
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);
    }
}