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

DeathMod

【Terraria】DeathMod (now aka Soul Harvest)

公开
关注 0 Fork 0 Star 0
UTF-8
using SoulHarvest.Common;
using SoulHarvest.Projectiles;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using System;
using System.Collections.Generic;
using System.IO;
using Terraria;
using Terraria.DataStructures;
using Terraria.ID;
using Terraria.Localization;
using Terraria.ModLoader;
using Terraria.ModLoader.IO;

namespace SoulHarvest.Items;

public class NormalSickle : ModItem
{
    public const int BaseUseTime = 25;
    public const float BaseKnockback = 4f;
    public const int BaseHitCooldownFrames = 15;
    public const int MaxDamageLevel = 40;
    public const int MaxProjectileCount = 7;
    public const int MaxProjectileDurationLevel = 10;
    public const int MaxArmorPenetrationLevel = 20;
    public const int MaxLifeStealLevel = 5;
    public const int MaxAttackSpeedLevel = 10;
    public const int MaxKnockbackLevel = 10;
    public const int MaxHitCooldownLevel = 10;
    public const int MaxFatedStackLevel = 10;
    public const int MaxFatedStackCapacity = 1 + (MaxFatedStackLevel - 1) * 3;
    public const int MaxFatedStacksPerHitLevel = 5;
    public const int MaxFatedDurationLevel = 10;
    public const int BaseFatedDurationFrames = 60 * 3;

    private static readonly DeathAltarUpgradeType[] LinearUpgradeTypes =
    [
        DeathAltarUpgradeType.Advancement,
        DeathAltarUpgradeType.Damage,
        DeathAltarUpgradeType.AttackSpeed,
        DeathAltarUpgradeType.Knockback,
        DeathAltarUpgradeType.ProjectileCount,
        DeathAltarUpgradeType.ProjectileDuration,
        DeathAltarUpgradeType.ArmorPenetration,
        DeathAltarUpgradeType.HitCooldown,
        DeathAltarUpgradeType.LifeSteal,
        DeathAltarUpgradeType.FatedUnlock,
        DeathAltarUpgradeType.FatedMaxStacks,
        DeathAltarUpgradeType.FatedStacksPerHit,
        DeathAltarUpgradeType.FatedDuration,
        DeathAltarUpgradeType.Regression
    ];

    private static readonly DeathAltarUpgradeType[] BranchingUpgradeTypes =
    [
        DeathAltarUpgradeType.Advancement,
        DeathAltarUpgradeType.AlternateAdvancement,
        DeathAltarUpgradeType.Damage,
        DeathAltarUpgradeType.AttackSpeed,
        DeathAltarUpgradeType.Knockback,
        DeathAltarUpgradeType.ProjectileCount,
        DeathAltarUpgradeType.ProjectileDuration,
        DeathAltarUpgradeType.ArmorPenetration,
        DeathAltarUpgradeType.HitCooldown,
        DeathAltarUpgradeType.LifeSteal,
        DeathAltarUpgradeType.FatedUnlock,
        DeathAltarUpgradeType.FatedMaxStacks,
        DeathAltarUpgradeType.FatedStacksPerHit,
        DeathAltarUpgradeType.FatedDuration,
        DeathAltarUpgradeType.Regression
    ];

    public int DamageLevel { get; internal set; } = 1;
    public int ProjectileCount { get; internal set; } = 1;
    public int ProjectileDurationLevel { get; internal set; } = 1;
    public int ArmorPenetrationLevel { get; internal set; } = 1;
    public int LifeStealLevel { get; internal set; }
    public int AttackSpeedLevel { get; internal set; } = 1;
    public int KnockbackLevel { get; internal set; } = 1;
    public int HitCooldownLevel { get; internal set; } = 1;
    public bool FatedUnlocked { get; internal set; }
    public int FatedMaximumStacksLevel { get; internal set; } = 1;
    public int FatedStacksPerHitLevel { get; internal set; } = 1;
    public int FatedDurationLevel { get; internal set; } = 1;
    private int previousTierOneType;
    private int previousTierTwoType;
    private int previousTierThreeType;

    public virtual int SickleTier => 0;
    public virtual int BaseSickleDamage => 14;
    public virtual int AttackProjectileType => ProjectileID.None;
    public virtual float AttackProjectileSpeed => 8f;
    public virtual float ProjectileDamageFactor => 0.55f;
    public virtual int PrimaryNextSickleType => ItemID.None;
    public virtual int AlternateNextSickleType => ItemID.None;
    public virtual bool UsesModernSwing => true;
    public virtual SickleSwingStyle SwingStyle => SickleSwingStyle.Diagonal;
    public virtual SickleAlternateAttackStyle AlternateAttackStyle => SickleAlternateAttackStyle.None;
    public virtual int MaximumChargeFrames => 55 + SickleTier * 8;
    public virtual int SickleReach => 74 + SickleTier * 10;
    public virtual float SickleCollisionWidth => 18f + SickleTier * 3f;
    public virtual Color SickleColor => ReaperCombatRegistry.GetPrimaryColor(ResolvePresentationForm());
    public virtual Color SickleSecondaryColor => ReaperCombatRegistry.GetSecondaryColor(ResolvePresentationForm());
    public virtual int SickleDustType => DustID.AncientLight;
    public virtual float TrailWidth => 9f;
    public virtual Vector2 HandleAnchor => new(0.06f, 0.95f);
    public virtual float TextureRotationCorrection => 0.98f;
    public virtual float BladeTipLength => 157.4f;
    public virtual int AlternateCooldownFrames => 45;
    public virtual int OnHitDebuffType => 0;
    public virtual int OnHitDebuffDuration => 0;

