using SoulHarvest.Projectiles; using Microsoft.Xna.Framework; using System; using System.Collections.Generic; using System.IO; using Terraria; using Terraria.ID; using Terraria.ModLoader; namespace SoulHarvest.Common; public sealed class ReaperCombatPlayer : ModPlayer { private int bloodShield; private int bloodShieldIdle; private uint bloodShieldRevision; private int pendingMirroredBloodShieldAbsorption; private int pendingMirroredBloodShieldTimer; private int bloodHealingThisSecond; private int bloodHealingWindow; private int infernalMomentumTimer; private int ultimateTimer; private ReaperFormId ultimateForm; private bool bloodFrenzyLatched; private int lastBloodFinisherIdentity = -1; private int bloodFinisherTargets; private int deathUltimateHealing; private byte infernalSpecialMask; private int infernalSpecialMaskTimer; private int lastSoulActionId; private readonly HashSet soulActionTargets = []; private int bloodDrinkVisualCooldown; private int bloodShieldVisualCooldown; private int bloodFrenzyVisualRefresh; private int bloodShieldCounterCooldown; private int infernalScorchExecutionCooldown; private int infernalMomentumVisualCooldown; private SickleCombatSnapshot bloodShieldSnapshot; private bool hasBloodShieldSnapshot; private readonly int[,] lastSkillCueAction = new int[8, ReaperDefinitions.SkillsPerBranch]; private readonly ulong[,] lastSkillCueTick = new ulong[8, ReaperDefinitions.SkillsPerBranch]; private readonly bool[,] hasSkillCueAction = new bool[8, ReaperDefinitions.SkillsPerBranch]; private readonly byte[,] skillCueCenterSamples = new byte[8, ReaperDefinitions.SkillsPerBranch]; public bool BloodFrenzyActive => bloodFrenzyLatched; internal int BloodShieldAmount => bloodShield; internal bool FreezesWorldForDeathUltimate { get { if (ultimateForm != ReaperFormId.Death || ultimateTimer <= 0) return false; int elapsed = ReaperDeathUltimateGeometry.Duration - ultimateTimer; return elapsed >= ReaperDeathUltimateGeometry.FirstCutTick && elapsed <= ReaperDeathUltimateGeometry.ShatterTick; } } public override void Initialize() { ClearRuntime(); } internal void ClearRuntime() { bloodShield = 0; bloodShieldIdle = 0; if (Main.netMode == NetmodeID.Server) AdvanceBloodShieldRevision(); else bloodShieldRevision = 0; pendingMirroredBloodShieldAbsorption = 0; pendingMirroredBloodShieldTimer = 0; bloodHealingThisSecond = 0; bloodHealingWindow = 0; infernalMomentumTimer = 0; ultimateTimer = 0; ultimateForm = ReaperFormId.Base; bloodFrenzyLatched = false; lastBloodFinisherIdentity = -1; bloodFinisherTargets = 0; deathUltimateHealing = 0; infernalSpecialMask = 0; infernalSpecialMaskTimer = 0; lastSoulActionId = int.MinValue; soulActionTargets.Clear(); bloodDrinkVisualCooldown = 0; bloodShieldVisualCooldown = 0; bloodFrenzyVisualRefresh = 0; bloodShieldCounterCooldown = 0; infernalScorchExecutionCooldown = 0; infernalMomentumVisualCooldown = 0; bloodShieldSnapshot = default; hasBloodShieldSnapshot = false; Array.Clear(lastSkillCueAction); Array.Clear(lastSkillCueTick); Array.Clear(hasSkillCueAction); Array.Clear(skillCueCenterSamples); } internal void WriteOwnerRuntime(BinaryWriter writer) { writer.Write((ushort)Math.Clamp(bloodShield, 0, ushort.MaxValue)); writer.Write((ushort)Math.Clamp(bloodShieldIdle, 0, ushort.MaxValue)); writer.Write(bloodShieldRevision); writer.Write((ushort)Math.Clamp(infernalMomentumTimer, 0, ushort.MaxValue)); writer.Write((ushort)Math.Clamp(ultimateTimer, 0, ushort.MaxValue)); writer.Write((byte)ultimateForm); } internal void ReceiveOwnerRuntime(BinaryReader reader) { bloodShield = reader.ReadUInt16(); bloodShieldIdle = reader.ReadUInt16(); bloodShieldRevision = reader.ReadUInt32(); infernalMomentumTimer = reader.ReadUInt16(); ultimateTimer = reader.ReadUInt16(); ReaperFormId receivedForm = (ReaperFormId)reader.ReadByte(); ultimateForm = Enum.IsDefined(receivedForm) ? receivedForm : ReaperFormId.Base; } public override void PostUpdate() { if (++bloodHealingWindow >= 60) { bloodHealingWindow = 0; bloodHealingThisSecond = 0; } if (bloodShield > 0 && ++bloodShieldIdle >= 300) { ReleaseBloodShield(Vector2.UnitY * -1f); if (Main.netMode == NetmodeID.Server) { AdvanceBloodShieldRevision(); Player.GetModPlayer().SyncReaperCombatRuntime(); } } if (pendingMirroredBloodShieldTimer > 0) pendingMirroredBloodShieldTimer--; else pendingMirroredBloodShieldAbsorption = 0; if (infernalMomentumTimer > 0) infernalMomentumTimer--; if (ultimateTimer > 0) ultimateTimer--; if (bloodDrinkVisualCooldown > 0) bloodDrinkVisualCooldown--; if (bloodShieldVisualCooldown > 0) bloodShieldVisualCooldown--; if (bloodFrenzyVisualRefresh > 0) bloodFrenzyVisualRefresh--; if (bloodShieldCounterCooldown > 0) bloodShieldCounterCooldown--; if (infernalScorchExecutionCooldown > 0) infernalScorchExecutionCooldown--; if (infernalMomentumVisualCooldown > 0) infernalMomentumVisualCooldown--; ReaperProgressionState state = Player.GetModPlayer().ReaperProgression; bool frenzyWasActive = bloodFrenzyLatched; if (state.CurrentForm != ReaperFormId.Blood || Player.statLife <= 0) bloodFrenzyLatched = false; else if (!bloodFrenzyLatched && Player.statLife * 2 < Player.statLifeMax2) bloodFrenzyLatched = true; else if (bloodFrenzyLatched && Player.statLife * 100 >= Player.statLifeMax2 * 55) bloodFrenzyLatched = false; if (bloodFrenzyLatched && Main.netMode != NetmodeID.MultiplayerClient && (!frenzyWasActive || bloodFrenzyVisualRefresh <= 0)) { int level = state.GetSkillLevel(ReaperFormId.Blood, 1); if (level > 0) { ReaperSkillCueProjectile.Spawn(Player.GetSource_Misc("SoulHarvest:BloodFrenzyVisual"), Player.whoAmI, ReaperFormId.Blood, state.GetStage(ReaperFormId.Blood), 1, level, Player.MountedCenter, Vector2.UnitY * -1f); bloodFrenzyVisualRefresh = 90; } } else if (frenzyWasActive && !bloodFrenzyLatched && Main.netMode != NetmodeID.MultiplayerClient) { EmitCurrentBloodVisual(1, Player.MountedCenter, Vector2.UnitY * -1f, variant: 1); bloodFrenzyVisualRefresh = 0; } if (infernalSpecialMaskTimer > 0) infernalSpecialMaskTimer--; else infernalSpecialMask = 0; } public override void PostUpdateRunSpeeds() { if (ultimateTimer > 0 && ultimateForm == ReaperFormId.Blood) { Player.moveSpeed *= 0.6f; Player.maxRunSpeed *= 0.6f; Player.accRunSpeed *= 0.6f; Player.runAcceleration *= 0.6f; } if (infernalMomentumTimer <= 0) return; ReaperProgressionState state = Player.GetModPlayer().ReaperProgression; if (state.CurrentForm != ReaperFormId.Infernal) return; int level = state.GetSkillLevel(ReaperFormId.Infernal, 0); float bonus = level switch { 2 => 0.12f, >= 3 => 0.16f, _ => 0.08f }; Player.moveSpeed += bonus; Player.maxRunSpeed *= 1f + bonus; } public override void ModifyHurt(ref Player.HurtModifiers modifiers) { if (ultimateTimer > 0) { modifiers.FinalDamage *= ultimateForm == ReaperFormId.Death ? 0.20f : 0.30f; modifiers.Knockback *= 0f; } if (bloodShield > 0 || Main.netMode == NetmodeID.Server && pendingMirroredBloodShieldAbsorption > 0) modifiers.ModifyHurtInfo += ConsumeBloodShield; } private void ConsumeBloodShield(ref Player.HurtInfo info) { if (info.Damage <= 0) return; if (Main.netMode == NetmodeID.Server && pendingMirroredBloodShieldAbsorption > 0) { // If the owner's revisioned report arrived before this server-side Hurt // hook, its delta already changed the authoritative shield. Mirror only // the damage reduction here instead of charging that shield a second time. int mirrored = Math.Min(pendingMirroredBloodShieldAbsorption, info.Damage); pendingMirroredBloodShieldAbsorption -= mirrored; if (pendingMirroredBloodShieldAbsorption <= 0) pendingMirroredBloodShieldTimer = 0; info.Damage -= mirrored; } if (bloodShield <= 0 || info.Damage <= 0) return; int absorbed = Math.Min(bloodShield, info.Damage); uint consumedRevision = bloodShieldRevision; bloodShield -= absorbed; info.Damage -= absorbed; if (Main.netMode != NetmodeID.Server) CombatText.NewText(Player.Hitbox, new Color(245, 70, 125), absorbed); if (Main.netMode == NetmodeID.MultiplayerClient) { SoulHarvest.SendBloodShieldConsumption(Player, consumedRevision, absorbed); AdvanceBloodShieldRevision(); } else if (Main.netMode == NetmodeID.Server) { AdvanceBloodShieldRevision(); Player.GetModPlayer().SyncReaperCombatRuntime(); } if (Main.netMode != NetmodeID.MultiplayerClient) OnBloodShieldAbsorbed(absorbed, ResolveCounterDirection(info.HitDirection)); } internal void AcceptOwnerBloodShieldConsumption(uint revision, int absorbed) { if (Main.netMode != NetmodeID.Server) return; if (revision == bloodShieldRevision && absorbed > 0) { int previousShield = bloodShield; bloodShield = Math.Max(0, bloodShield - absorbed); if (bloodShield != previousShield) { int acceptedAbsorption = previousShield - bloodShield; pendingMirroredBloodShieldAbsorption = Math.Min( Math.Max(1, Player.statLifeMax2), pendingMirroredBloodShieldAbsorption + acceptedAbsorption); pendingMirroredBloodShieldTimer = 15; AdvanceBloodShieldRevision(); OnBloodShieldAbsorbed(acceptedAbsorption, new Vector2(Player.direction, 0f)); } } // Always answer, including stale/malformed revisions, so the owner cannot // retain a predicted shield state that the server rejected. Player.GetModPlayer().SyncReaperCombatRuntime(); } internal void ApplyBloodHealing(in SickleCombatSnapshot snapshot, int damageDone, Vector2 visualSource) { int level = snapshot.Skill1; if (level <= 0 || damageDone <= 0 || Player.dead) return; float rate = level switch { 2 => 0.03f, >= 3 => 0.04f, _ => 0.02f }; int secondCap = Math.Max(1, (int)(Player.statLifeMax2 * (level switch { 2 => 0.06f, >= 3 => 0.08f, _ => 0.04f }))); int potential = Math.Max(1, (int)Math.Ceiling(damageDone * rate)); int permitted = Math.Min(potential, Math.Max(0, secondCap - bloodHealingThisSecond)); int missing = Math.Max(0, Player.statLifeMax2 - Player.statLife); int healed = Math.Min(missing, permitted); if (healed > 0) { Player.Heal(healed); bloodHealingThisSecond += healed; // Character-owned Blood Covenant healing bypasses the legacy item // lifesteal path, so it must explicitly create the returning blood-soul // feedback on the authoritative side. The projectile is then synced to // clients like every other combat visual. LifeStealVisuals.Spawn(visualSource, Player.whoAmI, level, healed); if (bloodDrinkVisualCooldown <= 0 && Main.netMode != NetmodeID.MultiplayerClient) { ReaperSkillCueProjectile.Spawn(Player.GetSource_Misc("SoulHarvest:BloodDrinkVisual"), Player.whoAmI, snapshot, 0, visualSource, (Player.MountedCenter - visualSource).SafeNormalize(Vector2.UnitY * -1f)); bloodDrinkVisualCooldown = 10; } } if (snapshot.Skill3 <= 0 || permitted <= healed) return; float shieldRatio = snapshot.Skill3 switch { 2 => 0.06f, >= 3 => 0.08f, _ => 0.04f }; int shieldCap = Math.Max(1, (int)(Player.statLifeMax2 * shieldRatio)); int previousShield = bloodShield; bloodShield = Math.Min(shieldCap, bloodShield + permitted - healed); if (bloodShield != previousShield) { bloodShieldSnapshot = snapshot; hasBloodShieldSnapshot = true; bloodShieldIdle = 0; LifeStealVisuals.Spawn(visualSource, Player.whoAmI, level, bloodShield - previousShield); if (Main.netMode == NetmodeID.Server) { AdvanceBloodShieldRevision(); Player.GetModPlayer().SyncReaperCombatRuntime(); } if (bloodShieldVisualCooldown <= 0 && Main.netMode != NetmodeID.MultiplayerClient) { ReaperSkillCueProjectile.Spawn(Player.GetSource_Misc("SoulHarvest:BloodShieldVisual"), Player.whoAmI, snapshot, 2, Player.MountedCenter, Vector2.UnitY * -1f); bloodShieldVisualCooldown = 18; } } } private void OnBloodShieldAbsorbed(int absorbed, Vector2 direction) { if (absorbed <= 0 || !hasBloodShieldSnapshot) return; direction = direction.SafeNormalize(new Vector2(Player.direction, 0f)); int level = bloodShieldSnapshot.Skill3; if (level >= 2 && bloodShieldCounterCooldown <= 0 && (bloodShield > 0 || level == 2)) { bloodShieldCounterCooldown = 42; float counterFactor = level >= 3 ? 0.20f : 0.16f; ReaperStrikeProjectile.Spawn(Player.GetSource_Misc("SoulHarvest:BloodShieldCounter"), Player.whoAmI, bloodShieldSnapshot, ReaperHitKind.PrimaryDerived, 0, Player.MountedCenter + direction * 12f, direction, ReaperStrikeShape.Line, level >= 3 ? 142f : 118f, level >= 3 ? 31f : 25f, counterFactor, 3); ReaperSkillCueProjectile.Spawn(Player.GetSource_Misc("SoulHarvest:BloodShieldCounterVisual"), Player.whoAmI, bloodShieldSnapshot, 2, Player.MountedCenter, direction, variant: 2); } if (bloodShield <= 0) ReleaseBloodShield(direction); } private void ReleaseBloodShield(Vector2 direction) { SickleCombatSnapshot releasedSnapshot = bloodShieldSnapshot; bool canExecute = Main.netMode != NetmodeID.MultiplayerClient && hasBloodShieldSnapshot && releasedSnapshot.Skill3 >= 3; bloodShield = 0; bloodShieldIdle = 0; hasBloodShieldSnapshot = false; bloodShieldSnapshot = default; if (Main.netMode == NetmodeID.MultiplayerClient) return; direction = direction.SafeNormalize(Vector2.UnitY * -1f); if (canExecute) { // The cocoon's final ring is one bounded area hit, not twelve radial // projectiles. This keeps the shield's defensive budget predictable. ReaperStrikeProjectile.Spawn(Player.GetSource_Misc("SoulHarvest:BloodShieldRelease"), Player.whoAmI, releasedSnapshot, ReaperHitKind.PrimaryDerived, 0, Player.MountedCenter, direction, ReaperStrikeShape.Circle, 112f, 30f, 0.32f, 2); ReaperSkillCueProjectile.Spawn(Player.GetSource_Misc("SoulHarvest:BloodShieldReleaseVisual"), Player.whoAmI, releasedSnapshot, 2, Player.MountedCenter, direction, variant: 1); } else { EmitCurrentBloodVisual(2, Player.MountedCenter, direction, variant: 1); } } private Vector2 ResolveCounterDirection(int hitDirection) { int direction = hitDirection == 0 ? Player.direction : hitDirection; return new Vector2(direction == 0 ? 1 : direction, 0f); } internal bool TryTriggerInfernalScorchExecution(in SickleCombatSnapshot snapshot, NPC target, int actionId) { if (Main.netMode == NetmodeID.MultiplayerClient || snapshot.Skill2 < 3 || infernalScorchExecutionCooldown > 0 || !target.active) { return false; } infernalScorchExecutionCooldown = 75; Vector2 direction = (target.Center - Player.MountedCenter) .SafeNormalize(new Vector2(Player.direction, 0f)); ReaperStrikeProjectile.Spawn(target.GetSource_FromAI(), Player.whoAmI, snapshot, ReaperHitKind.PrimaryDerived, 0, target.Center, direction, ReaperStrikeShape.Circle, Math.Max(88f, target.width * 0.75f), 28f, 0.42f, 9, actionId); if (TryReserveSkillCue(ReaperFormId.Infernal, 1, actionId)) { ReaperSkillCueProjectile.Spawn(target.GetSource_FromAI(), Player.whoAmI, snapshot, 1, target.Center, direction, variant: 1, actionId: actionId); } return true; } internal bool TryReserveSkillCue(ReaperFormId form, int skillIndex, int actionId) { int formIndex = (int)form; if (formIndex < 0 || formIndex >= lastSkillCueAction.GetLength(0) || skillIndex < 0 || skillIndex >= ReaperDefinitions.SkillsPerBranch) { return false; } ulong now = Main.GameUpdateCount; if (hasSkillCueAction[formIndex, skillIndex] && lastSkillCueAction[formIndex, skillIndex] == actionId && now - lastSkillCueTick[formIndex, skillIndex] <= 300UL) { // Same-tick multi-target hits may send a handful of lightweight // center samples. Clients merge them into the one existing visual, // producing an actual group focus instead of using whichever NPC the // server happened to enumerate first. Later ticks remain suppressed. if (now != lastSkillCueTick[formIndex, skillIndex] || skillCueCenterSamples[formIndex, skillIndex] >= 12) { return false; } skillCueCenterSamples[formIndex, skillIndex]++; return true; } hasSkillCueAction[formIndex, skillIndex] = true; lastSkillCueAction[formIndex, skillIndex] = actionId; lastSkillCueTick[formIndex, skillIndex] = now; skillCueCenterSamples[formIndex, skillIndex] = 1; return true; } private void AdvanceBloodShieldRevision() { bloodShieldRevision++; if (bloodShieldRevision == 0) bloodShieldRevision++; } internal void ApplyDeathBloodHealing(int damageDone) { if (damageDone <= 0 || Player.dead) return; int amount = Math.Min(Math.Max(0, Player.statLifeMax2 - Player.statLife), Math.Min(Math.Max(1, damageDone / 100), Math.Max(1, Player.statLifeMax2 / 100))); if (amount > 0) Player.Heal(amount); } internal void ApplyBloodFinisherHealing(int projectileIdentity, Vector2 visualSource, int visualLevel) { if (projectileIdentity != lastBloodFinisherIdentity) { lastBloodFinisherIdentity = projectileIdentity; bloodFinisherTargets = 0; } if (bloodFinisherTargets >= 3 || Player.dead) return; bloodFinisherTargets++; int amount = Math.Max(1, (int)Math.Ceiling(Player.statLifeMax2 * 0.0075f)); amount = Math.Min(amount, Math.Max(0, Player.statLifeMax2 - Player.statLife)); if (amount <= 0) return; Player.Heal(amount); LifeStealVisuals.Spawn(visualSource, Player.whoAmI, Math.Max(1, visualLevel), amount); } internal void ApplyDeathUltimateHealing(int damageDone) { if (ultimateForm != ReaperFormId.Death || ultimateTimer <= 0 || damageDone <= 0 || Player.dead) return; int cap = Math.Max(1, (int)Math.Ceiling(Player.statLifeMax2 * 0.08f)); int amount = Math.Min(Math.Max(0, cap - deathUltimateHealing), Math.Max(1, damageDone / 100)); amount = Math.Min(amount, Math.Max(0, Player.statLifeMax2 - Player.statLife)); if (amount <= 0) return; Player.Heal(amount); deathUltimateHealing += amount; } internal void RecordInfernalSpecialHit(in SickleCombatSnapshot snapshot, int phase, Projectile source) { if (snapshot.Stage < ReaperStage.StageIII || snapshot.Skill3 <= 0 || phase < 0 || phase > 2) return; infernalSpecialMask |= (byte)(1 << phase); infernalSpecialMaskTimer = 90; if (infernalSpecialMask != 0x07) return; infernalSpecialMask = 0; float factor = snapshot.Skill3 switch { 2 => 0.75f, >= 3 => 1f, _ => 0.5f }; int refund = snapshot.Skill3 switch { 2 => 75, >= 3 => 105, _ => 45 }; ReaperStrikeProjectile.Spawn(source.GetSource_FromThis(), Player.whoAmI, snapshot, ReaperHitKind.PrimaryDerived, phase, Player.Center, Vector2.UnitX, ReaperStrikeShape.Circle, 125f, 32f, factor, 1); ReaperSkillCueProjectile.Spawn(source.GetSource_FromThis(), Player.whoAmI, snapshot, 2, Player.MountedCenter, source.velocity.SafeNormalize(Vector2.UnitX), actionId: source.identity); Player.GetModPlayer().ReduceReaperSpecialCooldown(refund); } internal bool TryRecordSoulShard(int actionId, int targetRoot) { if (actionId != lastSoulActionId) { lastSoulActionId = actionId; soulActionTargets.Clear(); } if (soulActionTargets.Count >= 2 || !soulActionTargets.Add(targetRoot)) return false; return true; } internal void RollbackSoulShardReservation(int actionId, int targetRoot) { if (actionId == lastSoulActionId) soulActionTargets.Remove(targetRoot); } internal void GrantInfernalMomentum() { bool wasInactive = infernalMomentumTimer <= 0; infernalMomentumTimer = 120; if ((wasInactive || infernalMomentumVisualCooldown <= 0) && Main.netMode != NetmodeID.MultiplayerClient) { ReaperProgressionState state = Player.GetModPlayer().ReaperProgression; int level = state.GetSkillLevel(ReaperFormId.Infernal, 0); if (level > 0) { ReaperSkillCueProjectile.Spawn(Player.GetSource_Misc("SoulHarvest:InfernalMomentumVisual"), Player.whoAmI, ReaperFormId.Infernal, state.GetStage(ReaperFormId.Infernal), 0, level, Player.MountedCenter, new Vector2(Player.direction, 0f)); infernalMomentumVisualCooldown = 18; } } if (Main.netMode == NetmodeID.Server) Player.GetModPlayer().SyncReaperCombatRuntime(); } internal void BeginUltimate(ReaperFormId form) { ultimateForm = form; deathUltimateHealing = 0; ultimateTimer = form switch { ReaperFormId.Bone => 216, ReaperFormId.Blood => 210, ReaperFormId.Infernal => 204, ReaperFormId.Frost => 120, ReaperFormId.Soul => 116, ReaperFormId.Void => 220, ReaperFormId.Death => ReaperDeathUltimateGeometry.Duration, _ => 0 }; } private void EmitCurrentBloodVisual(int skillIndex, Vector2 center, Vector2 direction, int variant = 0) { ReaperProgressionState state = Player.GetModPlayer().ReaperProgression; int level = state.GetSkillLevel(ReaperFormId.Blood, skillIndex); if (level <= 0) return; ReaperSkillCueProjectile.Spawn(Player.GetSource_Misc("SoulHarvest:BloodSkillVisual"), Player.whoAmI, ReaperFormId.Blood, state.GetStage(ReaperFormId.Blood), skillIndex, level, center, direction, variant); } }