using SoulHarvest.Common; using SoulHarvest.Items; using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Graphics; using System; using System.IO; using Terraria; using Terraria.Audio; using Terraria.GameContent; using Terraria.ID; using Terraria.ModLoader; namespace SoulHarvest.Projectiles; public sealed class SickleSwingProjectile : ModProjectile { private const int HistoryLength = 18; private const int AuthorityWaitTimeoutFrames = 180; private readonly Vector2[] tipHistory = new Vector2[HistoryLength]; private readonly float[] angleHistory = new float[HistoryLength]; private SickleCombatSnapshot snapshot; private int phase; private int timer; private int duration; private float aimRotation; private float weaponAngle; private Vector2 grip; private bool configured; private bool initialized; private bool released; private bool residualRiftSpawned; private bool deathDomainRiftSpawned; private bool infernalTrailSpawned; private bool movementTriggered; private bool receivedAuthoritativeState; private int authorityWaitFrames; private bool canDamage; private bool serverValidated; private int authoritativeDamage; private Vector2 authoritativeVelocity; private int authoritativeSwingDirection; private int validHistorySamples; private long deathCrescentVisualInstanceId; private float animationProgress; private float previousCollisionAngle; private bool collisionAngleInitialized; private float deathTempo = 1f; private bool deathAssemblyRequired; private bool deathAssemblyCancelling; private int deathAssemblyCancelStartTimer; private int deathAssemblyCancelTimer; private Vector2 infernalMoveStart; private Vector2 infernalMoveEnd; private bool infernalMoveReady; private int infernalMoveFramesRemaining; private Vector2 infernalMoveVelocity; internal bool ServerValidated => serverValidated; private int DeathAssemblyDuration => snapshot.Form == ReaperFormId.Death && deathAssemblyRequired ? snapshot.AssemblyFrames : 0; private bool IsDeathAssemblyActive => snapshot.Form == ReaperFormId.Death && deathAssemblyRequired && !deathAssemblyCancelling && timer <= DeathAssemblyDuration; internal bool BlocksNewPrimarySwing => !configured || !initialized || deathAssemblyCancelling || timer < duration + DeathAssemblyDuration; internal bool DeathAssemblyComplete => snapshot.Form != ReaperFormId.Death || !deathAssemblyRequired || timer >= DeathAssemblyDuration; internal bool DeathAssemblyRequired => deathAssemblyRequired; internal bool DeathAssemblyCancelling => deathAssemblyCancelling; internal float DeathTempo => deathTempo; internal bool DeathSpaceBreak => snapshot.Form == ReaperFormId.Death && snapshot.DeathSpaceBreakUnlocked && deathTempo >= snapshot.DeathPrimaryMaximumTempo - 0.01f; private int ItemType => (int)Projectile.ai[2]; private int SwingDirection => Math.Sign(Projectile.ai[0]) == 0 ? 1 : Math.Sign(Projectile.ai[0]); private int VisualStage => snapshot.Form == ReaperFormId.Death ? 3 : snapshot.StageNumber; // Death primary attacks are one oversized Death-scythe sequence. Do not // remap its six combo beats to branch forms: doing so reintroduced branch // scythes, mirrors and execution silhouettes even after their projectiles // had been removed. private ReaperFormId VisualForm => snapshot.Form; private bool IsComboFinisher => phase >= ReaperCombatRegistry.GetPrimaryPhaseCount(snapshot.Form, snapshot.Stage) - 1; public override string Texture => "Terraria/Images/Projectile_0"; internal static bool HasBlockingPrimarySwing(Player player) { int swingType = ModContent.ProjectileType(); foreach (Projectile projectile in Main.ActiveProjectiles) { if (projectile.owner == player.whoAmI && projectile.type == swingType && projectile.ModProjectile is SickleSwingProjectile swing && swing.BlocksNewPrimarySwing) { return true; } } return false; } internal void Configure(in SickleCombatSnapshot value, int attackPhase, float primaryDeathTempo = 1f, bool authorizeServer = false, bool requireDeathAssembly = true) { snapshot = value; phase = Math.Max(0, attackPhase); deathAssemblyRequired = snapshot.Form == ReaperFormId.Death && requireDeathAssembly; deathAssemblyCancelling = false; deathAssemblyCancelStartTimer = 0; deathAssemblyCancelTimer = 0; deathTempo = snapshot.Form == ReaperFormId.Death ? MathHelper.Clamp(primaryDeathTempo, 1f, snapshot.DeathPrimaryMaximumTempo) : 1f; deathCrescentVisualInstanceId = 0; configured = true; Projectile.damage = Math.Max(1, (int)Math.Round(snapshot.Damage * ReaperCombatRegistry.GetPrimaryDamageMultiplier(snapshot, phase))); Projectile.originalDamage = snapshot.Damage; Projectile.CritChance = snapshot.CritChance; float attackAngle = Projectile.velocity.ToRotation(); Projectile.GetGlobalProjectile().ConfigureReaperProjectile( Projectile, snapshot, ReaperHitKind.Primary, phase, attackAngle); if (Main.netMode == NetmodeID.Server && authorizeServer) { serverValidated = true; authoritativeDamage = Projectile.damage; authoritativeVelocity = Projectile.velocity.SafeNormalize(Vector2.UnitX * Main.player[Projectile.owner].direction); authoritativeSwingDirection = SwingDirection; } Projectile.netUpdate = true; } public override void SetDefaults() { Projectile.width = 24; Projectile.height = 24; Projectile.friendly = true; Projectile.tileCollide = false; Projectile.ignoreWater = true; Projectile.penetrate = -1; // Reaper blades intentionally cut through terrain. Their authoritative // swept geometry is sufficient; Terraria's owner line-of-sight gate // would otherwise reject a valid hit whenever a tile is between them. Projectile.ownerHitCheck = false; Projectile.hide = true; Projectile.timeLeft = 120; Projectile.DamageType = DamageClass.Melee; Projectile.usesLocalNPCImmunity = true; Projectile.localNPCHitCooldown = NormalSickle.BaseHitCooldownFrames; } public override bool ShouldUpdatePosition() => false; public override bool? CanDamage() => configured && canDamage ? null : false; internal static void ApplyHarvestUppercut(NPC target, int direction, float knockback) { if (target.boss) return; float resistance = MathHelper.Clamp(target.knockBackResist, 0.15f, 1f); target.velocity.Y = Math.Min(target.velocity.Y, -(6.5f + knockback * 0.35f) * resistance); target.velocity.X += direction * (1.8f + knockback * 0.12f) * resistance; target.netUpdate = true; } public override void AI() { if (!TryGetOwner(out Player player)) { Projectile.Kill(); return; } if (Main.netMode == NetmodeID.Server && !serverValidated) { if (!float.IsFinite(Projectile.velocity.X) || !float.IsFinite(Projectile.velocity.Y)) { Projectile.Kill(); return; } int serverDamage = Math.Max(1, player.GetWeaponDamage(player.HeldItem)); float authoritativeKnockback = player.GetWeaponKnockback(player.HeldItem, player.HeldItem.knockBack); SickleCombatSnapshot authoritative = SickleCombatSnapshot.Capture(player, serverDamage, authoritativeKnockback); if (!ReaperCombatService.TryAuthorizePrimarySwing(player, Projectile, authoritative, out int authoritativePhase, out float authoritativeDeathTempo, out bool authoritativeAssemblyRequired)) { Projectile.Kill(); return; } Projectile.velocity = Projectile.velocity.SafeNormalize(Vector2.UnitX * player.direction); Projectile.ai[0] = player.GetModPlayer().TakeNextSwingDirection(player.direction); Projectile.ai[2] = player.HeldItem.type; Configure(authoritative, authoritativePhase, authoritativeDeathTempo, authorizeServer: true, requireDeathAssembly: authoritativeAssemblyRequired); } else if (!configured) { int damage = Math.Max(1, Projectile.originalDamage > 0 ? Projectile.originalDamage : Projectile.damage); Configure(SickleCombatSnapshot.Capture(player, damage, Projectile.knockBack), 0); } if (Main.netMode == NetmodeID.Server) { Projectile.damage = authoritativeDamage; Projectile.velocity = authoritativeVelocity; Projectile.ai[0] = authoritativeSwingDirection; } if (!initialized) Initialize(player); bool debugSimulatedPrimaryHeld = false; #if DEBUG debugSimulatedPrimaryHeld = ReaperMultiplayerClientSelfTest .SimulatedPrimaryHeld; #endif if (!deathAssemblyCancelling && IsDeathAssemblyActive && (Main.netMode == NetmodeID.SinglePlayer || Main.netMode == NetmodeID.MultiplayerClient && Projectile.owner == Main.myPlayer) && !player.controlUseItem && !debugSimulatedPrimaryHeld) { RequestDeathAssemblyCancellation(); } if (deathAssemblyCancelling) { UpdateDeathAssemblyCancellation(player); return; } timer++; Projectile.timeLeft = 2; Projectile.Center = player.MountedCenter; grip = player.MountedCenter; if (snapshot.Form == ReaperFormId.Death && timer <= DeathAssemblyDuration) { player.heldProj = Projectile.whoAmI; player.itemTime = 2; player.itemAnimation = 2; player.ChangeDir(Projectile.velocity.X >= 0f ? 1 : -1); animationProgress = 0f; UpdatePose(0f); previousCollisionAngle = weaponAngle; collisionAngleInitialized = true; canDamage = false; player.SetCompositeArmFront(true, Player.CompositeArmStretchAmount.Full, weaponAngle - MathHelper.PiOver2); player.itemRotation = MathHelper.WrapAngle(weaponAngle); if (timer == DeathAssemblyDuration) ReaperCombatService.CompleteDeathPrimaryAssembly(player); return; } int attackTimer = timer - DeathAssemblyDuration; float progress = MathHelper.Clamp(attackTimer / (float)duration, 0f, 1f); if (progress < 1f) { player.heldProj = Projectile.whoAmI; player.itemTime = 2; player.itemAnimation = 2; player.ChangeDir(Projectile.velocity.X >= 0f ? 1 : -1); } else if (player.heldProj == Projectile.whoAmI) { // A predicted multiplayer swing may remain alive briefly while it // waits for its server authorization snapshot. That hidden, // non-damaging wait must never keep the player's item locked or make // the next click stall for the full authority timeout. player.heldProj = -1; } animationProgress = progress; float previousPoseAngle = weaponAngle; UpdatePose(progress); if (!collisionAngleInitialized) { // Do not join the cursor-facing initialization pose to the first real // wind-up pose; that chord was never crossed by the visible weapon. previousCollisionAngle = weaponAngle; collisionAngleInitialized = true; } else { previousCollisionAngle = previousPoseAngle; } if (Main.netMode != NetmodeID.Server && snapshot.Form == ReaperFormId.Death) { RecordDeathPrimaryCrescent(); } if (snapshot.Form == ReaperFormId.Void || snapshot.Form == ReaperFormId.Death && phase == 5) { Projectile.GetGlobalProjectile() .UpdateReaperAttackAngle(weaponAngle); } canDamage = progress is > 0.02f and < 0.98f; ConfirmServerTrainingDummyContact(); if (!released && progress >= 0.4f) { released = true; ReaperCombatService.OnPrimaryReleased(Projectile, snapshot, phase, player.MountedCenter, aimRotation.ToRotationVector2()); if (Main.netMode != NetmodeID.Server && VisualStage >= 3 && IsComboFinisher) TriggerPrimaryFinisherImpact(); } UpdateMovement(player); UpdateHistory(progress); if (!residualRiftSpawned && snapshot.Form == ReaperFormId.Void && progress >= 0.88f && validHistorySamples >= 2 && Main.netMode != NetmodeID.MultiplayerClient) { residualRiftSpawned = true; ReaperVoidArcRiftProjectile.Spawn(Projectile.GetSource_FromThis(), Projectile.owner, snapshot, tipHistory, validHistorySamples, phase, Projectile.identity); } if (!deathDomainRiftSpawned && DeathSpaceBreak && progress >= 0.98f && Main.netMode != NetmodeID.MultiplayerClient) { deathDomainRiftSpawned = true; SpawnDeathDomainRift(); } if (!infernalTrailSpawned && snapshot.Form == ReaperFormId.Infernal && progress >= 0.88f && infernalMoveReady && Main.netMode != NetmodeID.MultiplayerClient) { infernalTrailSpawned = true; ReaperInfernalTrailProjectile.Spawn(Projectile.GetSource_FromThis(), Projectile.owner, snapshot, infernalMoveStart, infernalMoveEnd, 10f + snapshot.StageNumber * 2.5f, phase, Projectile.identity); } if (Main.netMode != NetmodeID.Server && VisualForm == ReaperFormId.Void && validHistorySamples > 0) { ReaperVoidTrailVisualSystem.RecordTip(Projectile.owner, Projectile.identity, (ReaperStage)Math.Clamp(VisualStage, 1, 3), tipHistory[0]); } player.SetCompositeArmFront(true, Player.CompositeArmStretchAmount.Full, weaponAngle - MathHelper.PiOver2); player.itemRotation = MathHelper.WrapAngle(weaponAngle); SpawnMotionDust(); if (progress >= 1f) { // Never let the held-projectile cleanup frame join its final pose to // stale history. That single terminal chord was the long "laser" seen // after an otherwise continuous swing. InvalidateHistory(); // The owning multiplayer client must not tell the server to kill a // predicted swing before its authoritative snapshot returns. With a // long RTT the shortest phases can otherwise end locally first and // cancel their still-valid server damage. Hold a harmless terminal // pose until validation (or a bounded disconnect-safe timeout). bool awaitingAuthority = Main.netMode == NetmodeID.MultiplayerClient && Projectile.owner == Main.myPlayer && !receivedAuthoritativeState && authorityWaitFrames++ < AuthorityWaitTimeoutFrames; if (awaitingAuthority) { canDamage = false; return; } Projectile.Kill(); } } internal bool RequestDeathAssemblyCancellation() { if (!configured || snapshot.Form != ReaperFormId.Death || !deathAssemblyRequired || deathAssemblyCancelling || timer >= DeathAssemblyDuration) { return false; } deathAssemblyCancelling = true; deathAssemblyCancelStartTimer = Math.Clamp(timer, 0, DeathAssemblyDuration); deathAssemblyCancelTimer = 0; canDamage = false; released = false; Projectile.netUpdate = true; if (Main.netMode == NetmodeID.Server) { NetMessage.SendData(MessageID.SyncProjectile, -1, -1, null, Projectile.whoAmI); } return true; } private void UpdateDeathAssemblyCancellation(Player player) { deathAssemblyCancelTimer++; Projectile.timeLeft = 2; Projectile.Center = player.MountedCenter; grip = player.MountedCenter; player.heldProj = Projectile.whoAmI; player.itemTime = 2; player.itemAnimation = 2; player.ChangeDir(Projectile.velocity.X >= 0f ? 1 : -1); animationProgress = 0f; UpdatePose(0f); previousCollisionAngle = weaponAngle; collisionAngleInitialized = true; canDamage = false; player.SetCompositeArmFront(true, Player.CompositeArmStretchAmount.Full, weaponAngle - MathHelper.PiOver2); player.itemRotation = MathHelper.WrapAngle(weaponAngle); if (deathAssemblyCancelTimer >= ReaperDefinitions.AssemblyCancelFrames) Projectile.Kill(); } public override bool? Colliding(Rectangle projHitbox, Rectangle targetHitbox) { if (!configured || !canDamage) return false; float width = ReaperCombatRegistry.GetCollisionWidth(snapshot); float reach; if (snapshot.Form == ReaperFormId.Death) { reach = ReaperCombatRegistry.GetDeathPrimaryCollisionReach(snapshot); width = 74f; } else if (snapshot.Form == ReaperFormId.Void) { // The Void primary is a sweeping crescent, not a stationary spatial // laser. Its authoritative line follows the live weapon angle so the // damage edge travels through the same arc shown to the player. reach = GetVoidVisibleReach(); width = Math.Max(width, GetVoidPrimaryWidth()); } else if (snapshot.Form == ReaperFormId.Blood) { float reachScale = snapshot.Stage switch { ReaperStage.StageI => 0.98f, ReaperStage.StageII => IsComboFinisher ? 1.08f : 1.03f, _ => IsComboFinisher ? 1.14f : 1.08f }; reach = ReaperCombatRegistry.GetBladeTipLength(snapshot.Form, snapshot.Stage) * reachScale; width = snapshot.Stage switch { ReaperStage.StageI => 20f, ReaperStage.StageII => IsComboFinisher ? 28f : 24f, _ => IsComboFinisher ? 34f : 28f }; } else { reach = Vector2.Distance(grip, GetBladeTip(grip, weaponAngle)); } return CheckSweptBladeCollision(targetHitbox, reach, width); } private bool CheckSweptBladeCollision(Rectangle targetHitbox, float reach, float width) { // The old inner-radius exclusion left a very large blind spot (over 200 // pixels for Death). Cover the handle area explicitly, then sweep every // blade sample all the way from the grip to the visible cutting edge. float closeRadius = Math.Clamp(width * 0.65f, 32f, 64f); Vector2 closest = new( MathHelper.Clamp(grip.X, targetHitbox.Left, targetHitbox.Right), MathHelper.Clamp(grip.Y, targetHitbox.Top, targetHitbox.Bottom)); if (Vector2.DistanceSquared(grip, closest) <= closeRadius * closeRadius) return true; float angularDelta = MathHelper.WrapAngle(weaponAngle - previousCollisionAngle); float sweptDistance = Math.Abs(angularDelta) * reach; // No point on the hot edge may travel farther than roughly half the // collision thickness between samples. This closes the high-speed holes // without changing the total damage or adding extra attack events. int samples = Math.Clamp((int)Math.Ceiling(sweptDistance / Math.Max(5f, width * 0.48f)), 1, 72); float collisionPoint = 0f; for (int sample = 0; sample <= samples; sample++) { float angle = previousCollisionAngle + angularDelta * (sample / (float)samples); Vector2 direction = angle.ToRotationVector2(); Vector2 start = grip; Vector2 end = grip + direction * reach; if (Collision.CheckAABBvLineCollision(targetHitbox.TopLeft(), targetHitbox.Size(), start, end, width, ref collisionPoint)) { return true; } } return false; } private void ConfirmServerTrainingDummyContact() { if (Main.netMode != NetmodeID.Server || !serverValidated || !canDamage) return; MyGlobalProjectile hitData = Projectile.GetGlobalProjectile< MyGlobalProjectile>(); foreach (NPC npc in Main.ActiveNPCs) { // Vanilla's immortal target dummy can display the owning client's // damage text without invoking the dedicated server's projectile-hit // callback. Confirm only this exceptional target from the same // authoritative swept-blade geometry, then run the normal primary // hit side effects exactly once. No damage or hitbox is changed. if (!ReaperTargeting.IsTrainingDummy(npc) || Colliding(Projectile.Hitbox, npc.Hitbox) != true) continue; hitData.TryApplyPrimaryHitEffects(Projectile, npc, 0); } } public override bool PreDraw(ref Color lightColor) { if (Main.dedServ || !configured) return false; if (deathAssemblyCancelling || IsDeathAssemblyActive) { Texture2D assemblyTexture = ModContent.Request( ReaperCombatRegistry.GetTexturePath(snapshot.Form, snapshot.Stage)).Value; SpriteEffects assemblyEffects = GetWeaponEffects(0); Vector2 normalizedAnchor = ReaperCombatRegistry.GetHandleAnchor( snapshot.Form, snapshot.Stage); Vector2 assemblyAnchor = new( assemblyTexture.Width * normalizedAnchor.X, assemblyTexture.Height * normalizedAnchor.Y); if ((assemblyEffects & SpriteEffects.FlipVertically) != 0) assemblyAnchor.Y = assemblyTexture.Height - assemblyAnchor.Y; float assemblyScale = ReaperCombatRegistry .GetDeathWeaponDrawScale(snapshot); float assemblyRotation = weaponAngle + ReaperCombatRegistry.GetTextureRotationCorrection( snapshot.Form, snapshot.Stage); int actionSeed = Projectile.identity * 31 + phase * 997; if (deathAssemblyCancelling) { DeathSickleAssemblyTextureSystem.DrawCancellation( assemblyTexture, grip, assemblyRotation, assemblyAnchor, assemblyScale, assemblyEffects, lightColor, deathAssemblyCancelStartTimer, deathAssemblyCancelTimer, DeathAssemblyDuration, actionSeed, largeDeathLayout: true); } else { DeathSickleAssemblyTextureSystem.Draw(assemblyTexture, grip, assemblyRotation, assemblyAnchor, assemblyScale, assemblyEffects, lightColor, timer, DeathAssemblyDuration, actionSeed, largeDeathLayout: true); } return false; } DrawFormBackdrop(); DrawTrail(); // Blood's chevron rune read as an aiming arrow, while Void's parallel // rune bars visibly crossed the torn background. Their trail surfaces are // already the form language, so do not stamp unrelated glyphs over them. if (HasStageVisual(ReaperStage.StageII) && VisualForm is not ReaperFormId.Blood and not ReaperFormId.Void) DrawStageRunes(); Texture2D texture = ModContent.Request( ReaperCombatRegistry.GetTexturePath(snapshot.Form, snapshot.Stage)).Value; float scale = snapshot.Form == ReaperFormId.Death ? ReaperCombatRegistry.GetDeathWeaponDrawScale(snapshot) : 0.86f + snapshot.StageNumber * 0.045f; SpriteEffects weaponEffects = GetWeaponEffects(0); DrawWeaponAfterimages(texture, scale); DrawWeapon(texture, grip, weaponAngle, lightColor, scale, weaponEffects); return false; } public override void OnHitNPC(NPC target, NPC.HitInfo hit, int damageDone) { if (Main.netMode == NetmodeID.Server) return; if (snapshot.Form == ReaperFormId.Base) { SpawnBasePrimaryImpact(target.Center); return; } // Void owns a synchronized, persistent impact rift. Keeping the old // shadowflame burst as well made the contact read like an ordinary purple // magic hit and doubled the owner's feedback in multiplayer. if (snapshot.Form != ReaperFormId.Void) SpawnFormImpact(target.Center, VisualForm); } public override void SendExtraAI(BinaryWriter writer) { writer.Write(configured); if (!configured) return; snapshot.Write(writer); writer.Write((byte)Math.Clamp(phase, 0, byte.MaxValue)); writer.Write((short)Math.Clamp(timer, 0, short.MaxValue)); writer.Write(aimRotation); writer.Write(deathTempo); writer.Write(deathAssemblyRequired); writer.Write(deathAssemblyCancelling); writer.Write((short)Math.Clamp(deathAssemblyCancelStartTimer, 0, short.MaxValue)); writer.Write((short)Math.Clamp(deathAssemblyCancelTimer, 0, short.MaxValue)); writer.Write(infernalMoveReady); if (infernalMoveReady) { writer.WriteVector2(infernalMoveStart); writer.WriteVector2(infernalMoveEnd); } } public override void ReceiveExtraAI(BinaryReader reader) { bool incomingConfigured = reader.ReadBoolean(); if (!incomingConfigured) { if (Main.netMode != NetmodeID.Server) configured = false; return; } SickleCombatSnapshot incomingSnapshot = SickleCombatSnapshot.Read(reader); int incomingPhase = reader.ReadByte(); int incomingTimer = reader.ReadInt16(); float incomingAimRotation = reader.ReadSingle(); float incomingDeathTempo = MathHelper.Clamp(reader.ReadSingle(), 1f, incomingSnapshot.DeathPrimaryMaximumTempo); bool incomingDeathAssemblyRequired = reader.ReadBoolean(); bool incomingDeathAssemblyCancelling = reader.ReadBoolean(); int incomingDeathAssemblyCancelStartTimer = Math.Max(0, (int)reader.ReadInt16()); int incomingDeathAssemblyCancelTimer = Math.Max(0, (int)reader.ReadInt16()); bool incomingInfernalMoveReady = reader.ReadBoolean(); Vector2 incomingInfernalMoveStart = incomingInfernalMoveReady ? reader.ReadVector2() : Vector2.Zero; Vector2 incomingInfernalMoveEnd = incomingInfernalMoveReady ? reader.ReadVector2() : Vector2.Zero; if (Main.netMode == NetmodeID.Server) return; // A late authoritative packet can correct the locally predicted form, // phase, aim, or animation time. Never join samples from the two poses: // doing so draws one bright chord across the otherwise continuous arc. bool poseWasCorrected = initialized && (snapshot.Form != incomingSnapshot.Form || snapshot.Stage != incomingSnapshot.Stage || phase != incomingPhase || Math.Abs(MathHelper.WrapAngle(aimRotation - incomingAimRotation)) > 0.08f || Math.Abs(timer - incomingTimer) > 2 || deathAssemblyRequired != incomingDeathAssemblyRequired || deathAssemblyCancelling != incomingDeathAssemblyCancelling); configured = true; snapshot = incomingSnapshot; phase = incomingPhase; timer = incomingTimer; aimRotation = incomingAimRotation; deathTempo = incomingSnapshot.Form == ReaperFormId.Death ? incomingDeathTempo : 1f; deathAssemblyRequired = incomingDeathAssemblyRequired; deathAssemblyCancelling = incomingDeathAssemblyCancelling; deathAssemblyCancelStartTimer = incomingDeathAssemblyCancelStartTimer; deathAssemblyCancelTimer = incomingDeathAssemblyCancelTimer; infernalMoveReady = incomingInfernalMoveReady; infernalMoveStart = incomingInfernalMoveStart; infernalMoveEnd = incomingInfernalMoveEnd; receivedAuthoritativeState = true; authorityWaitFrames = 0; int correctedDuration = CalculateDuration(snapshot, phase, deathTempo); if (initialized) duration = correctedDuration; // One-shot release and movement beats must never replay when prediction // is rewound by an authoritative packet. A client which first sees an // already-past release skips that old beat instead of firing it late. int releaseTick = Math.Max(1, (int)Math.Ceiling(correctedDuration * 0.4f)); released |= incomingTimer >= releaseTick; if (incomingTimer > 4) movementTriggered = true; if (poseWasCorrected) InvalidateHistory(); } private void Initialize(Player player) { initialized = true; if (!receivedAuthoritativeState) aimRotation = Projectile.velocity.SafeNormalize(Vector2.UnitX * player.direction).ToRotation(); duration = CalculateDuration(snapshot, phase, deathTempo); grip = player.MountedCenter; weaponAngle = aimRotation; // ReceiveExtraAI can run before the first local AI tick. Preserve an // already synchronized infernal route instead of erasing it here. InvalidateHistory(); if (Main.netMode != NetmodeID.Server) { SoundEngine.PlaySound(snapshot.Form switch { ReaperFormId.Bone => SoundID.Item71, ReaperFormId.Infernal => SoundID.Item74, ReaperFormId.Frost => SoundID.Item28, ReaperFormId.Void => SoundID.Item8, ReaperFormId.Death => SoundID.Item122, _ => SoundID.Item1 }, player.Center); } } private void UpdatePose(float progress) { float eased = SmoothStep(progress); int direction = SwingDirection; switch (snapshot.Form) { case ReaperFormId.Base when phase == 1: SetArc(2.2f, -1.25f, eased, direction); break; case ReaperFormId.Bone: SetArc(-1.85f + phase * 0.22f, 1.12f + phase * 0.18f, eased, direction); grip.Y -= 3f; break; case ReaperFormId.Blood: SetArc(-2.85f, 2.42f, eased, direction); break; case ReaperFormId.Infernal: SetArc(2.15f - phase * 0.12f, -1.4f + phase * 0.15f, EaseOutSine(progress), direction); break; case ReaperFormId.Frost: SetArc(phase == 1 ? 2.45f : -2.45f, phase == 1 ? -1.2f : 1.2f, eased, direction); break; case ReaperFormId.Soul: SetArc(-3.05f, 2.75f, eased, direction); break; case ReaperFormId.Void: float voidSweep = VisualStage switch { 1 => 2.15f, 2 => 2.62f, _ => 3.08f }; // Void attacks must read as an alternating physical swing rather // than a repeated cursor-relative flick. The first cut descends // from above the shoulder; the second retraces from below. Facing // mirrors the pose horizontally without changing that ordering. int voidFacing = Math.Cos(aimRotation) >= 0f ? 1 : -1; if ((phase & 1) == 0) SetArc(-voidSweep * 0.52f, voidSweep * 0.52f, eased, voidFacing); else SetArc(voidSweep * 0.52f, -voidSweep * 0.52f, eased, voidFacing); break; case ReaperFormId.Death: UpdateDeathPose(progress, eased, direction); break; default: SetArc(-2.08f, 1.2f, EaseOutCubic(progress), direction); break; } } private void UpdateDeathPose(float progress, float eased, int direction) { _ = progress; _ = phase; // A fixed sweep is what lets the weapon, revealed crescent, swept damage // sector and max-tempo residual all describe the exact same blade-tip arc. SetArc(-ReaperCombatRegistry.DeathPrimaryHalfSweep, ReaperCombatRegistry.DeathPrimaryHalfSweep, eased, direction); } private void SetArc(float startOffset, float endOffset, float progress, int direction) { weaponAngle = MathHelper.Lerp(aimRotation + startOffset * direction, aimRotation + endOffset * direction, progress); } private void UpdateMovement(Player player) { if (snapshot.Form == ReaperFormId.Infernal && infernalMoveFramesRemaining > 0 && (Main.netMode != NetmodeID.MultiplayerClient || Projectile.owner == Main.myPlayer)) { Vector2 candidate = player.position + infernalMoveVelocity; if (!Collision.SolidCollision(candidate, player.width, player.height)) player.velocity = infernalMoveVelocity; else infernalMoveFramesRemaining = 0; if (infernalMoveFramesRemaining > 0) infernalMoveFramesRemaining--; } if (movementTriggered || timer < 2 || Main.netMode == NetmodeID.MultiplayerClient && Projectile.owner != Main.myPlayer) return; movementTriggered = true; // A newly observed remote action may already be well past its movement // beat. Its authoritative player sync supplies the final position; do not // apply a stale local dash several frames late. if (timer > 4) return; if (snapshot.Form == ReaperFormId.Infernal) { float distance = snapshot.Stage switch { ReaperStage.StageI => 44f, ReaperStage.StageII => 62f, _ => 78f }; Vector2 direction = aimRotation.ToRotationVector2(); float accepted = ReaperMovementHelper.TraceAlong(player, direction, distance, out infernalMoveStart, out infernalMoveEnd); infernalMoveReady = accepted > 0f; infernalMoveFramesRemaining = snapshot.Stage switch { ReaperStage.StageI => 6, ReaperStage.StageII => 7, _ => 8 }; infernalMoveVelocity = infernalMoveFramesRemaining > 0 ? (infernalMoveEnd - infernalMoveStart) / infernalMoveFramesRemaining : Vector2.Zero; Projectile.netUpdate = true; } } private static int CalculateDuration(in SickleCombatSnapshot value, int attackPhase, float primaryDeathTempo) { int baseDuration = ReaperCombatRegistry.GetUseTime(value.Form, value.Stage, attackPhase); int minimumDuration = value.Form == ReaperFormId.Death ? 3 : 12; float tempo = value.Form == ReaperFormId.Death ? primaryDeathTempo : 1f; float effectiveAttackSpeed = value.AttackSpeedMultiplier * tempo; if (value.Form == ReaperFormId.Death) effectiveAttackSpeed = Math.Min(effectiveAttackSpeed, value.DeathPrimaryMaximumTempo); return Math.Max(minimumDuration, (int)Math.Ceiling(baseDuration / Math.Max(0.1f, effectiveAttackSpeed))); } private void UpdateHistory(float progress) { // The final recovery frames do not add trail samples. Their pose can snap // to the held-item resting transform when Terraria disposes heldProj. if (progress >= 0.94f) return; // Sample the rendered weapon tip exactly. The former Void-only reach // estimate was longer than the texture and made the scene tear float in // front of the blade instead of being carved by its tip. Vector2 currentTip = snapshot.Form == ReaperFormId.Death ? grip + weaponAngle.ToRotationVector2() * ReaperCombatRegistry.GetDeathPrimaryBladeReach(snapshot) : GetBladeTip(grip, weaponAngle); // Initialize from the first evaluated animation pose, not aimRotation. // Most forms begin 1-3 radians away from the cursor; pre-filling history // at the cursor used to connect that point to the first swing pose and // produced the single-frame diameter/chord reported by players. if (validHistorySamples <= 0) { Array.Fill(tipHistory, currentTip); Array.Fill(angleHistory, weaponAngle); validHistorySamples = 1; return; } // Also sever history after a large network correction or teleport. Normal // per-tick angular motion (including Infernal movement) remains far below // this form-scaled limit, so legitimate arcs stay continuous. float discontinuityDistance = snapshot.Form == ReaperFormId.Death ? ReaperCombatRegistry.GetDeathPrimaryBladeReach(snapshot) * 0.96f : Math.Clamp(ReaperCombatRegistry.GetBladeTipLength(snapshot.Form, snapshot.Stage) * 0.62f, 62f, 118f); float angularJump = Math.Abs(MathHelper.WrapAngle(weaponAngle - angleHistory[0])); if (Vector2.DistanceSquared(currentTip, tipHistory[0]) > discontinuityDistance * discontinuityDistance || angularJump > (snapshot.Form == ReaperFormId.Death ? 1.35f : 0.72f)) { Array.Fill(tipHistory, currentTip); Array.Fill(angleHistory, weaponAngle); validHistorySamples = 1; return; } for (int index = HistoryLength - 1; index > 0; index--) { tipHistory[index] = tipHistory[index - 1]; angleHistory[index] = angleHistory[index - 1]; } tipHistory[0] = currentTip; angleHistory[0] = weaponAngle; validHistorySamples = Math.Min(HistoryLength, validHistorySamples + 1); } private void InvalidateHistory() { validHistorySamples = 0; Array.Clear(tipHistory, 0, tipHistory.Length); Array.Clear(angleHistory, 0, angleHistory.Length); } private void SpawnMotionDust() { if (Main.netMode == NetmodeID.Server || validHistorySamples <= 0 || !Main.rand.NextBool(snapshot.Stage >= ReaperStage.StageIII ? 1 : snapshot.Stage >= ReaperStage.StageII ? 2 : 3)) return; Color color = ReaperCombatRegistry.GetPrimaryColor(snapshot.Form); Dust dust = Dust.NewDustPerfect(tipHistory[0], ReaperCombatRegistry.GetDust(snapshot.Form), Main.rand.NextVector2Circular(1.8f, 1.8f), 80, color, 0.8f + snapshot.StageNumber * 0.08f); dust.noGravity = true; Lighting.AddLight(tipHistory[0], color.ToVector3() * (0.3f + snapshot.StageNumber * 0.12f)); } private void DrawFormBackdrop() { float fade = GetSwingVisibility(); if (fade <= 0.01f) return; switch (VisualForm) { case ReaperFormId.Bone when VisualStage >= 2: DrawBoneRibBackdrop(fade); break; case ReaperFormId.Blood when VisualStage >= 2: DrawBloodMoonBackdrop(fade); break; case ReaperFormId.Infernal when VisualStage >= 2: DrawInfernalBackdrop(fade); break; case ReaperFormId.Frost when VisualStage >= 2: DrawFrostMirrorBackdrop(fade); break; case ReaperFormId.Soul: DrawSoulEchoBackdrop(fade); break; } if (snapshot.Form == ReaperFormId.Infernal) DrawInfernalMovementTrack(fade); } private void DrawBoneRibBackdrop(float fade) { Texture2D pixel = TextureAssets.MagicPixel.Value; Vector2 direction = weaponAngle.ToRotationVector2(); Vector2 normal = direction.RotatedBy(MathHelper.PiOver2); float reach = Vector2.Distance(grip, GetBladeTip(grip, weaponAngle)) * (VisualStage >= 3 ? 1.08f : 0.96f); Color fire = new Color(30, 220, 255, 0); Color bone = new Color(242, 241, 211, 0); float opacity = fade * (IsComboFinisher || snapshot.Form == ReaperFormId.Death ? 0.72f : 0.42f); Vector2 shoulder = grip - direction * 13f; Vector2 elbow = grip + direction * reach * 0.43f + normal * SwingDirection * 20f; Vector2 wrist = grip + direction * reach * 0.91f; DrawVisualSegment(pixel, shoulder, elbow, fire * (opacity * 0.44f), VisualStage >= 3 ? 17f : 11f); DrawVisualSegment(pixel, shoulder, elbow, bone * opacity, VisualStage >= 3 ? 6.5f : 4.2f); DrawVisualSegment(pixel, elbow, wrist + normal * 6f, fire * (opacity * 0.42f), VisualStage >= 3 ? 13f : 8f); DrawVisualSegment(pixel, elbow, wrist + normal * 6f, bone * opacity, VisualStage >= 3 ? 4.5f : 3f); DrawVisualSegment(pixel, elbow, wrist - normal * 6f, bone * (opacity * 0.74f), VisualStage >= 3 ? 3.8f : 2.5f); DrawRing(pixel, elbow, VisualStage >= 3 ? 13f : 8f, 12, fire * (opacity * 0.52f), 4f, weaponAngle); DrawRing(pixel, elbow, VisualStage >= 3 ? 8f : 5f, 10, bone * opacity, 2.2f, weaponAngle); int ribs = VisualStage >= 3 ? 5 : 3; for (int index = 0; index < ribs; index++) { float along = 0.2f + index * 0.115f; Vector2 spine = Vector2.Lerp(shoulder, elbow, along); float ribLength = (16f + VisualStage * 5f) * (1f - index * 0.07f); Vector2 side = normal * ribLength; Vector2 curled = direction * (7f + index * 1.5f); DrawVisualSegment(pixel, spine, spine + side, fire * (opacity * 0.34f), 5.4f); DrawVisualSegment(pixel, spine + side, spine + side * 0.76f + curled, bone * (opacity * 0.76f), 2.3f); DrawVisualSegment(pixel, spine, spine - side, fire * (opacity * 0.34f), 5.4f); DrawVisualSegment(pixel, spine - side, spine - side * 0.76f + curled, bone * (opacity * 0.76f), 2.3f); } if (VisualStage >= 3 && IsComboFinisher) { Vector2 focus = grip + aimRotation.ToRotationVector2() * 90f; DrawBoneGuillotine(pixel, focus, fade); } } private void DrawBoneGuillotine(Texture2D pixel, Vector2 center, float fade) { Color fire = new Color(25, 220, 255, 0); Color bone = new Color(250, 246, 215, 0); float pulse = Smooth01((animationProgress - 0.2f) / 0.24f) * fade; Vector2 vertical = Vector2.UnitY * 66f; Vector2 horizontal = Vector2.UnitX * 60f; DrawVisualSegment(pixel, center - vertical, center + vertical, fire * (pulse * 0.28f), 22f); DrawVisualSegment(pixel, center - vertical, center + vertical, bone * (pulse * 0.56f), 5f); DrawVisualSegment(pixel, center - horizontal, center + horizontal, fire * (pulse * 0.22f), 18f); DrawVisualSegment(pixel, center - horizontal, center + horizontal, bone * (pulse * 0.46f), 4f); DrawRing(pixel, center, 48f, 20, fire * (pulse * 0.25f), 4f, MathHelper.PiOver4); } private void DrawBloodMoonBackdrop(float fade) { bool showMoon = IsComboFinisher || snapshot.Form == ReaperFormId.Death; if (!showMoon) return; float grow = Smooth01((animationProgress - 0.08f) / 0.34f); float radius = (VisualStage >= 3 ? 92f : 66f) * grow; Vector2 center = grip + aimRotation.ToRotationVector2() * (VisualStage >= 3 ? 92f : 72f); if (radius > 2f) { DeathDomainPrimitiveTextureSystem.DrawRadialGradient(Main.spriteBatch, center - Main.screenPosition, radius * 1.28f, new Color(7, 0, 4, 145) * (fade * 0.72f)); DeathDomainPrimitiveTextureSystem.DrawRadialGradient(Main.spriteBatch, center - Main.screenPosition, radius, new Color(92, 0, 18, 115) * (fade * 0.62f)); } Texture2D pixel = TextureAssets.MagicPixel.Value; DrawRing(pixel, center, radius, 42, new Color(255, 22, 52, 0) * (fade * 0.32f), VisualStage >= 3 ? 7f : 4f, -Main.GlobalTimeWrappedHourly * 0.2f); if (VisualStage >= 3) DrawRing(pixel, center, radius * 0.76f, 36, new Color(255, 158, 128, 0) * (fade * 0.16f), 2f, Main.GlobalTimeWrappedHourly * 0.3f); } private void DrawInfernalBackdrop(float fade) { Texture2D pixel = TextureAssets.MagicPixel.Value; Vector2 center = grip + aimRotation.ToRotationVector2() * 62f; Color ember = new Color(255, 54, 4, 0); Color hot = new Color(255, 222, 70, 0); if (IsComboFinisher || snapshot.Form == ReaperFormId.Death) { float radius = VisualStage >= 3 ? 82f : 52f; float rotation = -MathHelper.PiOver2 + Main.GlobalTimeWrappedHourly * 0.28f * SwingDirection; DrawStarPolygon(pixel, center, radius, 5, rotation, ember * (fade * 0.26f), VisualStage >= 3 ? 8f : 5f); DrawStarPolygon(pixel, center, radius * 0.84f, 5, rotation, hot * (fade * 0.42f), 2.1f); } if (VisualStage < 3) return; Vector2 forward = weaponAngle.ToRotationVector2(); Vector2 normal = forward.RotatedBy(MathHelper.PiOver2); Vector2 root = grip - forward * 15f; for (int side = -1; side <= 1; side += 2) { Vector2 previous = root; for (int feather = 0; feather < 4; feather++) { Vector2 end = root - forward * (28f + feather * 16f) + normal * side * (32f + feather * 18f); DrawVisualSegment(pixel, previous, end, new Color(12, 2, 0, 205) * (fade * 0.72f), 15f - feather * 1.7f); DrawVisualSegment(pixel, previous, end, ember * (fade * (0.42f - feather * 0.045f)), 7f - feather * 0.7f); DrawVisualSegment(pixel, previous, end, hot * (fade * 0.36f), 1.8f); previous = Vector2.Lerp(root, end, 0.28f); } } } private void DrawInfernalMovementTrack(float fade) { if (!infernalMoveReady) return; Vector2 path = infernalMoveEnd - infernalMoveStart; float length = path.Length(); if (length <= 1f) return; Texture2D pixel = TextureAssets.MagicPixel.Value; Vector2 direction = path / length; Vector2 normal = direction.RotatedBy(MathHelper.PiOver2); float reveal = Smooth01((animationProgress - 0.04f) / 0.24f) * GetTerminalFade(); float shellWidth = 13f + VisualStage * 3f; DrawVisualSegment(pixel, infernalMoveStart, infernalMoveEnd, new Color(8, 2, 0, 190) * (reveal * 0.58f), shellWidth); DrawVisualSegment(pixel, infernalMoveStart, infernalMoveEnd, new Color(255, 66, 5, 0) * (reveal * 0.48f), shellWidth * 0.54f); DrawVisualSegment(pixel, infernalMoveStart, infernalMoveEnd, new Color(255, 235, 130, 0) * (reveal * 0.72f), Math.Max(1.6f, shellWidth * 0.15f)); int sparks = 3 + VisualStage * 2; for (int index = 0; index < sparks; index++) { float amount = (index + 0.5f) / sparks; Vector2 point = Vector2.Lerp(infernalMoveStart, infernalMoveEnd, amount); float side = (index & 1) == 0 ? 1f : -1f; Vector2 end = point - direction * (5f + index % 3 * 3f) + normal * side * (8f + VisualStage * 3f); DrawVisualSegment(pixel, point, end, new Color(255, 154, 35, 0) * (reveal * 0.42f), 1.6f); } if (IsComboFinisher) { DrawRing(pixel, infernalMoveEnd, 24f + VisualStage * 7f, 22, new Color(255, 76, 8, 0) * (reveal * 0.44f), 5f, Main.GlobalTimeWrappedHourly * 0.5f); } } private void DrawFrostMirrorBackdrop(float fade) { Texture2D pixel = TextureAssets.MagicPixel.Value; Color glass = new Color(55, 155, 255, 50); Color edge = new Color(224, 255, 255, 0); int mirrors = VisualStage >= 3 ? 3 : 1; for (int index = 0; index < mirrors; index++) { float side = index - (mirrors - 1) * 0.5f; Vector2 focus = grip + aimRotation.ToRotationVector2() * (78f + index * 20f) + aimRotation.ToRotationVector2().RotatedBy(MathHelper.PiOver2) * side * 54f; float radius = VisualStage >= 3 ? 44f : 31f; DrawHexMirror(pixel, focus, radius, weaponAngle + index * 0.22f, glass * (fade * 0.58f), edge * (fade * 0.52f)); } if (VisualStage >= 3 && IsComboFinisher) { Vector2 center = grip + aimRotation.ToRotationVector2() * 82f; float execution = Smooth01((animationProgress - 0.23f) / 0.23f) * fade; DrawHexMirror(pixel, center, 58f, 0f, new Color(38, 125, 225, 65) * execution, edge * (execution * 0.54f)); DrawVisualSegment(pixel, center + new Vector2(-62f, -62f), center + new Vector2(62f, 62f), edge * (execution * 0.38f), 5.5f); DrawVisualSegment(pixel, center + new Vector2(-62f, 62f), center + new Vector2(62f, -62f), new Color(80, 205, 255, 0) * (execution * 0.44f), 7f); } } private void DrawSoulEchoBackdrop(float fade) { int echoes = Math.Clamp(VisualStage, 1, 3); for (int echo = echoes; echo >= 1; echo--) { int historyIndex = Math.Min(Math.Max(0, validHistorySamples - 1), echo * 3); float angle = validHistorySamples > 0 ? angleHistory[historyIndex] : weaponAngle; Vector2 offset = angle.ToRotationVector2().RotatedBy(-MathHelper.PiOver2) * (echo - 1) * 7f; float opacity = fade * (0.22f + (echoes - echo) * 0.08f); DrawSoulEchoFigure(grip + offset, angle, 0.82f + VisualStage * 0.12f, opacity); } if (VisualStage >= 3 && IsComboFinisher) DrawSoulEchoFigure(grip - new Vector2(0f, 18f), weaponAngle, 1.38f, fade * 0.30f); } private void DrawSoulEchoFigure(Vector2 center, float angle, float scale, float opacity) { Texture2D pixel = TextureAssets.MagicPixel.Value; Color aura = new Color(104, 54, 255, 0) * opacity; Color spirit = new Color(80, 245, 255, 0) * (opacity * 0.82f); Vector2 head = center - Vector2.UnitY * 18f * scale; Vector2 bodyBottom = center + Vector2.UnitY * 29f * scale; DrawRing(pixel, head, 10f * scale, 16, aura, 5.5f * scale, angle); DrawVisualSegment(pixel, head + Vector2.UnitY * 5f * scale, bodyBottom, aura, 10f * scale); Vector2 hand = center + angle.ToRotationVector2() * 30f * scale; DrawVisualSegment(pixel, center - Vector2.UnitY * 2f * scale, hand, spirit, 3f * scale); DrawVisualSegment(pixel, hand - angle.ToRotationVector2() * 12f * scale, hand + angle.ToRotationVector2() * 48f * scale, aura, 4f * scale); Vector2 bladeEnd = hand + angle.ToRotationVector2() * 58f * scale + angle.ToRotationVector2().RotatedBy(MathHelper.PiOver2) * 18f * scale * SwingDirection; DrawVisualSegment(pixel, hand + angle.ToRotationVector2() * 43f * scale, bladeEnd, spirit, 3.2f * scale); } private void DrawTrail() { if (snapshot.Form == ReaperFormId.Death) { // The opaque domain face and its fixed-size cutting edge are emitted // together by DeathDomainCrescentVisualSystem. Drawing the old sprite // rim here would reintroduce a pulsing duplicate over that geometry. return; } if (VisualForm == ReaperFormId.Void) { DrawVoidCut(); return; } DrawMaterialCrescentTrail(); if (VisualForm == ReaperFormId.Base) DrawBaseTrail(); } private void DrawMaterialCrescentTrail() { float fade = GetSwingVisibility(); if (fade <= 0.001f) return; float weaponLength = ReaperCombatRegistry.GetBladeTipLength( snapshot.Form, snapshot.Stage); float radius = weaponLength * (VisualStage switch { <= 0 => 0.86f, 1 => 0.94f, 2 => 1.02f, _ => 1.10f }); if (VisualStage >= 3 && IsComboFinisher) radius *= 1.035f; int direction = GetAngularDirection(0); int motionLayers = VisualStage switch { <= 1 => 1, 2 => 2, _ => 4 }; Vector2 forward = aimRotation.ToRotationVector2(); Vector2 normal = forward.RotatedBy(MathHelper.PiOver2); Vector2 center = grip + forward * radius * 0.075f - normal * direction * radius * 0.11f; float rotation = aimRotation + MathHelper.Lerp(-0.12f, 0.10f, SmoothStep(animationProgress)) * direction; ReaperCrescentPrimitiveTextureSystem.DrawFormCrescent(VisualForm, center, rotation, radius, fade * 0.90f, motionLayers, direction, Main.GlobalTimeWrappedHourly, animationProgress); } private void DrawDeathPrimaryTrail() { float visibility = GetDeathCrescentVisibility(); if (visibility <= 0.001f) return; GetDeathPrimaryCrescentGeometry(out Vector2 center, out float rotation, out float reach, out int direction); ReaperCrescentPrimitiveTextureSystem.DrawDeathDomainCrescentRim(center, rotation, reach, visibility * 0.92f, 4, direction, Main.GlobalTimeWrappedHourly, animationProgress); } private void RecordDeathPrimaryCrescent() { float visibility = GetDeathCrescentVisibility(); if (visibility <= 0.001f) return; if (deathCrescentVisualInstanceId <= 0) { deathCrescentVisualInstanceId = DeathDomainCrescentVisualSystem .AllocateVisualInstanceId(); } GetDeathPrimaryCrescentGeometry(out Vector2 center, out float rotation, out float reach, out int direction); DeathDomainCrescentVisualSystem.Record(deathCrescentVisualInstanceId, Projectile.owner, center, rotation, reach, visibility * 0.92f, direction, animationProgress, DeathSpaceBreak); } private void GetDeathPrimaryCrescentGeometry(out Vector2 center, out float rotation, out float reach, out int direction) { direction = GetAngularDirection(0); reach = ReaperCombatRegistry.GetDeathPrimaryCrescentRadius(snapshot); center = grip; rotation = aimRotation; } private void SpawnDeathDomainRift() { // Max tempo only has six or seven animation frames. Connecting those // sparse history samples turns a circular swing into several enormous // straight chords. Reconstruct the exact immutable blade-tip locus at // high resolution from the same pose equation used by UpdateDeathPose. // Space Break represents the completed max-tempo sweep. Build the full // deterministic blade-tip locus instead of freezing whichever fractional // animation sample happened to cross the spawn threshold on this host. Vector2[] bladePath = BuildDeathDomainRiftPath(grip, aimRotation, SwingDirection, snapshot, 1f); ReaperDeathDomainRiftProjectile.Spawn(Projectile.GetSource_FromThis(), Projectile.owner, snapshot, bladePath, phase, Projectile.identity); } internal static Vector2[] BuildDeathDomainRiftPath(Vector2 pathGrip, float pathAimRotation, int pathSwingDirection, in SickleCombatSnapshot pathSnapshot, float pathAnimationProgress) { const int sampleCount = 65; Vector2[] bladePath = new Vector2[sampleCount]; float traversed = SmoothStep(MathHelper.Clamp(pathAnimationProgress, 0f, 1f)); int direction = Math.Sign(pathSwingDirection) == 0 ? 1 : Math.Sign(pathSwingDirection); float reach = ReaperCombatRegistry.GetDeathPrimaryBladeReach( pathSnapshot); for (int index = 0; index < sampleCount; index++) { float progress = index / (float)(sampleCount - 1) * traversed; float offset = MathHelper.Lerp( -ReaperCombatRegistry.DeathPrimaryHalfSweep, ReaperCombatRegistry.DeathPrimaryHalfSweep, progress) * direction; float angle = pathAimRotation + offset; bladePath[index] = pathGrip + angle.ToRotationVector2() * reach; } return bladePath; } private void DrawBaseTrail() { Texture2D pixel = TextureAssets.MagicPixel.Value; float fade = GetSwingVisibility(); Color shadow = new(7, 45, 58, 150); Color spirit = new(45, 205, 225, 0); Color edge = new(215, 255, 250, 0); for (int index = validHistorySamples - 1; index > 0; index--) { float strength = GetHistoryStrength(index) * fade; Vector2 older = tipHistory[index]; Vector2 newer = tipHistory[index - 1]; float width = 3.2f + strength * 4.8f; DrawVisualSegment(pixel, older, newer, shadow * (strength * 0.72f), width * 1.75f); DrawVisualSegment(pixel, older, newer, spirit * (strength * 0.62f), width); DrawVisualSegment(pixel, older, newer, edge * (strength * 0.88f), 1.25f); // The inner filament is reconstructed from the same blade-tip // history, so the starter crescent grows with the real swing rather // than appearing at full size on its first frame. Vector2 innerOlder = Vector2.Lerp(grip, older, 0.78f); Vector2 innerNewer = Vector2.Lerp(grip, newer, 0.78f); DrawVisualSegment(pixel, innerOlder, innerNewer, spirit * (strength * 0.24f), Math.Max(1.4f, width * 0.42f)); } } private void DrawBoneTrail() { Texture2D pixel = TextureAssets.MagicPixel.Value; float fade = GetTerminalFade(); float scale = VisualStage switch { 1 => 1f, 2 => 1.05f, _ => 1.10f }; Color corpseFire = new Color(35, 225, 255, 0); Color marrow = new Color(246, 244, 211, 0); Color shadow = new Color(8, 42, 54, 155); for (int index = validHistorySamples - 1; index > 0; index--) { Vector2 older = ScaleTip(index, scale); Vector2 newer = ScaleTip(index - 1, scale); Vector2 oldRadial = (older - grip).SafeNormalize(Vector2.UnitY); Vector2 newRadial = (newer - grip).SafeNormalize(Vector2.UnitY); older += oldRadial * (index % 2 == 0 ? 4f + VisualStage * 2f : -2f); newer += newRadial * ((index - 1) % 2 == 0 ? 4f + VisualStage * 2f : -2f); float strength = GetHistoryStrength(index) * fade; float width = (5.8f + VisualStage * 1.65f) * (0.35f + strength * 0.65f); DrawVisualSegment(pixel, older, newer, shadow * (strength * 0.82f), width * 2.05f); DrawVisualSegment(pixel, older, newer, corpseFire * (strength * 0.58f), width * 1.48f); DrawVisualSegment(pixel, older, newer, marrow * (strength * 0.92f), Math.Max(1.2f, width * 0.48f)); if (index % 2 == 0) { Vector2 toothBase = Vector2.Lerp(older, newer, 0.5f); Vector2 inward = (grip - toothBase).SafeNormalize(-newRadial); float toothLength = 7f + VisualStage * 4f; DrawVisualSegment(pixel, toothBase, toothBase + inward * toothLength, corpseFire * (strength * 0.38f), 4.8f); DrawVisualSegment(pixel, toothBase, toothBase + inward * toothLength, marrow * (strength * 0.88f), 1.65f); } } } private void DrawBloodTrail() { float fade = GetSwingVisibility(); bool apex = VisualStage >= 3 && (IsComboFinisher || snapshot.Form == ReaperFormId.Death); float weaponLength = ReaperCombatRegistry.GetBladeTipLength(snapshot.Form, snapshot.Stage); float radius = weaponLength * (VisualStage switch { 1 => 0.94f, 2 => 1.02f, _ => 1.10f }); if (apex) radius *= 1.035f; int motionLayers = VisualStage switch { 1 => 1, 2 => 2, _ => 4 }; int motionDirection = SwingDirection * (phase % 2 == 0 ? 1 : -1); Vector2 forward = aimRotation.ToRotationVector2(); Vector2 normal = forward.RotatedBy(MathHelper.PiOver2); Vector2 center = grip + forward * radius * 0.075f - normal * motionDirection * radius * 0.11f; float rotation = aimRotation + MathHelper.Lerp(-0.12f, 0.10f, SmoothStep(animationProgress)) * motionDirection; // A single high-resolution surface recreates the reference silhouette: // needle-point ends, a massive liquid-red face, warm-white outside edge // and layered motion exposure. No radial MagicPixel blocks remain. ReaperCrescentPrimitiveTextureSystem.DrawBloodCrescent( center, rotation, radius, fade * (apex ? 0.98f : 0.88f), motionLayers, motionDirection, Main.GlobalTimeWrappedHourly, animationProgress); } private void DrawInfernalTrail() { Texture2D pixel = TextureAssets.MagicPixel.Value; float fade = GetTerminalFade(); float scale = VisualStage switch { 1 => 1f, 2 => 1.04f, _ => 1.09f }; Color charred = new Color(7, 3, 2, 220); Color ember = new Color(255, 48, 4, 0); Color lava = new Color(255, 135, 12, 0); Color core = new Color(255, 246, 145, 0); for (int index = validHistorySamples - 1; index > 0; index--) { Vector2 older = ScaleTip(index, scale); Vector2 newer = ScaleTip(index - 1, scale); float strength = GetHistoryStrength(index) * fade; float width = (7f + VisualStage * 2.5f) * (0.32f + strength * 0.68f); DrawVisualSegment(pixel, older, newer, ember * (strength * 0.25f), width * 2.8f); DrawVisualSegment(pixel, older, newer, charred * (strength * 0.92f), width * 1.75f); DrawVisualSegment(pixel, older, newer, lava * (strength * 0.90f), width); DrawVisualSegment(pixel, older, newer, core * (strength * 0.86f), Math.Max(1.2f, width * 0.24f)); if (VisualStage >= 2 && index % 4 == 0) DrawMoltenNode(Vector2.Lerp(older, newer, 0.5f), width * 0.72f, strength); } } private void DrawFrostTrail() { Texture2D pixel = TextureAssets.MagicPixel.Value; float fade = GetTerminalFade(); float scale = VisualStage switch { 1 => 1f, 2 => 1.05f, _ => 1.10f }; Color depth = new Color(12, 48, 105, 145); Color ice = new Color(55, 180, 255, 0); Color prism = new Color(226, 255, 255, 0); for (int index = validHistorySamples - 1; index > 0; index--) { Vector2 older = ScaleTip(index, scale); Vector2 newer = ScaleTip(index - 1, scale); float strength = GetHistoryStrength(index) * fade; float width = (7f + VisualStage * 2.2f) * (0.25f + strength * 0.75f); DrawVisualSegment(pixel, older, newer, depth * (strength * 0.74f), width * 2f); DrawVisualSegment(pixel, older, newer, ice * (strength * 0.62f), width); DrawVisualSegment(pixel, older, newer, prism * (strength * 0.92f), Math.Max(1.1f, width * 0.23f)); if (index % 2 == 0) { Vector2 oldInner = grip + (older - grip) * (0.82f - VisualStage * 0.025f); Vector2 newInner = grip + (newer - grip) * (0.82f - VisualStage * 0.025f); DrawVisualSegment(pixel, older, newInner, prism * (strength * 0.34f), 1.15f); DrawVisualSegment(pixel, oldInner, newer, ice * (strength * 0.28f), 1.85f); } } } private void DrawSoulTrail() { Texture2D pixel = TextureAssets.MagicPixel.Value; float fade = GetTerminalFade(); float scale = VisualStage switch { 1 => 1f, 2 => 1.05f, _ => 1.11f }; Color deepSoul = new Color(48, 18, 118, 110); Color soul = new Color(130, 78, 255, 0); Color spirit = new Color(86, 245, 255, 0); float time = Main.GlobalTimeWrappedHourly * 7f; for (int index = validHistorySamples - 1; index > 0; index--) { Vector2 older = ScaleTip(index, scale); Vector2 newer = ScaleTip(index - 1, scale); Vector2 normal = (newer - older).SafeNormalize(Vector2.UnitX).RotatedBy(MathHelper.PiOver2); float strength = GetHistoryStrength(index) * fade; float wave = (float)Math.Sin(time + index * 0.9f) * (2f + VisualStage * 1.5f); float width = (8f + VisualStage * 3f) * (0.28f + strength * 0.72f); DrawVisualSegment(pixel, older, newer, deepSoul * (strength * 0.68f), width * 2.1f); DrawVisualSegment(pixel, older + normal * wave, newer + normal * wave, soul * (strength * 0.54f), width); DrawVisualSegment(pixel, older - normal * wave * 0.65f, newer - normal * wave * 0.65f, spirit * (strength * 0.46f), Math.Max(1.25f, width * 0.26f)); } } private void DrawVoidCut() { // The client-only trail system records the real blade-tip path during AI // and keeps it for sixty frames. Nothing is drawn as a prebuilt crescent. } private void DrawDeathBindingTrail() { if (validHistorySamples < 2) return; Texture2D pixel = TextureAssets.MagicPixel.Value; float fade = GetTerminalFade(); for (int index = validHistorySamples - 1; index > 0; index--) { float strength = GetHistoryStrength(index) * fade; DrawVisualSegment(pixel, tipHistory[index], tipHistory[index - 1], new Color(8, 0, 5, 210) * (strength * 0.86f), 13f * strength + 2f); DrawVisualSegment(pixel, tipHistory[index], tipHistory[index - 1], new Color(255, 20, 48, 0) * (strength * 0.54f), 2.1f); } } private void DrawWeaponAfterimages(Texture2D texture, float scale) { bool soulTrail = snapshot.Form == ReaperFormId.Soul; int copies = soulTrail ? 2 + VisualStage * 2 : HasStageVisual(ReaperStage.StageIII) ? 4 : HasStageVisual(ReaperStage.StageII) ? 2 : 0; if (copies <= 0 || validHistorySamples < 3) return; Color primary = ReaperCombatRegistry.GetPrimaryColor(snapshot.Form) with { A = 0 }; Color secondary = ReaperCombatRegistry.GetSecondaryColor(snapshot.Form) with { A = 0 }; float terminalFade = MathHelper.Clamp((0.985f - animationProgress) / 0.12f, 0f, 1f); for (int copy = copies; copy >= 1; copy--) { int historyIndex = Math.Min(validHistorySamples - 1, soulTrail ? copy : copy * 2); float strength = (copies - copy + 1f) / (copies + 1f) * terminalFade; float opacity = soulTrail ? 0.52f : 0.34f; Color color = Color.Lerp(primary, secondary, copy / (float)(copies + 1)) * (strength * opacity); DrawWeapon(texture, grip, angleHistory[historyIndex], color, scale * (soulTrail ? 1f : 0.96f + strength * 0.04f), GetWeaponEffects(historyIndex)); } } private void DrawStageRunes() { if (validHistorySamples < 2) return; Texture2D pixel = TextureAssets.MagicPixel.Value; Color primary = ReaperCombatRegistry.GetPrimaryColor(VisualForm) with { A = 0 }; Color secondary = ReaperCombatRegistry.GetSecondaryColor(VisualForm) with { A = 0 }; int runeCount = HasStageVisual(ReaperStage.StageIII) ? 5 : 3; float time = Main.GlobalTimeWrappedHourly; for (int rune = 0; rune < runeCount; rune++) { int historyIndex = 1 + rune * (HistoryLength - 4) / Math.Max(1, runeCount - 1); if (historyIndex >= validHistorySamples) continue; Vector2 position = tipHistory[Math.Clamp(historyIndex, 0, HistoryLength - 1)]; if (position == Vector2.Zero || Vector2.DistanceSquared(position, grip) > 300f * 300f) continue; float historyStrength = 1f - historyIndex / (float)HistoryLength; float pulse = 0.78f + (float)Math.Sin(time * 5f + rune * 1.7f) * 0.16f; float size = (HasStageVisual(ReaperStage.StageIII) ? 7.5f : 6f) * (0.72f + historyStrength * 0.38f); float rotation = weaponAngle + rune * 0.37f + time * (rune % 2 == 0 ? 0.42f : -0.32f); DrawRuneGlyph(pixel, position, size, rotation, primary, secondary, pulse * (0.35f + historyStrength * 0.55f)); } } private void DrawRuneGlyph( Texture2D pixel, Vector2 center, float size, float rotation, Color outer, Color core, float opacity) { Vector2 x = rotation.ToRotationVector2() * size; Vector2 y = x.RotatedBy(MathHelper.PiOver2); switch (VisualForm) { case ReaperFormId.Bone: DrawLayeredVisualSegment(pixel, center - x, center + x, outer, core, opacity); DrawLayeredVisualSegment(pixel, center - y * 0.72f, center + y * 0.72f, outer, core, opacity); break; case ReaperFormId.Blood: DrawLayeredVisualSegment(pixel, center - x, center + y, outer, core, opacity); DrawLayeredVisualSegment(pixel, center + y, center + x, outer, core, opacity); break; case ReaperFormId.Infernal: DrawLayeredVisualSegment(pixel, center - x, center + y, outer, core, opacity); DrawLayeredVisualSegment(pixel, center + y, center + x, outer, core, opacity); DrawLayeredVisualSegment(pixel, center + x, center - x, outer, core, opacity); break; case ReaperFormId.Frost: for (int axis = 0; axis < 3; axis++) { Vector2 spoke = (rotation + axis * MathHelper.Pi / 3f).ToRotationVector2() * size; DrawLayeredVisualSegment(pixel, center - spoke, center + spoke, outer, core, opacity); } break; case ReaperFormId.Soul: DrawLayeredVisualSegment(pixel, center - x, center + y, outer, core, opacity); DrawLayeredVisualSegment(pixel, center + y, center + x, outer, core, opacity); DrawLayeredVisualSegment(pixel, center + x, center - y, outer, core, opacity); DrawLayeredVisualSegment(pixel, center - y, center - x, outer, core, opacity); break; case ReaperFormId.Void: DrawLayeredVisualSegment(pixel, center - x - y * 0.35f, center + x - y * 0.35f, outer, core, opacity); DrawLayeredVisualSegment(pixel, center - x + y * 0.35f, center + x + y * 0.35f, outer, core, opacity * 0.72f); break; default: DrawLayeredVisualSegment(pixel, center - x, center + x, outer, core, opacity); DrawLayeredVisualSegment(pixel, center - y, center + y, outer, core, opacity); break; } } private void DrawMoltenNode(Vector2 center, float radius, float opacity) { Texture2D pixel = TextureAssets.MagicPixel.Value; Color ember = new Color(255, 48, 4, 0) * (opacity * 0.52f); Color core = new Color(255, 239, 112, 0) * (opacity * 0.86f); DrawRing(pixel, center, radius, 10, ember, Math.Max(2f, radius * 0.42f), weaponAngle); for (int spoke = 0; spoke < 4; spoke++) { Vector2 direction = (weaponAngle + spoke * MathHelper.PiOver2).ToRotationVector2(); DrawVisualSegment(pixel, center - direction * radius * 1.35f, center + direction * radius * 1.35f, ember, Math.Max(1.5f, radius * 0.25f)); DrawVisualSegment(pixel, center - direction * radius * 0.82f, center + direction * radius * 0.82f, core, 1.25f); } } private void DrawVoidCoordinatePlane(Vector2 start, Vector2 end, Vector2 normal, float fade) { Texture2D pixel = TextureAssets.MagicPixel.Value; Vector2 direction = (end - start).SafeNormalize(Vector2.UnitX); Color grid = new Color(176, 42, 255, 0) * (fade * 0.30f); Color white = new Color(255, 240, 255, 0) * (fade * 0.48f); float length = Vector2.Distance(start, end); for (int tick = 1; tick <= 7; tick++) { float amount = tick / 8f; Vector2 center = Vector2.Lerp(start, end, amount); float half = 15f + (float)Math.Sin(amount * MathHelper.Pi) * 24f; DrawVisualSegment(pixel, center - normal * half, center + normal * half, tick == 4 ? white : grid, tick == 4 ? 2.3f : 1.2f); } DrawVisualSegment(pixel, start, end, grid, 1.15f); DrawVisualSegment(pixel, start - normal * 31f, end - normal * 31f, grid * 0.55f, 1.05f); for (int gate = 0; gate < 3; gate++) { float amount = 0.28f + gate * 0.29f; Vector2 center = start + direction * length * amount; float size = 17f + gate * 7f; Vector2 x = direction * size; Vector2 y = normal * size; DrawVisualSegment(pixel, center - x, center + y, grid, 1.75f); DrawVisualSegment(pixel, center + y, center + x, white * 0.62f, 1.35f); DrawVisualSegment(pixel, center + x, center - y, grid, 1.75f); DrawVisualSegment(pixel, center - y, center - x, white * 0.62f, 1.35f); } } private static void DrawHexMirror( Texture2D pixel, Vector2 center, float radius, float rotation, Color glass, Color edge) { Span vertices = stackalloc Vector2[6]; for (int index = 0; index < vertices.Length; index++) vertices[index] = center + (rotation + index * MathHelper.TwoPi / 6f).ToRotationVector2() * radius; for (int index = 0; index < vertices.Length; index++) { Vector2 current = vertices[index]; Vector2 next = vertices[(index + 1) % vertices.Length]; DrawVisualSegment(pixel, current, next, edge, 2.2f); DrawVisualSegment(pixel, center, Vector2.Lerp(current, next, 0.5f), glass, radius * 0.20f); } DrawVisualSegment(pixel, vertices[0], vertices[3], edge * 0.38f, 1.15f); DrawVisualSegment(pixel, vertices[1], vertices[4], edge * 0.26f, 1f); DrawVisualSegment(pixel, vertices[2], vertices[5], edge * 0.26f, 1f); } private static void DrawStarPolygon( Texture2D pixel, Vector2 center, float radius, int points, float rotation, Color color, float width) { if (points < 3) return; Vector2 previous = center + rotation.ToRotationVector2() * radius; int currentIndex = 0; for (int edge = 0; edge < points; edge++) { currentIndex = (currentIndex + 2) % points; Vector2 current = center + (rotation + currentIndex * MathHelper.TwoPi / points).ToRotationVector2() * radius; DrawVisualSegment(pixel, previous, current, color, width); previous = current; } } private static void DrawRing( Texture2D pixel, Vector2 center, float radius, int segments, Color color, float width, float rotation = 0f) { if (radius <= 0.1f || segments < 3) return; segments = Math.Max(segments, Math.Min(160, (int)Math.Ceiling(MathHelper.TwoPi * radius / 18f))); Vector2 previous = center + rotation.ToRotationVector2() * radius; for (int index = 1; index <= segments; index++) { Vector2 current = center + (rotation + index * MathHelper.TwoPi / segments).ToRotationVector2() * radius; DrawVisualSegment(pixel, previous, current, color, width); previous = current; } } private void SpawnFormImpact(Vector2 center, ReaperFormId form) { int count = 4 + VisualStage * 2; int dustType = form switch { ReaperFormId.Bone => DustID.DungeonSpirit, ReaperFormId.Blood => DustID.Blood, ReaperFormId.Infernal => DustID.Torch, ReaperFormId.Frost => DustID.IceTorch, ReaperFormId.Soul => DustID.AncientLight, ReaperFormId.Void => DustID.Shadowflame, _ => DustID.AncientLight }; Color color = ReaperCombatRegistry.GetPrimaryColor(form); Vector2 tangent = weaponAngle.ToRotationVector2().RotatedBy(MathHelper.PiOver2); for (int index = 0; index < count; index++) { Vector2 velocity = tangent * Main.rand.NextFloat(-4.2f, 4.2f) + weaponAngle.ToRotationVector2() * Main.rand.NextFloat(0.6f, 3.8f); Dust dust = Dust.NewDustPerfect(center + Main.rand.NextVector2Circular(11f, 11f), dustType, velocity, 60, color, Main.rand.NextFloat(0.72f, 1.18f)); dust.noGravity = true; } if (form != ReaperFormId.Blood || VisualStage < 3) return; // Warm sparks at the contact point sell the white-hot edge of the giant // blood crescent without creating another damaging projectile. for (int index = 0; index < 12; index++) { Vector2 velocity = Main.rand.NextVector2CircularEdge(1f, 1f) * Main.rand.NextFloat(3.5f, 8.5f); Dust spark = Dust.NewDustPerfect(center, DustID.Torch, velocity, 20, index % 3 == 0 ? Color.White : new Color(255, 145, 74), Main.rand.NextFloat(0.72f, 1.16f)); spark.noGravity = true; } } private void SpawnBasePrimaryImpact(Vector2 center) { Vector2 blade = weaponAngle.ToRotationVector2(); Vector2 slash = blade.RotatedBy(MathHelper.PiOver2 * SwingDirection) .SafeNormalize(Vector2.UnitY); Vector2 towardOwner = (grip - center).SafeNormalize(-blade); ReaperVfxDirector.TriggerGlobalImpact(slash, 2.6f, 4, new Color(92, 214, 224), 0f, 0, 0.06f); SoundEngine.PlaySound(SoundID.Item71 with { Volume = 0.46f, Pitch = 0.24f, PitchVariance = 0.08f }, center); for (int index = 0; index < 9; index++) { Vector2 velocity = slash.RotatedBy( Main.rand.NextFloat(-0.72f, 0.72f)) * Main.rand.NextFloat(2.8f, 6.8f); Dust spark = Dust.NewDustPerfect( center + Main.rand.NextVector2Circular(7f, 7f), DustID.AncientLight, velocity, 30, index % 3 == 0 ? new Color(225, 255, 250) : new Color(55, 220, 235), Main.rand.NextFloat(0.72f, 1.08f)); spark.noGravity = true; } // A few harmless motes peel from the target and return toward the // wielder, preserving the basic weapon's soul-pulling identity. for (int index = 0; index < 4; index++) { Dust mote = Dust.NewDustPerfect( center + Main.rand.NextVector2Circular(9f, 9f), DustID.AncientLight, towardOwner.RotatedBy(Main.rand.NextFloat(-0.28f, 0.28f)) * Main.rand.NextFloat(1.2f, 2.8f), 70, new Color(95, 230, 235), Main.rand.NextFloat(0.62f, 0.88f)); mote.noGravity = true; } } private void TriggerPrimaryFinisherImpact() { Vector2 impactDirection = weaponAngle.ToRotationVector2() .RotatedBy(MathHelper.PiOver2 * SwingDirection); (float strength, int frames, Color color, float opacity, int colorFrames, float vignette) = VisualForm switch { ReaperFormId.Bone => (5.2f, 6, new Color(225, 255, 245), 0.20f, 4, 0.24f), ReaperFormId.Blood => (6.8f, 8, new Color(255, 238, 210), 0.34f, 5, 0.38f), ReaperFormId.Infernal => (6.2f, 7, new Color(255, 224, 148), 0.27f, 5, 0.27f), ReaperFormId.Frost => (5.5f, 7, new Color(224, 250, 255), 0.25f, 5, 0.24f), ReaperFormId.Soul => (5.2f, 7, new Color(160, 230, 255), 0.20f, 5, 0.28f), ReaperFormId.Void => (6.4f, 7, new Color(232, 185, 255), 0.29f, 4, 0.36f), _ => (4.6f, 5, Color.White, 0.16f, 3, 0.18f) }; ReaperVfxDirector.TriggerGlobalImpact(impactDirection, strength, frames, color, opacity, colorFrames, vignette); } private Vector2 ScaleTip(int index, float scale) { index = Math.Clamp(index, 0, Math.Max(0, validHistorySamples - 1)); return grip + (tipHistory[index] - grip) * scale; } private float GetHistoryStrength(int index) { return MathHelper.Clamp(1f - index / (float)HistoryLength, 0.08f, 1f); } private float GetTerminalFade() { return MathHelper.Clamp((0.985f - animationProgress) / 0.12f, 0f, 1f); } private float GetSwingVisibility() { return Smooth01(animationProgress / 0.13f) * GetTerminalFade(); } private float GetDeathCrescentVisibility() { if (DeathSpaceBreak) return Smooth01(animationProgress / 0.08f); float terminalFade = 1f - Smooth01((animationProgress - 0.96f) / 0.04f); return Smooth01(animationProgress / 0.13f) * terminalFade; } private static void DrawLayeredVisualSegment( Texture2D pixel, Vector2 start, Vector2 end, Color outer, Color core, float opacity) { DrawVisualSegment(pixel, start, end, outer * (opacity * 0.58f), 3.6f); DrawVisualSegment(pixel, start, end, core * opacity, 1.15f); } private static void DrawVisualSegment(Texture2D pixel, Vector2 start, Vector2 end, Color color, float width) { DeathDomainPrimitiveTextureSystem.DrawSmoothWorldLine(start, end, color, width); } private bool HasStageVisual(ReaperStage minimumStage) { return snapshot.Form == ReaperFormId.Death || ReaperDefinitions.IsBranchForm(snapshot.Form) && snapshot.Stage >= minimumStage; } private void DrawWeapon(Texture2D texture, Vector2 position, float angle, Color color, float scale, SpriteEffects effects) { Vector2 normalizedAnchor = ReaperCombatRegistry.GetHandleAnchor(snapshot.Form, snapshot.Stage); Vector2 anchor = new(texture.Width * normalizedAnchor.X, texture.Height * normalizedAnchor.Y); if ((effects & SpriteEffects.FlipVertically) != 0) anchor.Y = texture.Height - anchor.Y; Main.EntitySpriteDraw(texture, position - Main.screenPosition, null, color, angle + ReaperCombatRegistry.GetTextureRotationCorrection(snapshot.Form, snapshot.Stage), anchor, scale, effects); } private SpriteEffects GetWeaponEffects(int historyIndex) => GetAngularDirection(historyIndex) < 0 ? SpriteEffects.FlipVertically : SpriteEffects.None; private int GetAngularDirection(int historyIndex) { if (validHistorySamples >= 2) { int newer = Math.Clamp(historyIndex, 0, validHistorySamples - 1); int older = Math.Min(validHistorySamples - 1, newer + 1); float delta = MathHelper.WrapAngle(angleHistory[newer] - angleHistory[older]); if (Math.Abs(delta) > 0.001f) return delta >= 0f ? 1 : -1; } return SwingDirection; } private Vector2 GetBladeTip(Vector2 position, float angle) => position + angle.ToRotationVector2() * ReaperCombatRegistry.GetBladeTipLength(snapshot.Form, snapshot.Stage) * (0.86f + snapshot.StageNumber * 0.045f); private float GetVoidVisibleReach() => ReaperCombatRegistry.GetBladeTipLength(snapshot.Form, snapshot.Stage) * (VisualStage switch { 1 => 1.02f, 2 => 1.07f, _ => 1.12f }); private float GetVoidPrimaryWidth() => VisualStage switch { 1 => 9f, 2 => 13f, _ => 17f }; private bool TryGetOwner(out Player player) { if (Projectile.owner < 0 || Projectile.owner >= Main.maxPlayers) { player = null!; return false; } player = Main.player[Projectile.owner]; if (!player.active || player.dead) return false; // Once captured/validated, the swing belongs to its immutable snapshot. // Hotbar or form changes during the animation must not cancel it or make // later frames read properties from the newly held weapon. The server's // initial unconfigured path still requires the claimed source item. return configured || player.HeldItem.type == ItemType && player.HeldItem.ModItem is NormalSickle; } private static float SmoothStep(float value) => value * value * (3f - 2f * value); private static float Smooth01(float value) { value = MathHelper.Clamp(value, 0f, 1f); return value * value * (3f - 2f * value); } private static float EaseOutCubic(float value) => 1f - (float)Math.Pow(1f - value, 3f); private static float EaseOutSine(float value) => (float)Math.Sin(value * MathHelper.PiOver2); }