    public bool SupportsProjectileCount => AttackProjectileType > ProjectileID.None;
    public bool SupportsProjectileDuration => AttackProjectileType > ProjectileID.None;
    public bool HasAlternateAttack => AlternateAttackStyle != SickleAlternateAttackStyle.None;
    public bool SupportsCharge => AlternateAttackStyle is SickleAlternateAttackStyle.BloodCharge
        or SickleAlternateAttackStyle.FrostCharge
        or SickleAlternateAttackStyle.SoulCharge;
    public double DamageMultiplier => ScaledBaseDamage / (double)Math.Max(1, BaseSickleDamage);
    public int ScaledBaseDamage => GetScaledDamage(DamageLevel);
    public int AttackUseTime => BaseUseTime - (AttackSpeedLevel - 1);
    public float KnockbackBonus => (KnockbackLevel - 1) * 0.5f;
    public float TotalBaseKnockback => BaseKnockback + KnockbackBonus;
    public float ArmorPenetrationPercent => ArmorPenetrationLevel / 100f;
    public float ProjectileLifetimeMultiplier => 1f + (ProjectileDurationLevel - 1) * 0.12f;
    public int HitCooldownFrames => BaseHitCooldownFrames - (HitCooldownLevel - 1);
    public int FatedMaximumStacks => FatedUnlocked ? GetFatedMaximumStacks(FatedMaximumStacksLevel) : 0;
    public int FatedStacksPerHit => FatedUnlocked ? FatedStacksPerHitLevel : 0;
    public int FatedDurationFrames => FatedUnlocked ? BaseFatedDurationFrames + (FatedDurationLevel - 1) * 60 : 0;
    public bool AllNonAdvancementUpgradesMaxed => DamageLevel >= MaxDamageLevel
        && (!SupportsProjectileCount || ProjectileCount >= MaxProjectileCount)
        && (!SupportsProjectileDuration || ProjectileDurationLevel >= MaxProjectileDurationLevel)
        && ArmorPenetrationLevel >= MaxArmorPenetrationLevel
        && LifeStealLevel >= MaxLifeStealLevel
        && AttackSpeedLevel >= MaxAttackSpeedLevel
        && KnockbackLevel >= MaxKnockbackLevel
        && (!SupportsProjectileCount || HitCooldownLevel >= MaxHitCooldownLevel)
        && FatedUnlocked
        && FatedMaximumStacksLevel >= MaxFatedStackLevel
        && FatedStacksPerHitLevel >= MaxFatedStacksPerHitLevel
        && FatedDurationLevel >= MaxFatedDurationLevel;
    public IReadOnlyList<DeathAltarUpgradeType> AltarUpgradeTypes => Array.Empty<DeathAltarUpgradeType>();

    protected override bool CloneNewInstances => true;

    public override void PreReforge()
    {
        DeathAltarReforgePreservation.Capture(this);
    }

    public override void PostReforge()
    {
        DeathAltarReforgePreservation.Restore(this);
        ApplyDynamicStats();
    }

    public override void SetDefaults()
    {
        Item.damage = BaseSickleDamage;
        Item.DamageType = DamageClass.Melee;
        Item.width = 72;
        Item.height = 96;
        Item.useTime = BaseUseTime;
        Item.useAnimation = BaseUseTime;
        Item.useStyle = UsesModernSwing ? ItemUseStyleID.Shoot : ItemUseStyleID.Swing;
        Item.crit = 0;
        Item.scale = 1f;
        Item.knockBack = BaseKnockback;
        Item.rare = ItemRarityID.White;
        Item.UseSound = UsesModernSwing ? null : SoundID.Item1;
        Item.autoReuse = true;
        Item.noUseGraphic = UsesModernSwing;
        Item.noMelee = UsesModernSwing;
        Item.channel = SupportsCharge;
        Item.shoot = UsesModernSwing ? ModContent.ProjectileType<SickleSwingProjectile>() : AttackProjectileType;
        Item.shootSpeed = AttackProjectileSpeed;
        ApplyDynamicStats();
    }

    public override void UpdateInventory(Player player) => ApplyDynamicStats();

    public override void HoldItem(Player player) => ApplyDynamicStats();

    public override bool PreDrawInInventory(
        SpriteBatch spriteBatch,
        Vector2 position,
        Rectangle frame,
        Color drawColor,
        Color itemColor,
        Vector2 origin,
        float scale)
    {
        ReaperFormId form = ResolvePresentationForm();
        ReaperStage stage = ResolvePresentationStage(form);
        Texture2D texture = ModContent.Request<Texture2D>(
            ReaperCombatRegistry.GetTexturePath(form, stage)).Value;
        Rectangle presentationFrame = texture.Frame();

        // Preserve the footprint Terraria calculated for the physical Harvest
        // Sickle shell while substituting the character's active form artwork.
        // This keeps large late-game scythes inside hotbar and inventory slots.
        float calculatedExtent = Math.Max(frame.Width, frame.Height) * scale;
        float presentationScale = calculatedExtent / Math.Max(presentationFrame.Width, presentationFrame.Height);
        spriteBatch.Draw(
            texture,
            position,
            presentationFrame,
            drawColor,
            0f,
            presentationFrame.Size() * 0.5f,
            presentationScale,
            SpriteEffects.None,
            0f);
        return false;
    }

    public override bool PreDrawInWorld(
        SpriteBatch spriteBatch,
        Color lightColor,
        Color alphaColor,
        ref float rotation,
        ref float scale,
        int whoAmI)
    {
        ReaperFormId form = ResolvePresentationForm();
        ReaperStage stage = ResolvePresentationStage(form);
        Texture2D texture = ModContent.Request<Texture2D>(
            ReaperCombatRegistry.GetTexturePath(form, stage)).Value;
        Rectangle presentationFrame = texture.Frame();
        Vector2 position = Item.position - Main.screenPosition
            + new Vector2(Item.width, Item.height) * 0.5f;
        float calculatedExtent = Math.Max(Item.width, Item.height) * scale;
        float presentationScale = calculatedExtent
            / Math.Max(presentationFrame.Width, presentationFrame.Height);
        spriteBatch.Draw(texture, position, presentationFrame, lightColor, rotation,
            presentationFrame.Size() * 0.5f, presentationScale, SpriteEffects.None, 0f);
        return false;
    }

