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 System;
using System.Collections.Generic;
using System.IO;
using Terraria;
using Terraria.DataStructures;
using Terraria.ID;
using Terraria.ModLoader;
using Terraria.ModLoader.IO;

namespace SoulHarvest.Common;

public class MyGlobalProjectile : GlobalProjectile
{
    public override bool InstancePerEntity => true;

    public bool IsSickleProjectile { get; private set; }
    public bool IsDeathDomainProjectile { get; private set; }
    public int LifeStealLevel { get; private set; }
    public int ArmorPenetrationLevel { get; private set; }
    public int ProjectileDurationLevel { get; private set; } = 1;
    public int HitCooldownFrames { get; private set; }
    public int OnHitDebuffType { get; private set; }
    public int OnHitDebuffDuration { get; private set; }
    public int FatedStacksPerHit { get; private set; }
    public int FatedMaximumStacks { get; private set; }
    public int FatedDurationFrames { get; private set; }
    public bool HasReaperSnapshot { get; private set; }
    public SickleCombatSnapshot ReaperSnapshot { get; private set; }
    public ReaperHitKind ReaperHitKind { get; private set; }
    public int ReaperPhase { get; private set; }
    public int ReaperActionId { get; private set; }
    public float AttackAngle { get; private set; }
    private readonly Dictionary<int, int> reaperRootHitCounts = [];
    private readonly HashSet<int> reaperPrimaryEffectRoots = [];
    private bool serverAuthorizedReaper;
    private int authoritativeDamage;
    private int authoritativeOriginalDamage;
    private int authoritativeCritChance;
    private float authoritativeKnockback;
    private readonly float[] authoritativeAI = new float[3];

    public override void OnSpawn(Projectile projectile, IEntitySource source)
    {
        if (source is EntitySource_ItemUse_WithAmmo itemSource && itemSource.Item.ModItem is NormalSickle sickle)
        {
            LifeStealLevel = sickle.LifeStealLevel;
            ArmorPenetrationLevel = sickle.ArmorPenetrationLevel;
            ProjectileDurationLevel = sickle.ProjectileDurationLevel;
            HitCooldownFrames = sickle.HitCooldownFrames;
            OnHitDebuffType = sickle.OnHitDebuffType;
            OnHitDebuffDuration = sickle.OnHitDebuffDuration;
            FatedStacksPerHit = sickle.FatedStacksPerHit;
            FatedMaximumStacks = sickle.FatedMaximumStacks;
            FatedDurationFrames = sickle.FatedDurationFrames;
        }
        else if (source is EntitySource_ItemUse_WithAmmo legacySource && legacySource.Item.ModItem is LegacyDeath)
        {
            HitCooldownFrames = NormalSickle.BaseHitCooldownFrames;
        }
        else if (source is EntitySource_Parent parentSource
            && parentSource.Entity is Projectile parentProjectile
            && parentProjectile.GetGlobalProjectile<MyGlobalProjectile>() is { IsSickleProjectile: true } parentGlobal)
        {
            LifeStealLevel = parentGlobal.LifeStealLevel;
            ArmorPenetrationLevel = parentGlobal.ArmorPenetrationLevel;
            ProjectileDurationLevel = parentGlobal.ProjectileDurationLevel;
            HitCooldownFrames = parentGlobal.HitCooldownFrames;
            OnHitDebuffType = parentGlobal.OnHitDebuffType;
            OnHitDebuffDuration = parentGlobal.OnHitDebuffDuration;
            FatedStacksPerHit = parentGlobal.FatedStacksPerHit;
            FatedMaximumStacks = parentGlobal.FatedMaximumStacks;
            FatedDurationFrames = parentGlobal.FatedDurationFrames;
        }
        else
        {
            return;
        }

        IsSickleProjectile = true;
        projectile.ArmorPenetration = 0;
        projectile.usesIDStaticNPCImmunity = false;
        projectile.usesLocalNPCImmunity = true;
        projectile.localNPCHitCooldown = HitCooldownFrames;
        ScaleProjectileLifetime(projectile);
    }

