using SoulHarvest.Buffs; using SoulHarvest.Items; using SoulHarvest.Projectiles; using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Graphics; using System; using System.IO; using Terraria; using Terraria.GameContent; using Terraria.ID; using Terraria.ModLoader; using Terraria.ModLoader.IO; namespace SoulHarvest.Common; public class MyGlobalNPC : GlobalNPC { internal const int DeathMarkDuration = 60 * 60 * 10; internal const int AbsoluteMaximumFatedStacks = NormalSickle.MaxFatedStackCapacity; public override bool InstancePerEntity => true; private readonly bool[] sickleParticipants = new bool[Main.maxPlayers]; private readonly int[] fatedStackTimers = new int[AbsoluteMaximumFatedStacks]; private readonly int[] fatedStackOwners = new int[AbsoluteMaximumFatedStacks]; private readonly int[] fatedStackLifeStealLevels = new int[AbsoluteMaximumFatedStacks]; private int lastSicklePlayer = -1; private int fatedSecondTimer; private bool deathMarked; private int deathDomainHitStopFrames; private Vector2 deathDomainStoredVelocity; public int FatedStackCount { get; private set; } public override void SetDefaults(NPC npc) { Array.Clear(sickleParticipants); Array.Clear(fatedStackTimers); Array.Fill(fatedStackOwners, -1); Array.Clear(fatedStackLifeStealLevels); lastSicklePlayer = -1; fatedSecondTimer = 0; deathMarked = false; deathDomainHitStopFrames = 0; deathDomainStoredVelocity = Vector2.Zero; FatedStackCount = 0; } public override bool PreAI(NPC npc) { if (deathDomainHitStopFrames <= 0) return true; deathDomainHitStopFrames--; npc.velocity = Vector2.Zero; if (deathDomainHitStopFrames <= 0) { // Restore only part of the previous movement so the pause reads as an // impact without launching fast enemies immediately out of the blade. npc.velocity = deathDomainStoredVelocity * 0.35f; deathDomainStoredVelocity = Vector2.Zero; if (Main.netMode != NetmodeID.MultiplayerClient) npc.netUpdate = true; } return false; } public override void ModifyHitByItem(NPC npc, Player player, Item item, ref NPC.HitModifiers modifiers) { if (item.ModItem is not (NormalSickle or LegacyDeath)) return; if (item.ModItem is NormalSickle sickle) modifiers.ScalingArmorPenetration += sickle.ArmorPenetrationPercent; if (Main.netMode == NetmodeID.MultiplayerClient) { return; } // The server records the reaper before damage is applied. This avoids a one-shot kill // racing ahead of the client's early registration packet. RegisterSickleHit(npc, player.whoAmI); } public override void OnHitByItem(NPC npc, Player player, Item item, NPC.HitInfo hit, int damageDone) { if (item.ModItem is NormalSickle or LegacyDeath) RegisterSickleHit(npc, player.whoAmI); } public override void OnHitByProjectile(NPC npc, Projectile projectile, NPC.HitInfo hit, int damageDone) { if (projectile.GetGlobalProjectile().IsSickleProjectile) RegisterSickleHit(npc, projectile.owner); } public override void PostAI(NPC npc) { if (Main.netMode != NetmodeID.MultiplayerClient && lastSicklePlayer >= 0) npc.AddBuff(ModContent.BuffType(), 2); UpdateFatedState(npc); SpawnStatusParticles(npc); } public override void PostDraw(NPC npc, SpriteBatch spriteBatch, Vector2 screenPos, Color drawColor) { if (!deathMarked) return; Texture2D markTexture = TextureAssets.Buff[ModContent.BuffType()].Value; float pulse = 0.52f + (float)Math.Sin(Main.GlobalTimeWrappedHourly * 5f + npc.whoAmI) * 0.035f; Vector2 position = new Vector2(npc.Center.X, npc.Top.Y - 13f) - screenPos; Color auraColor = FatedStackCount > 0 ? new Color(235, 55, 150, 0) : new Color(65, 235, 255, 0); for (int index = 0; index < 4; index++) { Vector2 offset = (Main.GlobalTimeWrappedHourly * 2f + index * MathHelper.PiOver2).ToRotationVector2() * 2f; spriteBatch.Draw(markTexture, position + offset, null, auraColor * 0.32f, 0f, markTexture.Size() / 2f, pulse * 1.12f, SpriteEffects.None, 0f); } spriteBatch.Draw(markTexture, position, null, Color.White, 0f, markTexture.Size() / 2f, pulse, SpriteEffects.None, 0f); if (FatedStackCount <= 0) return; Texture2D fatedTexture = TextureAssets.Buff[ModContent.BuffType()].Value; Vector2 fatedPosition = position + new Vector2(18f, 1f); spriteBatch.Draw(fatedTexture, fatedPosition, null, Color.White, 0f, fatedTexture.Size() / 2f, 0.44f, SpriteEffects.None, 0f); Utils.DrawBorderStringFourWay(spriteBatch, FontAssets.ItemStack.Value, FatedStackCount.ToString(), fatedPosition.X + 5f, fatedPosition.Y + 3f, new Color(255, 95, 180), Color.Black, Vector2.Zero, 0.58f); } public override void SendExtraAI(NPC npc, BitWriter bitWriter, BinaryWriter binaryWriter) { bitWriter.WriteBit(deathMarked); binaryWriter.Write((byte)FatedStackCount); } public override void ReceiveExtraAI(NPC npc, BitReader bitReader, BinaryReader binaryReader) { deathMarked = bitReader.ReadBit(); FatedStackCount = Math.Clamp((int)binaryReader.ReadByte(), 0, AbsoluteMaximumFatedStacks); } public override void OnKill(NPC npc) { if (Main.netMode == NetmodeID.MultiplayerClient || npc.friendly || npc.lifeMax <= 5) return; // Dungeon Guardians and Paladins are exceptional normal enemies: their unusual // health values should not distort the regular curve, and they always cap it. if (IsFixedMaximumSoulEnemy(npc.type)) { if (lastSicklePlayer >= 0 && lastSicklePlayer < Main.maxPlayers && Main.player[lastSicklePlayer].active) Main.player[lastSicklePlayer].GetModPlayer().AddSouls(50); return; } if (TryGetBossParticipants(npc, out bool[] bossParticipants, out int encounterLifeMax)) { int reward = CalculateSoulReward(encounterLifeMax, boss: true); for (int playerIndex = 0; playerIndex < Main.maxPlayers; playerIndex++) { if (bossParticipants[playerIndex] && Main.player[playerIndex].active) Main.player[playerIndex].GetModPlayer().AddSouls(reward, announce: true); } return; } if (IsCompositeBossPart(npc.type)) return; if (npc.realLife >= 0 && npc.realLife < Main.maxNPCs && Main.npc[npc.realLife].boss) return; if (!npc.boss && lastSicklePlayer >= 0 && lastSicklePlayer < Main.maxPlayers && Main.player[lastSicklePlayer].active) Main.player[lastSicklePlayer].GetModPlayer().AddSouls(CalculateSoulReward(npc.lifeMax, boss: false)); } internal void RegisterSickleHit(NPC npc, int playerIndex, bool applyDeathMark = true) { if (Main.netMode == NetmodeID.MultiplayerClient || playerIndex < 0 || playerIndex >= Main.maxPlayers || npc.friendly) return; if (applyDeathMark) MarkForReaping(npc); sickleParticipants[playerIndex] = true; lastSicklePlayer = playerIndex; BossSoulParticipationSystem.RecordCompositeBossHit(npc, playerIndex); if (npc.realLife >= 0 && npc.realLife < Main.maxNPCs && npc.realLife != npc.whoAmI) { MyGlobalNPC root = Main.npc[npc.realLife].GetGlobalNPC(); root.sickleParticipants[playerIndex] = true; root.lastSicklePlayer = playerIndex; } } #if DEBUG internal bool HasSickleHarvester(int playerIndex) => playerIndex >= 0 && playerIndex < Main.maxPlayers && sickleParticipants[playerIndex] && lastSicklePlayer == playerIndex; #endif internal void AddFatedStacks(NPC npc, int amount, int maximumStacks, int durationFrames, int playerIndex, int lifeStealLevel) { if (amount <= 0 || Main.netMode == NetmodeID.MultiplayerClient || npc.friendly || npc.lifeMax <= 5) return; maximumStacks = Math.Clamp(maximumStacks, 1, AbsoluteMaximumFatedStacks); durationFrames = Math.Clamp(durationFrames, 60, ushort.MaxValue); int stacksToAdd = Math.Min(amount, Math.Max(0, maximumStacks - FatedStackCount)); for (int index = 0; index < stacksToAdd; index++) { fatedStackTimers[FatedStackCount] = durationFrames; fatedStackOwners[FatedStackCount] = playerIndex is >= 0 and < Main.maxPlayers ? playerIndex : -1; fatedStackLifeStealLevels[FatedStackCount] = Math.Max(0, lifeStealLevel); FatedStackCount++; } for (int index = 0; index < FatedStackCount; index++) fatedStackTimers[index] = Math.Max(fatedStackTimers[index], durationFrames); npc.AddBuff(ModContent.BuffType(), durationFrames); npc.netUpdate = true; } internal void MarkForReaping(NPC npc) { bool newlyMarked = !deathMarked; deathMarked = true; npc.AddBuff(ModContent.BuffType(), DeathMarkDuration); SyncBuffState(npc); if (newlyMarked) SoulHarvest.BroadcastDeathMark(npc, marked: true); } internal void ApplyDeathDomainHitStop(NPC npc, int frames) { frames = Math.Clamp(frames, 1, 4); if (deathDomainHitStopFrames <= 0) deathDomainStoredVelocity = npc.velocity; deathDomainHitStopFrames = Math.Max(deathDomainHitStopFrames, frames); npc.velocity = Vector2.Zero; if (Main.netMode != NetmodeID.MultiplayerClient) npc.netUpdate = true; } internal void ReceiveDeathMarkState(bool marked) { deathMarked = marked; } internal static void SyncBuffState(NPC npc) { npc.netUpdate = true; if (Main.netMode == NetmodeID.Server) NetMessage.SendData(MessageID.NPCBuffs, -1, -1, null, npc.whoAmI); } private void UpdateFatedState(NPC npc) { if (Main.netMode == NetmodeID.MultiplayerClient || FatedStackCount <= 0) return; int writeIndex = 0; for (int index = 0; index < FatedStackCount; index++) { if (--fatedStackTimers[index] > 0) { fatedStackTimers[writeIndex] = fatedStackTimers[index]; fatedStackOwners[writeIndex] = fatedStackOwners[index]; fatedStackLifeStealLevels[writeIndex] = fatedStackLifeStealLevels[index]; writeIndex++; } } if (writeIndex != FatedStackCount) { FatedStackCount = writeIndex; npc.netUpdate = true; } if (FatedStackCount <= 0) { fatedSecondTimer = 0; return; } npc.AddBuff(ModContent.BuffType(), 2); if (++fatedSecondTimer < 60) return; fatedSecondTimer = 0; bool bossOrBossPart = npc.boss || npc.realLife >= 0 && npc.realLife < Main.maxNPCs && Main.npc[npc.realLife].boss; float ratePerStack = bossOrBossPart ? 0.01f : 1f / 3f; int damage = Math.Max(1, (int)Math.Ceiling(npc.lifeMax * ratePerStack * FatedStackCount)); int lifeBeforeHit = npc.life; Vector2 lifeStealSource = npc.Center; int[] stackCountsByPlayer = new int[Main.maxPlayers]; int[] lifeStealLevelByPlayer = new int[Main.maxPlayers]; for (int index = 0; index < FatedStackCount; index++) { int owner = fatedStackOwners[index]; if (owner < 0 || owner >= Main.maxPlayers) continue; stackCountsByPlayer[owner]++; lifeStealLevelByPlayer[owner] = Math.Max(lifeStealLevelByPlayer[owner], fatedStackLifeStealLevels[index]); } NPC.HitInfo fateHit = new() { Damage = damage, SourceDamage = damage, HitDirection = 0, Knockback = 0f, DamageType = DamageClass.Generic, Crit = false }; npc.StrikeNPC(fateHit, fromNet: false, noPlayerInteraction: false); if (Main.netMode == NetmodeID.Server) NetMessage.SendStrikeNPC(npc, in fateHit); int actualDamage = Math.Min(Math.Max(0, lifeBeforeHit), damage); for (int playerIndex = 0; playerIndex < Main.maxPlayers; playerIndex++) { int stackCount = stackCountsByPlayer[playerIndex]; int lifeStealLevel = lifeStealLevelByPlayer[playerIndex]; if (stackCount <= 0 || lifeStealLevel <= 0 || !Main.player[playerIndex].active || Main.player[playerIndex].dead) continue; int attributedDamage = Math.Max(1, (int)Math.Round(actualDamage * stackCount / (double)FatedStackCount)); int healedLife = Main.player[playerIndex].GetModPlayer().TryLifeSteal(attributedDamage, lifeStealLevel); LifeStealVisuals.Spawn(lifeStealSource, playerIndex, lifeStealLevel, healedLife); } } private void SpawnStatusParticles(NPC npc) { if (Main.netMode == NetmodeID.Server || !npc.active) return; if (deathMarked && Main.rand.NextBool(4)) { float angle = Main.GlobalTimeWrappedHourly * 3.1f + npc.whoAmI * 0.73f + Main.rand.NextFloat(-0.35f, 0.35f); Vector2 orbit = new Vector2(Math.Max(12f, npc.width * 0.58f), Math.Max(9f, npc.height * 0.36f)); Vector2 position = npc.Center + new Vector2((float)Math.Cos(angle) * orbit.X, (float)Math.Sin(angle) * orbit.Y); Dust markDust = Dust.NewDustPerfect(position, DustID.DungeonSpirit, -npc.velocity * 0.08f + Vector2.UnitY * -0.25f, 80, new Color(70, 230, 255), 0.72f); markDust.noGravity = true; } if (FatedStackCount <= 0) return; int particleCount = Main.rand.NextBool(2) ? Math.Min(2, FatedStackCount) : 1; for (int index = 0; index < particleCount; index++) { Vector2 position = npc.Center + Main.rand.NextVector2CircularEdge(npc.width * 0.62f + 8f, npc.height * 0.5f + 8f); Vector2 velocity = (npc.Center - position).SafeNormalize(Vector2.Zero) * Main.rand.NextFloat(0.45f, 1.2f); Dust fateDust = Dust.NewDustPerfect(position, DustID.Shadowflame, velocity, 55, new Color(245, 45, 145), 0.9f + FatedStackCount * 0.08f); fateDust.noGravity = true; } } internal static int CalculateSoulReward(int lifeMax, bool boss) { lifeMax = Math.Max(1, lifeMax); if (boss) return Math.Clamp((int)Math.Ceiling(1.3d * Math.Sqrt(lifeMax)), 1, 500); double normalizedLife = lifeMax / 25d; return Math.Clamp((int)Math.Ceiling(Math.Pow(normalizedLife, 0.55d)), 1, 50); } private bool TryGetBossParticipants(NPC npc, out bool[] participants, out int encounterLifeMax) { if (BossSoulParticipationSystem.TryTakeCompositeParticipants(npc, out participants, out encounterLifeMax)) return true; if (IsCompositeBossPart(npc.type)) { encounterLifeMax = 0; return false; } if (!npc.boss) { encounterLifeMax = 0; return false; } if (npc.realLife >= 0 && npc.realLife < Main.maxNPCs && npc.realLife != npc.whoAmI && Main.npc[npc.realLife].active) { encounterLifeMax = 0; return false; } if (npc.realLife >= 0 && npc.realLife < Main.maxNPCs) { participants = (bool[])Main.npc[npc.realLife].GetGlobalNPC().sickleParticipants.Clone(); encounterLifeMax = BossSoulParticipationSystem.GetEncounterLifeMax(npc); return true; } participants = (bool[])sickleParticipants.Clone(); encounterLifeMax = BossSoulParticipationSystem.GetEncounterLifeMax(npc); return true; } private static bool IsCompositeBossPart(int npcType) { return npcType is NPCID.Retinazer or NPCID.Spazmatism or NPCID.EaterofWorldsBody or NPCID.EaterofWorldsHead or NPCID.EaterofWorldsTail or NPCID.Creeper or NPCID.GolemHead or NPCID.GolemHeadFree or NPCID.MoonLordHead or NPCID.MoonLordHand; } private static bool IsFixedMaximumSoulEnemy(int npcType) { return npcType is NPCID.DungeonGuardian or NPCID.Paladin; } }