    public override void ModifyWeaponDamage(Player player, ref StatModifier damage)
    {
        ReaperProgressionState state = player.GetModPlayer<MyPlayer>().ReaperProgression;
        ReaperFormId form = ReaperCombatRegistry.ResolveUsableForm(state);
        ReaperStage stage = ReaperDefinitions.IsBranchForm(form) ? state.GetStage(form) : ReaperStage.StageIII;
        float multiplier = ReaperCombatRegistry.GetBaseDamage(form, stage) / (float)BaseSickleDamage;
        multiplier *= 1f + state.GetCommonNodeLevel(ReaperCommonNode.Damage) * 0.03f;
        if (form == ReaperFormId.Death)
            multiplier *= 1f + state.GetCommonNodeLevel(ReaperCommonNode.DeathDamage) * 0.03f;
        if (form == ReaperFormId.Blood && player.GetModPlayer<ReaperCombatPlayer>().BloodFrenzyActive)
        {
            int level = state.GetSkillLevel(ReaperFormId.Blood, 1);
            multiplier *= 1f + (level switch { 2 => 0.12f, >= 3 => 0.16f, _ => level > 0 ? 0.08f : 0f });
        }
        damage *= multiplier;
    }

    public override float UseSpeedMultiplier(Player player)
    {
        ReaperProgressionState state = player.GetModPlayer<MyPlayer>().ReaperProgression;
        ReaperFormId form = ReaperCombatRegistry.ResolveUsableForm(state);
        ReaperStage stage = ReaperDefinitions.IsBranchForm(form) ? state.GetStage(form) : ReaperStage.StageIII;
        float speed = BaseUseTime / (float)ReaperCombatRegistry.GetUseTime(form, stage);
        speed *= ReaperDefinitions.GetAttackSpeedMultiplier(
            state.GetCommonNodeLevel(ReaperCommonNode.AttackSpeed));
        if (form == ReaperFormId.Blood && player.GetModPlayer<ReaperCombatPlayer>().BloodFrenzyActive)
        {
            int level = state.GetSkillLevel(ReaperFormId.Blood, 1);
            speed *= 1f + (level switch { 2 => 0.08f, >= 3 => 0.11f, _ => level > 0 ? 0.05f : 0f });
        }
        if (form == ReaperFormId.Death)
            speed *= ReaperCombatService.GetDeathPrimaryTempo(player);
        return speed;
    }

    public override void ModifyWeaponCrit(Player player, ref float crit)
    {
        crit += player.GetModPlayer<MyPlayer>().ReaperProgression.GetCommonNodeLevel(ReaperCommonNode.Crit) * 10f;
    }

    public override void ModifyWeaponKnockback(Player player, ref StatModifier knockback)
    {
        // Legacy per-item knockback remains serialized only so migration can value
        // old investment. Modern combat is entirely character-owned.
    }

    public override bool AltFunctionUse(Player player) => false;

    public override bool CanUseItem(Player player)
    {
        if (!UsesModernSwing)
            return true;

        if (SickleSwingProjectile.HasBlockingPrimarySwing(player))
            return false;
        if (player.ownedProjectileCounts[ModContent.ProjectileType<ReaperActionControllerProjectile>()] > 0)
            return false;
        if (player.GetModPlayer<MyPlayer>().IsReaperFormAssemblyActive())
            return false;

        Item.channel = false;
        Item.useTime = BaseUseTime;
        Item.useAnimation = BaseUseTime;
        return true;
    }

    public override bool Shoot(Player player, EntitySource_ItemUse_WithAmmo source, Vector2 position, Vector2 velocity, int type, int damage, float knockback)
    {
        if (!UsesModernSwing)
        {
            SpawnLegacyProjectiles(source, position, velocity, type, damage, knockback, player.whoAmI);
            return false;
        }

        SickleCombatSnapshot snapshot = SickleCombatSnapshot.Capture(player, damage, knockback);
        bool requireDeathAssembly = snapshot.Form == ReaperFormId.Death
            && ReaperCombatService.RequiresDeathPrimaryAssembly(player);
        int phase = ReaperCombatService.TakeNextPrimaryPhase(player, snapshot,
            out float deathTempo);
        int swingDirection = player.GetModPlayer<MyPlayer>().TakeNextSwingDirection(player.direction);
        Vector2 aim = velocity.SafeNormalize(Vector2.UnitX * player.direction);
        int projectileIndex = Projectile.NewProjectile(
            source,
            player.MountedCenter,
            aim,
            ModContent.ProjectileType<SickleSwingProjectile>(),
            damage,
            knockback,
            player.whoAmI,
            swingDirection,
            0f,
            Item.type);
        if (projectileIndex < 0 || projectileIndex >= Main.maxProjectiles)
            return false;
        if (Main.projectile[projectileIndex].ModProjectile is SickleSwingProjectile swing)
            swing.Configure(snapshot, phase, deathTempo,
                requireDeathAssembly: requireDeathAssembly);
        return false;
    }

    public override void OnHitNPC(Player player, NPC target, NPC.HitInfo hit, int damageDone)
    {
        if (Main.netMode == NetmodeID.MultiplayerClient)
        {
            if (player.whoAmI != Main.myPlayer)
                return;

            int healedLife = player.GetModPlayer<MyPlayer>().TryLifeSteal(damageDone, LifeStealLevel);
            LifeStealVisuals.Spawn(target.Center, player.whoAmI, LifeStealLevel, healedLife);
            return;
        }

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

        int localHealedLife = player.GetModPlayer<MyPlayer>().TryLifeSteal(damageDone, LifeStealLevel);
        LifeStealVisuals.Spawn(target.Center, player.whoAmI, LifeStealLevel, localHealedLife);
        MyGlobalProjectile.ApplyStatusEffects(target, player.whoAmI, LifeStealLevel, OnHitDebuffType, OnHitDebuffDuration, FatedStacksPerHit, FatedMaximumStacks, FatedDurationFrames);
    }