    public override void SendExtraAI(Projectile projectile, BitWriter bitWriter, BinaryWriter binaryWriter)
    {
        bitWriter.WriteBit(IsSickleProjectile);
        if (!IsSickleProjectile)
            return;

        bitWriter.WriteBit(HasReaperSnapshot);

        binaryWriter.Write((byte)LifeStealLevel);
        binaryWriter.Write((byte)ArmorPenetrationLevel);
        binaryWriter.Write((byte)ProjectileDurationLevel);
        binaryWriter.Write((byte)HitCooldownFrames);
        binaryWriter.Write((ushort)OnHitDebuffType);
        binaryWriter.Write((ushort)OnHitDebuffDuration);
        binaryWriter.Write((byte)FatedStacksPerHit);
        binaryWriter.Write((byte)FatedMaximumStacks);
        binaryWriter.Write((ushort)FatedDurationFrames);
        if (HasReaperSnapshot)
        {
            ReaperSnapshot.Write(binaryWriter);
            binaryWriter.Write((byte)ReaperHitKind);
            binaryWriter.Write((byte)Math.Clamp(ReaperPhase, 0, byte.MaxValue));
            binaryWriter.Write(ReaperActionId);
            binaryWriter.Write(AttackAngle);
        }
    }

    public override void ReceiveExtraAI(Projectile projectile, BitReader bitReader, BinaryReader binaryReader)
    {
        bool incomingSickleProjectile = bitReader.ReadBit();
        if (!incomingSickleProjectile)
        {
            if (Main.netMode != NetmodeID.Server)
                IsSickleProjectile = false;
            return;
        }

        bool incomingReaperSnapshot = bitReader.ReadBit();
        int incomingLifeSteal = binaryReader.ReadByte();
        int incomingArmorPenetration = binaryReader.ReadByte();
        int incomingDuration = Math.Clamp((int)binaryReader.ReadByte(), 1, NormalSickle.MaxProjectileDurationLevel);
        int incomingHitCooldown = binaryReader.ReadByte();
        int incomingDebuffType = binaryReader.ReadUInt16();
        int incomingDebuffDuration = binaryReader.ReadUInt16();
        int incomingFatedStacks = binaryReader.ReadByte();
        int incomingFatedMaximum = binaryReader.ReadByte();
        int incomingFatedDuration = binaryReader.ReadUInt16();
        SickleCombatSnapshot incomingSnapshot = default;
        ReaperHitKind incomingHitKind = default;
        int incomingPhase = 0;
        int incomingActionId = 0;
        float incomingAttackAngle = 0f;
        if (incomingReaperSnapshot)
        {
            incomingSnapshot = SickleCombatSnapshot.Read(binaryReader);
            incomingHitKind = (ReaperHitKind)binaryReader.ReadByte();
            incomingPhase = binaryReader.ReadByte();
            incomingActionId = binaryReader.ReadInt32();
            incomingAttackAngle = binaryReader.ReadSingle();
        }

        // Clients never get to replace combat snapshots or legacy upgrade values
        // on the authoritative simulation. Reading above only consumes the packet.
        if (Main.netMode == NetmodeID.Server)
            return;

        IsSickleProjectile = true;
        HasReaperSnapshot = incomingReaperSnapshot;
        LifeStealLevel = incomingLifeSteal;
        ArmorPenetrationLevel = incomingArmorPenetration;
        ProjectileDurationLevel = incomingDuration;
        HitCooldownFrames = incomingHitCooldown;
        OnHitDebuffType = incomingDebuffType;
        OnHitDebuffDuration = incomingDebuffDuration;
        FatedStacksPerHit = incomingFatedStacks;
        FatedMaximumStacks = incomingFatedMaximum;
        FatedDurationFrames = incomingFatedDuration;
        if (incomingReaperSnapshot)
        {
            ReaperSnapshot = incomingSnapshot;
            ReaperHitKind = incomingHitKind;
            ReaperPhase = incomingPhase;
            ReaperActionId = incomingActionId;
            AttackAngle = incomingAttackAngle;
        }
        projectile.ArmorPenetration = 0;
        projectile.usesIDStaticNPCImmunity = false;
        projectile.usesLocalNPCImmunity = true;
        projectile.localNPCHitCooldown = HitCooldownFrames;
    }

