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 AltarUpgradeTypes => Array.Empty(); 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() : 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( 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( 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().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().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().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().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().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()] > 0) return false; if (player.GetModPlayer().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().TakeNextSwingDirection(player.direction); Vector2 aim = velocity.SafeNormalize(Vector2.UnitX * player.direction); int projectileIndex = Projectile.NewProjectile( source, player.MountedCenter, aim, ModContent.ProjectileType(), 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().TryLifeSteal(damageDone, LifeStealLevel); LifeStealVisuals.Spawn(target.Center, player.whoAmI, LifeStealLevel, healedLife); return; } if (Main.netMode == NetmodeID.Server) return; int localHealedLife = player.GetModPlayer().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 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(), FrostSickle => ModContent.ItemType(), DevilSickle => ModContent.ItemType(), SoulSickle => ModContent.ItemType(), VoidSickle => ModContent.ItemType(), DeathSickle => ModContent.ItemType(), _ => 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().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().ReaperProgression.GetStage(form); return stage is >= ReaperStage.StageI and <= ReaperStage.StageIII ? stage : ReaperStage.StageI; } }