    public override void ModifyTooltips(List<TooltipLine> tooltips)
    {
        ReaperFormId form = ResolvePresentationForm();
        string formName = Language.GetTextValue($"Mods.SoulHarvest.UI.ReaperTree.Forms.{form}.Name");
        tooltips.Add(new TooltipLine(Mod, "SoulHarvestReaperForm", formName)
        {
            OverrideColor = ReaperCombatRegistry.GetPrimaryColor(form)
        });
        string special = Language.GetTextValue("Mods.SoulHarvest.Keybinds.SpecialAttack.DisplayName");
        string ultimate = Language.GetTextValue("Mods.SoulHarvest.Keybinds.Ultimate.DisplayName");
        string wheel = Language.GetTextValue("Mods.SoulHarvest.Keybinds.FormWheel.DisplayName");
        tooltips.Add(new TooltipLine(Mod, "SoulHarvestReaperControls", $"{special}  ·  {ultimate}  ·  {wheel}")
        {
            OverrideColor = new Color(160, 205, 235)
        });
    }

    public DeathAltarPrice GetUpgradePrice(DeathAltarUpgradeType upgradeType)
    {
        return upgradeType switch
        {
            DeathAltarUpgradeType.Advancement => PrimaryNextSickleType == ItemID.None
                || SickleTier == 3 && !AllNonAdvancementUpgradesMaxed
                ? DeathAltarPrice.Unavailable
                : DeathAltarPrice.Essence(GetAdvancementEssenceCost()),
            DeathAltarUpgradeType.AlternateAdvancement => AlternateNextSickleType == ItemID.None
                ? DeathAltarPrice.Unavailable
                : DeathAltarPrice.Essence(GetAdvancementEssenceCost()),
            DeathAltarUpgradeType.Regression => SickleTier <= 0
                ? DeathAltarPrice.Unavailable
                : DeathAltarPrice.RefundEssence(GetRegressionEssenceRefund()),
            DeathAltarUpgradeType.Damage => DamageLevel >= MaxDamageLevel
                ? DeathAltarPrice.Unavailable
                : DamageLevel < 20
                    ? DeathAltarPrice.Souls(DamageLevel * 2)
                    : DeathAltarPrice.Essence(DamageLevel < 30
                        ? 1 + (DamageLevel - 20) / 2
                        : 6 + DamageLevel - 30),
            DeathAltarUpgradeType.ProjectileCount => !SupportsProjectileCount || ProjectileCount >= MaxProjectileCount
                ? DeathAltarPrice.Unavailable
                : DeathAltarPrice.Essence(1 << (ProjectileCount - 1)),
            DeathAltarUpgradeType.ProjectileDuration => !SupportsProjectileDuration || ProjectileDurationLevel >= MaxProjectileDurationLevel
                ? DeathAltarPrice.Unavailable
                : DeathAltarPrice.Essence(1 + (ProjectileDurationLevel - 1) / 2),
            DeathAltarUpgradeType.ArmorPenetration => ArmorPenetrationLevel >= MaxArmorPenetrationLevel
                ? DeathAltarPrice.Unavailable
                : DeathAltarPrice.Essence(1 + (ArmorPenetrationLevel - 1) / 3),
            DeathAltarUpgradeType.LifeSteal => LifeStealLevel >= MaxLifeStealLevel
                ? DeathAltarPrice.Unavailable
                : DeathAltarPrice.Essence(1 + LifeStealLevel * 2),
            DeathAltarUpgradeType.AttackSpeed => AttackSpeedLevel >= MaxAttackSpeedLevel
                ? DeathAltarPrice.Unavailable
                : AttackSpeedLevel < 6 ? DeathAltarPrice.Souls(AttackSpeedLevel * 25) : DeathAltarPrice.Essence(1 + AttackSpeedLevel / 2),
            DeathAltarUpgradeType.Knockback => KnockbackLevel >= MaxKnockbackLevel
                ? DeathAltarPrice.Unavailable
                : KnockbackLevel < 6 ? DeathAltarPrice.Souls(KnockbackLevel * 15) : DeathAltarPrice.Essence(1 + KnockbackLevel / 3),
            DeathAltarUpgradeType.HitCooldown => !SupportsProjectileCount || HitCooldownLevel >= MaxHitCooldownLevel
                ? DeathAltarPrice.Unavailable
                : DeathAltarPrice.Essence(1 + (HitCooldownLevel - 1) / 2),
            DeathAltarUpgradeType.FatedUnlock => FatedUnlocked
                ? DeathAltarPrice.Unavailable
                : DeathAltarPrice.Essence(1),
            DeathAltarUpgradeType.FatedMaxStacks => !FatedUnlocked || FatedMaximumStacksLevel >= MaxFatedStackLevel
                ? DeathAltarPrice.Unavailable
                : DeathAltarPrice.Essence(FatedMaximumStacksLevel),
            DeathAltarUpgradeType.FatedStacksPerHit => !FatedUnlocked || FatedStacksPerHitLevel >= MaxFatedStacksPerHitLevel
                ? DeathAltarPrice.Unavailable
                : DeathAltarPrice.Essence(FatedStacksPerHitLevel * 2),
            DeathAltarUpgradeType.FatedDuration => !FatedUnlocked || FatedDurationLevel >= MaxFatedDurationLevel
                ? DeathAltarPrice.Unavailable
                : DeathAltarPrice.Essence(1 + (FatedDurationLevel - 1) / 2),
            _ => DeathAltarPrice.Unavailable
        };
    }

    public bool ApplyAltarUpgrade(Item item, DeathAltarUpgradeType upgradeType)
    {
        return upgradeType switch
        {
            DeathAltarUpgradeType.Advancement or DeathAltarUpgradeType.AlternateAdvancement => Advance(item, upgradeType),
            DeathAltarUpgradeType.Regression => Regress(item),
            _ => ApplyUpgrade(upgradeType)
        };
    }