    internal void ConfigureDeathDomainProjectile(Projectile projectile, NormalSickle? sickle)
    {
        IsDeathDomainProjectile = true;
        IsSickleProjectile = true;
        if (sickle is not null)
        {
            LifeStealLevel = sickle.LifeStealLevel;
            ArmorPenetrationLevel = sickle.ArmorPenetrationLevel;
            ProjectileDurationLevel = sickle.ProjectileDurationLevel;
            HitCooldownFrames = sickle.HitCooldownFrames;
            OnHitDebuffType = sickle.OnHitDebuffType;
            OnHitDebuffDuration = sickle.OnHitDebuffDuration;
            FatedStacksPerHit = sickle.FatedStacksPerHit;
            FatedMaximumStacks = sickle.FatedMaximumStacks;
            FatedDurationFrames = sickle.FatedDurationFrames;
        }
        else
        {
            HitCooldownFrames = NormalSickle.BaseHitCooldownFrames;
        }

        projectile.ArmorPenetration = 0;
        projectile.usesIDStaticNPCImmunity = false;
        projectile.usesLocalNPCImmunity = true;
        projectile.localNPCHitCooldown = HitCooldownFrames;
        ScaleProjectileLifetime(projectile);
        projectile.netUpdate = true;
    }

    internal void ConfigureReaperProjectile(Projectile projectile, in SickleCombatSnapshot snapshot,
        ReaperHitKind hitKind, int phase, float attackAngle, int actionId = -1)
    {
        IsSickleProjectile = true;
        HasReaperSnapshot = true;
        ReaperSnapshot = snapshot;
        ReaperHitKind = hitKind;
        ReaperPhase = Math.Max(0, phase);
        ReaperActionId = actionId >= 0 ? actionId : projectile.identity;
        AttackAngle = attackAngle;
        LifeStealLevel = 0;
        ArmorPenetrationLevel = snapshot.ArmorPenetrationLevel * 4;
        ProjectileDurationLevel = 1;
        HitCooldownFrames = hitKind == ReaperHitKind.ReturningArc ? 18 : NormalSickle.BaseHitCooldownFrames;
        OnHitDebuffType = 0;
        OnHitDebuffDuration = 0;
        FatedStacksPerHit = 0;
        FatedMaximumStacks = 0;
        FatedDurationFrames = 0;
        reaperRootHitCounts.Clear();
        projectile.ArmorPenetration = 0;
        projectile.usesIDStaticNPCImmunity = false;
        projectile.usesLocalNPCImmunity = true;
        projectile.localNPCHitCooldown = HitCooldownFrames;
        AuthorizeServerReaperProjectile(projectile);
        projectile.netUpdate = true;
    }

    internal void UpdateReaperAttackAngle(float attackAngle)
    {
        if (HasReaperSnapshot && float.IsFinite(attackAngle))
            AttackAngle = attackAngle;
    }

    internal void AuthorizeServerReaperProjectile(Projectile projectile)
    {
        if (Main.netMode == NetmodeID.MultiplayerClient)
            return;
        serverAuthorizedReaper = true;
        authoritativeDamage = Math.Max(0, projectile.damage);
        authoritativeOriginalDamage = Math.Max(0, projectile.originalDamage);
        authoritativeCritChance = Math.Clamp(projectile.CritChance, 0, 100);
        authoritativeKnockback = float.IsFinite(projectile.knockBack) ? Math.Max(0f, projectile.knockBack) : 0f;
        for (int index = 0; index < authoritativeAI.Length; index++)
            authoritativeAI[index] = float.IsFinite(projectile.ai[index]) ? projectile.ai[index] : 0f;
    }