    public string GetCurrentUpgradeValue(DeathAltarUpgradeType upgradeType)
    {
        return upgradeType switch
        {
            DeathAltarUpgradeType.Advancement or DeathAltarUpgradeType.AlternateAdvancement or DeathAltarUpgradeType.Regression => Item.Name,
            DeathAltarUpgradeType.Damage => Language.GetTextValue("Mods.SoulHarvest.UI.UpgradeValues.Damage", DamageLevel, ScaledBaseDamage),
            DeathAltarUpgradeType.AttackSpeed => Language.GetTextValue("Mods.SoulHarvest.UI.UpgradeValues.Frames", AttackUseTime),
            DeathAltarUpgradeType.Knockback => TotalBaseKnockback.ToString("0.0"),
            DeathAltarUpgradeType.ProjectileCount => $"×{ProjectileCount}",
            DeathAltarUpgradeType.ProjectileDuration => $"{ProjectileLifetimeMultiplier:P0}",
            DeathAltarUpgradeType.ArmorPenetration => $"{ArmorPenetrationLevel}%",
            DeathAltarUpgradeType.HitCooldown => Language.GetTextValue("Mods.SoulHarvest.UI.UpgradeValues.Frames", HitCooldownFrames),
            DeathAltarUpgradeType.LifeSteal => $"{LifeStealLevel}%",
            DeathAltarUpgradeType.FatedUnlock => Language.GetTextValue(FatedUnlocked ? "Mods.SoulHarvest.UI.Enabled" : "Mods.SoulHarvest.UI.Disabled"),
            DeathAltarUpgradeType.FatedMaxStacks => Language.GetTextValue("Mods.SoulHarvest.UI.UpgradeValues.Stacks", FatedMaximumStacks),
            DeathAltarUpgradeType.FatedStacksPerHit => Language.GetTextValue("Mods.SoulHarvest.UI.UpgradeValues.StacksPerHit", FatedStacksPerHit),
            DeathAltarUpgradeType.FatedDuration => Language.GetTextValue("Mods.SoulHarvest.UI.UpgradeValues.Seconds", FatedDurationFrames / 60f),
            _ => string.Empty
        };
    }

    public string GetNextUpgradeValue(DeathAltarUpgradeType upgradeType)
    {
        return upgradeType switch
        {
            DeathAltarUpgradeType.Advancement => Lang.GetItemNameValue(PrimaryNextSickleType),
            DeathAltarUpgradeType.AlternateAdvancement => Lang.GetItemNameValue(AlternateNextSickleType),
            DeathAltarUpgradeType.Regression => Lang.GetItemNameValue(GetPreviousSickleType()),
            DeathAltarUpgradeType.Damage => Language.GetTextValue("Mods.SoulHarvest.UI.UpgradeValues.Damage", DamageLevel + 1, GetScaledDamage(DamageLevel + 1)),
            DeathAltarUpgradeType.AttackSpeed => Language.GetTextValue("Mods.SoulHarvest.UI.UpgradeValues.Frames", AttackUseTime - 1),
            DeathAltarUpgradeType.Knockback => (TotalBaseKnockback + 0.5f).ToString("0.0"),
            DeathAltarUpgradeType.ProjectileCount => $"×{ProjectileCount + 1}",
            DeathAltarUpgradeType.ProjectileDuration => $"{1f + ProjectileDurationLevel * 0.12f:P0}",
            DeathAltarUpgradeType.ArmorPenetration => $"{ArmorPenetrationLevel + 1}%",
            DeathAltarUpgradeType.HitCooldown => Language.GetTextValue("Mods.SoulHarvest.UI.UpgradeValues.Frames", HitCooldownFrames - 1),
            DeathAltarUpgradeType.LifeSteal => $"{LifeStealLevel + 1}%",
            DeathAltarUpgradeType.FatedUnlock => Language.GetTextValue("Mods.SoulHarvest.UI.Enabled"),
            DeathAltarUpgradeType.FatedMaxStacks => Language.GetTextValue("Mods.SoulHarvest.UI.UpgradeValues.Stacks", GetFatedMaximumStacks(FatedMaximumStacksLevel + 1)),
            DeathAltarUpgradeType.FatedStacksPerHit => Language.GetTextValue("Mods.SoulHarvest.UI.UpgradeValues.StacksPerHit", FatedStacksPerHitLevel + 1),
            DeathAltarUpgradeType.FatedDuration => Language.GetTextValue("Mods.SoulHarvest.UI.UpgradeValues.Seconds", (BaseFatedDurationFrames + FatedDurationLevel * 60) / 60f),
            _ => string.Empty
        };
    }

    public DeathAltarUnavailableReason GetUnavailableReason(DeathAltarUpgradeType upgradeType)
    {
        if (upgradeType == DeathAltarUpgradeType.Advancement
            && PrimaryNextSickleType != ItemID.None
            && SickleTier == 3
            && !AllNonAdvancementUpgradesMaxed)
        {
            return DeathAltarUnavailableReason.RequiresAllSickleUpgrades;
        }
        if (upgradeType == DeathAltarUpgradeType.Regression && SickleTier <= 0)
            return DeathAltarUnavailableReason.RequiresHigherSickleForm;
        if ((upgradeType is DeathAltarUpgradeType.ProjectileCount or DeathAltarUpgradeType.ProjectileDuration or DeathAltarUpgradeType.HitCooldown) && !SupportsProjectileCount)
            return DeathAltarUnavailableReason.RequiresProjectile;
        if ((upgradeType is DeathAltarUpgradeType.FatedMaxStacks or DeathAltarUpgradeType.FatedStacksPerHit or DeathAltarUpgradeType.FatedDuration) && !FatedUnlocked)
            return DeathAltarUnavailableReason.RequiresFatedUnlock;
        return DeathAltarUnavailableReason.Maxed;
    }