    public override void AI(Projectile projectile)
    {
        if (Main.netMode != NetmodeID.Server)
            return;

        bool requiresAuthorization = projectile.ModProjectile is ReaperActionControllerProjectile
            or ReaperStrikeProjectile
            or ReaperFrostShardProjectile
            or SoulSickleProjectile
            or ReaperSoulFamiliarProjectile
            or ReaperSoulShardProjectile
            or ReaperVoidRiftProjectile
            or ReaperVoidBlackHoleProjectile
            or ReaperBloodScarProjectile
            or ReaperBloodDrainFieldProjectile
            or ReaperBloodBladeProjectile
            or BloodCrescentProjectile
            or DeathSickleProjectile;
        if (requiresAuthorization && !serverAuthorizedReaper)
        {
            projectile.Kill();
            return;
        }
        if (serverAuthorizedReaper)
        {
            projectile.damage = authoritativeDamage;
            projectile.originalDamage = authoritativeOriginalDamage;
            projectile.CritChance = authoritativeCritChance;
            projectile.knockBack = authoritativeKnockback;
            for (int index = 0; index < authoritativeAI.Length; index++)
                projectile.ai[index] = authoritativeAI[index];
        }
    }

    public override bool? CanHitNPC(Projectile projectile, NPC target)
    {
        if (!HasReaperSnapshot)
            return null;
        int root = target.realLife >= 0 ? target.realLife : target.whoAmI;
        int maximumHits = ReaperHitKind switch
        {
            ReaperHitKind.DamageOverTime => int.MaxValue,
            ReaperHitKind.ReturningArc => 2,
            ReaperHitKind.Ultimate when projectile.ModProjectile is ReaperBloodBladeProjectile => 5,
            _ => 1
        };
        return !reaperRootHitCounts.TryGetValue(root, out int hits)
            || hits < maximumHits ? null : false;
    }

    public override void ModifyHitNPC(Projectile projectile, NPC target, ref NPC.HitModifiers modifiers)
    {
        bool sickleHit = IsSickleProjectile || projectile.ModProjectile is SickleSwingProjectile;
        if (!sickleHit || projectile.owner < 0 || projectile.owner >= Main.maxPlayers)
            return;

        modifiers.ScalingArmorPenetration += HasReaperSnapshot
            ? Math.Clamp(ReaperSnapshot.ArmorPenetrationLevel * 0.06f, 0f, 0.3f)
            : Math.Clamp(ArmorPenetrationLevel, 0, NormalSickle.MaxArmorPenetrationLevel) / 100f;

        if (HasReaperSnapshot)
        {
            NPC statusTarget = ReaperCombatService.GetStatusTarget(target);
            statusTarget.GetGlobalNPC<ReaperCombatGlobalNPC>().ModifyReaperHit(
                projectile.owner, ReaperSnapshot, ReaperHitKind, ReaperPhase, ReaperActionId, statusTarget, ref modifiers);
        }

        if (Main.netMode == NetmodeID.MultiplayerClient)
            return;

        // Register directly on the authoritative simulation before damage and OnKill resolve.
        target.GetGlobalNPC<MyGlobalNPC>().RegisterSickleHit(target, projectile.owner);
    }

    public override void OnHitNPC(Projectile projectile, NPC target, NPC.HitInfo hit, int damageDone)
    {
        if ((!IsSickleProjectile && projectile.ModProjectile is not SickleSwingProjectile)
            || projectile.owner < 0
            || projectile.owner >= Main.maxPlayers)
            return;

        int uniqueHitIndex = 0;
        if (HasReaperSnapshot)
        {
            int root = target.realLife >= 0 ? target.realLife : target.whoAmI;
            reaperRootHitCounts.TryGetValue(root, out int previousHits);
            reaperRootHitCounts[root] = previousHits + 1;
            uniqueHitIndex = reaperRootHitCounts.Count;
            if (ReaperHitKind == ReaperHitKind.Primary)
                TryApplyPrimaryHitEffects(projectile, target, damageDone,
                    uniqueHitIndex);
            else
                ReaperCombatService.OnReaperHit(projectile, target, damageDone,
                    this, uniqueHitIndex);
        }

        if (Main.netMode == NetmodeID.MultiplayerClient)
        {
            if (projectile.owner != Main.myPlayer)
                return;

            int healedLife = Main.player[projectile.owner].GetModPlayer<MyPlayer>().TryLifeSteal(damageDone, LifeStealLevel);
            LifeStealVisuals.Spawn(target.Center, projectile.owner, LifeStealLevel, healedLife);
            return;
        }

        if (Main.netMode == NetmodeID.Server)
        {
            // Status and soul ownership must be committed by the authoritative
            // simulation. The old early return left multiplayer sickle hits with
            // client visuals but no Death Mark (and therefore no soul harvest).
            ApplyStatusEffects(target, projectile.owner, LifeStealLevel,
                OnHitDebuffType, OnHitDebuffDuration, FatedStacksPerHit,
                FatedMaximumStacks, FatedDurationFrames);
            return;
        }

        int localHealedLife = Main.player[projectile.owner].GetModPlayer<MyPlayer>().TryLifeSteal(damageDone, LifeStealLevel);
        LifeStealVisuals.Spawn(target.Center, projectile.owner, LifeStealLevel, localHealedLife);
        ApplyStatusEffects(target, projectile.owner, LifeStealLevel, OnHitDebuffType, OnHitDebuffDuration, FatedStacksPerHit, FatedMaximumStacks, FatedDurationFrames);
    }

    internal bool TryApplyPrimaryHitEffects(Projectile projectile, NPC target,
        int damageDone, int uniqueHitIndex = 1)
    {
        if (Main.netMode == NetmodeID.MultiplayerClient
            || !HasReaperSnapshot
            || ReaperHitKind != ReaperHitKind.Primary
            || projectile.owner < 0
            || projectile.owner >= Main.maxPlayers
            || !ReaperTargeting.IsValidWeaponTarget(target))
        {
            return false;
        }

        int root = target.realLife >= 0 ? target.realLife : target.whoAmI;
        if (!reaperPrimaryEffectRoots.Add(root))
            return false;

        // Target dummies do not consistently raise the dedicated server's
        // projectile-hit callback. This path is also used by the swept-geometry
        // confirmation, so record the same mark/harvester ownership here before
        // running form-specific effects.
        target.GetGlobalNPC<MyGlobalNPC>().RegisterSickleHit(target,
            projectile.owner);

        ReaperCombatService.OnReaperHit(projectile, target,
            Math.Max(0, damageDone), this, Math.Max(1, uniqueHitIndex));
        return true;
    }

    internal static void ApplyStatusEffects(NPC target, int playerIndex, int lifeStealLevel, int debuffType, int debuffDuration, int fatedStacks, int fatedMaximumStacks, int fatedDurationFrames)
    {
        if (Main.netMode == NetmodeID.MultiplayerClient || target.friendly || target.lifeMax <= 5)
            return;

        target.GetGlobalNPC<MyGlobalNPC>().MarkForReaping(target);
        NPC statusTarget = target.realLife >= 0 && target.realLife < Main.maxNPCs && Main.npc[target.realLife].active
            ? Main.npc[target.realLife]
            : target;
        if (statusTarget.whoAmI != target.whoAmI)
            statusTarget.GetGlobalNPC<MyGlobalNPC>().MarkForReaping(statusTarget);
        if (debuffType > 0 && debuffDuration > 0)
            statusTarget.AddBuff(debuffType, debuffDuration);
        if (fatedStacks > 0 && fatedMaximumStacks > 0 && fatedDurationFrames > 0)
            statusTarget.GetGlobalNPC<MyGlobalNPC>().AddFatedStacks(statusTarget, fatedStacks, fatedMaximumStacks, fatedDurationFrames, playerIndex, lifeStealLevel);

        MyGlobalNPC.SyncBuffState(statusTarget);
    }

    private void ScaleProjectileLifetime(Projectile projectile)
    {
        if (projectile.ModProjectile is SickleSwingProjectile || ProjectileDurationLevel <= 1)
            return;

        float multiplier = 1f + (ProjectileDurationLevel - 1) * 0.12f;
        projectile.timeLeft = Math.Max(1, (int)Math.Round(projectile.timeLeft * multiplier));
    }
}