    internal void SpawnAttackProjectiles(Player player, Projectile swingProjectile, float charge, bool heavySlash)
    {
        if (!SupportsProjectileCount || AttackProjectileType <= ProjectileID.None)
            return;

        int count = ProjectileCount;
        if (heavySlash)
        {
            float projectileMultiplier = SupportsCharge
                ? MathHelper.Lerp(1f, 2f, MathHelper.Clamp(charge, 0f, 1f))
                : 1.5f;
            count = Math.Max(1, (int)Math.Ceiling(ProjectileCount * projectileMultiplier));
        }
        count = Math.Clamp(count, 1, MaxProjectileCount * 2);
        if (heavySlash && AlternateAttackStyle == SickleAlternateAttackStyle.BoneBarrage)
            count = Math.Max(3, count);
        else if (heavySlash && AlternateAttackStyle == SickleAlternateAttackStyle.VoidRitual)
            count = Math.Max(4, count);

        float spread = MathHelper.ToRadians(heavySlash && AlternateAttackStyle == SickleAlternateAttackStyle.BoneBarrage ? 18f : heavySlash ? 12f : 7f);
        float speed = AttackProjectileSpeed * (heavySlash ? MathHelper.Lerp(1.1f, 1.35f, charge) : 1f);
        int damage = Math.Max(1, (int)(swingProjectile.damage * ProjectileDamageFactor * (heavySlash ? MathHelper.Lerp(1.25f, 1.9f, charge) : 1f)));
        Vector2 aim = swingProjectile.velocity.SafeNormalize(Vector2.UnitX * player.direction);
        IEntitySource source = swingProjectile.GetSource_FromThis();

        for (int index = 0; index < count; index++)
        {
            float spreadSlot = GetForwardBiasedSpreadSlot(index, (swingProjectile.identity & 1) == 0 ? 1f : -1f);
            Vector2 projectileDirection = heavySlash && AlternateAttackStyle == SickleAlternateAttackStyle.VoidRitual
                ? aim.RotatedBy(index * MathHelper.TwoPi / count)
                : aim.RotatedBy(spreadSlot * spread);
            Vector2 projectileVelocity = projectileDirection * speed;
            Projectile.NewProjectile(
                source,
                player.MountedCenter + projectileDirection * 28f,
                projectileVelocity,
                AttackProjectileType,
                damage,
                swingProjectile.knockBack,
                player.whoAmI,
                heavySlash ? 1f : 0f,
                charge,
                spreadSlot);
        }
    }

    private bool ApplyUpgrade(DeathAltarUpgradeType upgradeType)
    {
        if (!GetUpgradePrice(upgradeType).IsAvailable)
            return false;

        switch (upgradeType)
        {
            case DeathAltarUpgradeType.Damage:
                DamageLevel++;
                break;
            case DeathAltarUpgradeType.ProjectileCount:
                ProjectileCount++;
                break;
            case DeathAltarUpgradeType.ProjectileDuration:
                ProjectileDurationLevel++;
                break;
            case DeathAltarUpgradeType.ArmorPenetration:
                ArmorPenetrationLevel++;
                break;
            case DeathAltarUpgradeType.LifeSteal:
                LifeStealLevel++;
                break;
            case DeathAltarUpgradeType.AttackSpeed:
                AttackSpeedLevel++;
                break;
            case DeathAltarUpgradeType.Knockback:
                KnockbackLevel++;
                break;
            case DeathAltarUpgradeType.HitCooldown:
                HitCooldownLevel++;
                break;
            case DeathAltarUpgradeType.FatedUnlock:
                FatedUnlocked = true;
                break;
            case DeathAltarUpgradeType.FatedMaxStacks:
                FatedMaximumStacksLevel++;
                break;
            case DeathAltarUpgradeType.FatedStacksPerHit:
                FatedStacksPerHitLevel++;
                break;
            case DeathAltarUpgradeType.FatedDuration:
                FatedDurationLevel++;
                break;
            default:
                return false;
        }

        ApplyDynamicStats();
        return true;
    }

    internal bool Advance(Item item, DeathAltarUpgradeType advancementType)
    {
        int nextType = advancementType == DeathAltarUpgradeType.AlternateAdvancement
            ? AlternateNextSickleType
            : PrimaryNextSickleType;
        return TransformSickle(item, nextType, recordCurrentForm: true);
    }

    private bool Regress(Item item)
    {
        if (SickleTier <= 0)
            return false;
        return TransformSickle(item, GetPreviousSickleType(), recordCurrentForm: false);
    }

    private bool TransformSickle(Item item, int targetType, bool recordCurrentForm)
    {
        if (targetType <= ItemID.None || ItemLoader.GetItem(targetType) is not NormalSickle targetTemplate)
            return false;
        int expectedTier = SickleTier + (recordCurrentForm ? 1 : -1);
        if (targetTemplate.SickleTier != expectedTier)
            return false;

        int damageLevel = DamageLevel;
        int projectileCount = ProjectileCount;
        int projectileDurationLevel = ProjectileDurationLevel;
        int armorPenetrationLevel = ArmorPenetrationLevel;
        int lifeStealLevel = LifeStealLevel;
        int attackSpeedLevel = AttackSpeedLevel;
        int knockbackLevel = KnockbackLevel;
        int hitCooldownLevel = HitCooldownLevel;
        bool fatedUnlocked = FatedUnlocked;
        int fatedMaximumStacksLevel = FatedMaximumStacksLevel;
        int fatedStacksPerHitLevel = FatedStacksPerHitLevel;
        int fatedDurationLevel = FatedDurationLevel;
        int tierOneHistory = previousTierOneType;
        int tierTwoHistory = previousTierTwoType;
        int tierThreeHistory = previousTierThreeType;
        int currentType = item.type;
        if (recordCurrentForm)
        {
            switch (SickleTier)
            {
                case 1:
                    tierOneHistory = currentType;
                    break;
                case 2:
                    tierTwoHistory = currentType;
                    break;
                case 3:
                    tierThreeHistory = currentType;
                    break;
            }
        }
        int prefix = item.prefix;
        bool favorited = item.favorited;

        item.SetDefaults(targetType);
        if (prefix > 0)
            item.Prefix(prefix);

        if (item.ModItem is not NormalSickle upgraded)
            return false;

        upgraded.DamageLevel = damageLevel;
        upgraded.ProjectileCount = projectileCount;
        upgraded.ProjectileDurationLevel = projectileDurationLevel;
        upgraded.ArmorPenetrationLevel = armorPenetrationLevel;
        upgraded.LifeStealLevel = lifeStealLevel;
        upgraded.AttackSpeedLevel = attackSpeedLevel;
        upgraded.KnockbackLevel = knockbackLevel;
        upgraded.HitCooldownLevel = hitCooldownLevel;
        upgraded.FatedUnlocked = fatedUnlocked;
        upgraded.FatedMaximumStacksLevel = fatedMaximumStacksLevel;
        upgraded.FatedStacksPerHitLevel = fatedStacksPerHitLevel;
        upgraded.FatedDurationLevel = fatedDurationLevel;
        upgraded.previousTierOneType = tierOneHistory;
        upgraded.previousTierTwoType = tierTwoHistory;
        upgraded.previousTierThreeType = tierThreeHistory;
        upgraded.ApplyDynamicStats();
        item.favorited = favorited;
        return true;
    }

    private int GetPreviousSickleType()
    {
        int storedType = SickleTier switch
        {
            2 => previousTierOneType,
            3 => previousTierTwoType,
            4 => previousTierThreeType,
            _ => ItemID.None
        };
        if (storedType > ItemID.None
            && ItemLoader.GetItem(storedType) is NormalSickle storedSickle
            && storedSickle.SickleTier == SickleTier - 1)
        {
            return storedType;
        }

        // Items created before regression history existed use a deterministic
        // canonical predecessor. Newly advanced items always return along the exact
        // branch the player originally chose.
        return this switch
        {
            SkullSickle or BloodSickle => ModContent.ItemType<NormalSickle>(),
            FrostSickle => ModContent.ItemType<BloodSickle>(),
            DevilSickle => ModContent.ItemType<SkullSickle>(),
            SoulSickle => ModContent.ItemType<DevilSickle>(),
            VoidSickle => ModContent.ItemType<FrostSickle>(),
            DeathSickle => ModContent.ItemType<SoulSickle>(),
            _ => ItemID.None
        };
    }

    public override void SaveData(TagCompound tag)
    {
        tag[nameof(DamageLevel)] = DamageLevel;
        tag[nameof(ProjectileCount)] = ProjectileCount;
        tag[nameof(ProjectileDurationLevel)] = ProjectileDurationLevel;
        tag[nameof(ArmorPenetrationLevel)] = ArmorPenetrationLevel;
        tag[nameof(LifeStealLevel)] = LifeStealLevel;
        tag[nameof(AttackSpeedLevel)] = AttackSpeedLevel;
        tag[nameof(KnockbackLevel)] = KnockbackLevel;
        tag[nameof(HitCooldownLevel)] = HitCooldownLevel;
        tag[nameof(FatedUnlocked)] = FatedUnlocked;
        tag[nameof(FatedMaximumStacksLevel)] = FatedMaximumStacksLevel;
        tag[nameof(FatedStacksPerHitLevel)] = FatedStacksPerHitLevel;
        tag[nameof(FatedDurationLevel)] = FatedDurationLevel;
        tag[nameof(previousTierOneType)] = previousTierOneType;
        tag[nameof(previousTierTwoType)] = previousTierTwoType;
        tag[nameof(previousTierThreeType)] = previousTierThreeType;
    }

    public override void LoadData(TagCompound tag)
    {
        DamageLevel = Math.Clamp(tag.GetInt(nameof(DamageLevel)), 1, MaxDamageLevel);
        ProjectileCount = Math.Clamp(tag.GetInt(nameof(ProjectileCount)), 1, MaxProjectileCount);
        ProjectileDurationLevel = Math.Clamp(tag.GetInt(nameof(ProjectileDurationLevel)), 1, MaxProjectileDurationLevel);
        ArmorPenetrationLevel = Math.Clamp(tag.GetInt(nameof(ArmorPenetrationLevel)), 1, MaxArmorPenetrationLevel);
        LifeStealLevel = Math.Clamp(tag.GetInt(nameof(LifeStealLevel)), 0, MaxLifeStealLevel);
        AttackSpeedLevel = Math.Clamp(tag.GetInt(nameof(AttackSpeedLevel)), 1, MaxAttackSpeedLevel);
        KnockbackLevel = Math.Clamp(tag.GetInt(nameof(KnockbackLevel)), 1, MaxKnockbackLevel);
        HitCooldownLevel = Math.Clamp(tag.GetInt(nameof(HitCooldownLevel)), 1, MaxHitCooldownLevel);
        FatedUnlocked = tag.GetBool(nameof(FatedUnlocked));
        FatedMaximumStacksLevel = Math.Clamp(tag.GetInt(nameof(FatedMaximumStacksLevel)), 1, MaxFatedStackLevel);
        FatedStacksPerHitLevel = Math.Clamp(tag.GetInt(nameof(FatedStacksPerHitLevel)), 1, MaxFatedStacksPerHitLevel);
        FatedDurationLevel = Math.Clamp(tag.GetInt(nameof(FatedDurationLevel)), 1, MaxFatedDurationLevel);
        previousTierOneType = tag.GetInt(nameof(previousTierOneType));
        previousTierTwoType = tag.GetInt(nameof(previousTierTwoType));
        previousTierThreeType = tag.GetInt(nameof(previousTierThreeType));
        ApplyDynamicStats();
    }

    public override void NetSend(BinaryWriter writer)
    {
        writer.Write((byte)DamageLevel);
        writer.Write((byte)ProjectileCount);
        writer.Write((byte)ProjectileDurationLevel);
        writer.Write((byte)ArmorPenetrationLevel);
        writer.Write((byte)LifeStealLevel);
        writer.Write((byte)AttackSpeedLevel);
        writer.Write((byte)KnockbackLevel);
        writer.Write((byte)HitCooldownLevel);
        writer.Write(FatedUnlocked);
        writer.Write((byte)FatedMaximumStacksLevel);
        writer.Write((byte)FatedStacksPerHitLevel);
        writer.Write((byte)FatedDurationLevel);
        writer.Write(previousTierOneType);
        writer.Write(previousTierTwoType);
        writer.Write(previousTierThreeType);
    }

    public override void NetReceive(BinaryReader reader)
    {
        DamageLevel = Math.Clamp((int)reader.ReadByte(), 1, MaxDamageLevel);
        ProjectileCount = Math.Clamp((int)reader.ReadByte(), 1, MaxProjectileCount);
        ProjectileDurationLevel = Math.Clamp((int)reader.ReadByte(), 1, MaxProjectileDurationLevel);
        ArmorPenetrationLevel = Math.Clamp((int)reader.ReadByte(), 1, MaxArmorPenetrationLevel);
        LifeStealLevel = Math.Clamp((int)reader.ReadByte(), 0, MaxLifeStealLevel);
        AttackSpeedLevel = Math.Clamp((int)reader.ReadByte(), 1, MaxAttackSpeedLevel);
        KnockbackLevel = Math.Clamp((int)reader.ReadByte(), 1, MaxKnockbackLevel);
        HitCooldownLevel = Math.Clamp((int)reader.ReadByte(), 1, MaxHitCooldownLevel);
        FatedUnlocked = reader.ReadBoolean();
        FatedMaximumStacksLevel = Math.Clamp((int)reader.ReadByte(), 1, MaxFatedStackLevel);
        FatedStacksPerHitLevel = Math.Clamp((int)reader.ReadByte(), 1, MaxFatedStacksPerHitLevel);
        FatedDurationLevel = Math.Clamp((int)reader.ReadByte(), 1, MaxFatedDurationLevel);
        previousTierOneType = reader.ReadInt32();
        previousTierTwoType = reader.ReadInt32();
        previousTierThreeType = reader.ReadInt32();
        ApplyDynamicStats();
    }

    public override void AddRecipes()
    {
        CreateRecipe()
            .AddRecipeGroup(RecipeGroupID.Wood, 10)
            .AddRecipeGroup(RecipeGroupID.IronBar, 5)
            .AddTile(TileID.Anvils)
            .Register();
    }

    internal void ApplyDynamicStats()
    {
        // Percentage penetration is applied through NPC.HitModifiers so this must not
        // also contribute vanilla's flat armor penetration.
        Item.ArmorPenetration = 0;
    }

    private static int GetFatedMaximumStacks(int level)
    {
        return 1 + (Math.Clamp(level, 1, MaxFatedStackLevel) - 1) * 3;
    }

    private int GetScaledDamage(int level)
    {
        level = Math.Clamp(level, 1, MaxDamageLevel);
        if (level == 1)
            return Math.Max(1, BaseSickleDamage);

        const int lateGameStartLevel = 29;
        double earlyMidGrowth = GetUnscaledDamageGrowth(Math.Min(level, lateGameStartLevel - 1)) * 0.25d;
        double lateGameGrowth = level < lateGameStartLevel
            ? 0d
            : (GetUnscaledDamageGrowth(level) - GetUnscaledDamageGrowth(lateGameStartLevel - 1)) * 0.70d;
        return Math.Max(1, (int)Math.Round(BaseSickleDamage + earlyMidGrowth + lateGameGrowth));
    }

    private double GetUnscaledDamageGrowth(int level)
    {
        double progress = (level - 1d) / (MaxDamageLevel - 1d);
        double targetDamage = 1_200d + SickleTier * 550d;
        double guaranteedGrowth = level - 1d;
        double curvedGrowth = Math.Max(0d, targetDamage - BaseSickleDamage - (MaxDamageLevel - 1d)) * Math.Pow(progress, 2.3d);
        return guaranteedGrowth + curvedGrowth;
    }

    private int GetAdvancementEssenceCost()
    {
        // The price is based on the target form: form 2 is the introduction, while
        // forms 3-5 become deliberate long-term milestones.
        return SickleTier switch
        {
            0 => 1,
            1 => 16,
            2 => 32,
            3 => 99,
            _ => 999
        };
    }

    private int GetRegressionEssenceRefund()
    {
        // Refund exactly the cost paid to enter the current form.
        return SickleTier switch
        {
            1 => 1,
            2 => 16,
            3 => 32,
            4 => 99,
            _ => 0
        };
    }

    private void SpawnLegacyProjectiles(IEntitySource source, Vector2 position, Vector2 velocity, int type, int damage, float knockback, int owner)
    {
        if (!SupportsProjectileCount || type <= ProjectileID.None)
            return;

        int count = Math.Clamp(ProjectileCount, 1, MaxProjectileCount);
        float spread = MathHelper.ToRadians(7f);
        for (int index = 0; index < count; index++)
        {
            float offset = GetForwardBiasedSpreadSlot(index, 1f) * spread;
            Projectile.NewProjectile(source, position, velocity.RotatedBy(offset), type, damage, knockback, owner);
        }
    }

    private static float GetForwardBiasedSpreadSlot(int index, float firstSide)
    {
        if (index <= 0)
            return 0f;

        int distance = (index + 1) / 2;
        float side = index % 2 == 1 ? firstSide : -firstSide;
        return distance * side;
    }

    private Color GetGradientColor()
    {
        float pulse = (float)Math.Sin(Main.GlobalTimeWrappedHourly * 2.4f) * 0.16f + 0.84f;
        return SickleColor * pulse;
    }

    private static ReaperFormId ResolvePresentationForm()
    {
        if (Main.gameMenu || Main.LocalPlayer is null || !Main.LocalPlayer.active)
            return ReaperFormId.Base;
        return ReaperCombatRegistry.ResolveUsableForm(Main.LocalPlayer.GetModPlayer<MyPlayer>().ReaperProgression);
    }

    private static ReaperStage ResolvePresentationStage(ReaperFormId form)
    {
        if (form == ReaperFormId.Death)
            return ReaperStage.StageIII;
        if (!ReaperDefinitions.IsBranchForm(form)
            || Main.gameMenu
            || Main.LocalPlayer is null
            || !Main.LocalPlayer.active)
        {
            return ReaperStage.StageI;
        }

        ReaperStage stage = Main.LocalPlayer.GetModPlayer<MyPlayer>().ReaperProgression.GetStage(form);
        return stage is >= ReaperStage.StageI and <= ReaperStage.StageIII
            ? stage
            : ReaperStage.StageI;
    }
}