using SoulHarvest.Common; using SoulHarvest.Items; using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Graphics; using System; using System.Collections.Generic; using System.IO; using Terraria; using Terraria.Audio; using Terraria.GameContent; using Terraria.ID; using Terraria.ModLoader; namespace SoulHarvest.Projectiles; public sealed class ReaperActionControllerProjectile : ModProjectile { private const int BaseDashStartTick = 3; private const int BaseDashTravelFrames = 7; private const int VoidSpecialTeleportTick = 2; private const int VoidSpecialVisualEndTick = 14; private const int VoidUltimateBlackHoleTick = 32; private SickleCombatSnapshot snapshot; private Vector2 aim; private Vector2 specialTargetOffset; private Vector2 ultimateFocusWorld; private bool ultimate; private bool configured; private int timer; private int chargeFrames; private int releaseTimer; private bool releaseRequested; private bool specialReleased; private bool bloodOvercharged; private long bloodSiphonDamage; private int deathInvocationCooldown; private int deathInvocationCount; private int deathAssemblyTimer; private bool deathAssemblyCancelling; private int deathAssemblyCancelStartTimer; private int deathAssemblyCancelTimer; private bool serverAuthorized; private Vector2 baseDashStart; private Vector2 baseDashEnd; private bool baseDashReady; // First three entries serve the staged special; all five serve the pentagram // route of the Infernal ultimate. The authoritative endpoints are serialized // so every client draws the exact terrain-clipped paths used for collision. private readonly Vector2[] infernalDashStarts = new Vector2[5]; private readonly Vector2[] infernalDashEnds = new Vector2[5]; private readonly bool[] infernalDashReady = new bool[5]; private float visualWeaponAngle; private float previousVisualWeaponAngle; private int visualSwingDirection = 1; private bool visualDirectionInitialized; private float visualCharge; private ulong playedVisualEvents; private int previousVisualTimer; private int previousVisualReleaseTimer; private bool visualClockInitialized; private bool IsChargedSpecial => !ultimate && snapshot.Form is ReaperFormId.Blood or ReaperFormId.Frost or ReaperFormId.Soul or ReaperFormId.Death; // Death Mouse2 invokes remote echo scythes; unlike the primary swing it never // materializes the large held weapon, so it must enter its attack clock on // the first authoritative tick without sharing the primary assembly prelude. private bool HasDeathAssemblyPrelude => false; private bool IsDeathAssemblyActive => HasDeathAssemblyPrelude && !deathAssemblyCancelling && timer == 0 && deathAssemblyTimer <= snapshot.AssemblyFrames; internal bool DeathAssemblyComplete => !HasDeathAssemblyPrelude || !deathAssemblyCancelling && deathAssemblyTimer >= snapshot.AssemblyFrames; internal int DeathAssemblyElapsed => deathAssemblyTimer; internal bool DeathAssemblyCancelling => deathAssemblyCancelling; internal ReaperFormId ConfiguredForm => snapshot.Form; #if DEBUG internal static bool SawDeathRightHeldWeaponSuppressed { get; private set; } internal static void ResetDebugMetrics() => SawDeathRightHeldWeaponSuppressed = false; #endif internal bool IsBloodSiphonActive => configured && !ultimate && snapshot.Form == ReaperFormId.Blood && !specialReleased; public override string Texture => "Terraria/Images/Projectile_0"; public static int Spawn(Terraria.DataStructures.IEntitySource source, Player player, in SickleCombatSnapshot snapshot, Vector2 aim, bool ultimate) { if (Main.netMode == NetmodeID.MultiplayerClient) return -1; Vector2 requestedTarget = aim; Vector2 normalizedAim = requestedTarget.SafeNormalize(Vector2.UnitX * player.direction); int index = Projectile.NewProjectile(source, player.MountedCenter, normalizedAim, ModContent.ProjectileType(), 0, 0f, player.whoAmI); if (index < 0 || index >= Main.maxProjectiles) return -1; Projectile projectile = Main.projectile[index]; if (projectile.ModProjectile is ReaperActionControllerProjectile controller) controller.Configure(snapshot, requestedTarget, ultimate, player.Center); projectile.netUpdate = true; if (Main.netMode == NetmodeID.Server) { // This projectile is born inside a client request handler. Send its // first authoritative snapshot immediately after Configure so the // owner cannot miss the short-lived controller or keep retrying the // right-click while waiting for the regular projectile update pass. NetMessage.SendData(MessageID.SyncProjectile, -1, -1, null, index); } return index; } public override void SetDefaults() { Projectile.width = 2; Projectile.height = 2; Projectile.friendly = false; Projectile.hostile = false; Projectile.tileCollide = false; Projectile.ignoreWater = true; Projectile.penetrate = -1; Projectile.timeLeft = 360; // The controller now owns the held-weapon presentation for specials and // assigns itself to Player.heldProj every active tick. Keep it in the held // projectile draw layer, matching SickleSwingProjectile, instead of drawing // the weapon as an ordinary world projectile over unrelated entities. Projectile.hide = true; Projectile.netImportant = true; } public override bool ShouldUpdatePosition() => false; public override bool? CanDamage() => false; public override void AI() { if (Main.netMode == NetmodeID.Server && !serverAuthorized) { Projectile.Kill(); return; } if (!configured || Projectile.owner < 0 || Projectile.owner >= Main.maxPlayers) return; Player player = Main.player[Projectile.owner]; if (!player.active || player.dead || !ultimate && player.HeldItem.ModItem is not NormalSickle) { Projectile.Kill(); return; } // The owner can release Mouse2 before this server-owned projectile has // replicated to the client. In that ordering there was no local // controller for ProcessTriggers to release, so the late copy refreshed // timeLeft forever and blocked every later primary/special action. The // server remains authoritative for damage; this only guarantees that the // owning client's held presentation follows its real input lifecycle. bool debugSimulatedSpecialHeld = false; #if DEBUG debugSimulatedSpecialHeld = ReaperMultiplayerClientSelfTest .SimulatedSpecialHeld; #endif if (Main.netMode == NetmodeID.MultiplayerClient && Projectile.owner == Main.myPlayer && IsChargedSpecial && SoulHarvest.SpecialAttackKeybind?.Current != true && !Main.mouseRight && !debugSimulatedSpecialHeld) { RequestRelease(); } Projectile.Center = player.MountedCenter; if (UpdateDeathAssemblyPrelude(player)) return; timer++; if (ultimate) UpdateUltimate(player); else if (IsChargedSpecial) UpdateChargedSpecial(player); else UpdateSpecial(player); ApplyBaseDisplacement(player); ApplyInfernalDisplacement(player); if (Projectile.active) { UpdatePlayerPresentation(player); } SpawnAmbientVisuals(player); } public override bool PreDraw(ref Color lightColor) { if (Main.dedServ || !configured || Projectile.owner < 0 || Projectile.owner >= Main.maxPlayers) return false; Player player = Main.player[Projectile.owner]; if (!player.active) return false; Vector2 grip = player.MountedCenter; if (deathAssemblyCancelling || IsDeathAssemblyActive) { Texture2D assemblyTexture = ModContent.Request( ReaperCombatRegistry.GetTexturePath(snapshot.Form, snapshot.Stage)).Value; Vector2 normalizedAssemblyAnchor = ReaperCombatRegistry .GetHandleAnchor(snapshot.Form, snapshot.Stage); Vector2 assemblyAnchor = new( assemblyTexture.Width * normalizedAssemblyAnchor.X, assemblyTexture.Height * normalizedAssemblyAnchor.Y); SpriteEffects assemblyEffects = visualSwingDirection < 0 ? SpriteEffects.FlipVertically : SpriteEffects.None; if ((assemblyEffects & SpriteEffects.FlipVertically) != 0) assemblyAnchor.Y = assemblyTexture.Height - assemblyAnchor.Y; float assemblyRotation = visualWeaponAngle + ReaperCombatRegistry.GetTextureRotationCorrection( snapshot.Form, snapshot.Stage); int actionSeed = Projectile.identity * 43 + 1709; if (deathAssemblyCancelling) { DeathSickleAssemblyTextureSystem.DrawCancellation( assemblyTexture, grip, assemblyRotation, assemblyAnchor, ReaperCombatRegistry.DeathWeaponDrawScale, assemblyEffects, lightColor, deathAssemblyCancelStartTimer, deathAssemblyCancelTimer, snapshot.AssemblyFrames, actionSeed, largeDeathLayout: true); } else { DeathSickleAssemblyTextureSystem.Draw(assemblyTexture, grip, assemblyRotation, assemblyAnchor, ReaperCombatRegistry.DeathWeaponDrawScale, assemblyEffects, lightColor, deathAssemblyTimer, snapshot.AssemblyFrames, actionSeed, largeDeathLayout: true); } return false; } Color primary = ReaperCombatRegistry.GetPrimaryColor(snapshot.Form) with { A = 0 }; Color secondary = ReaperCombatRegistry.GetSecondaryColor(snapshot.Form) with { A = 0 }; if (!ultimate && IsChargedSpecial && !specialReleased) DrawChargeSeal(grip, primary, secondary); if (!ultimate) DrawSpecialStoryboard(player, grip); else if (snapshot.Form == ReaperFormId.Infernal) DrawInfernalUltimateRoute(TextureAssets.MagicPixel.Value); if (!ultimate && snapshot.Form == ReaperFormId.Death) { #if DEBUG SawDeathRightHeldWeaponSuppressed = true; #endif return false; } Texture2D texture = ModContent.Request( ReaperCombatRegistry.GetTexturePath(snapshot.Form, snapshot.Stage)).Value; Vector2 normalizedAnchor = ReaperCombatRegistry.GetHandleAnchor(snapshot.Form, snapshot.Stage); Vector2 anchor = new(texture.Width * normalizedAnchor.X, texture.Height * normalizedAnchor.Y); float scale = snapshot.Form == ReaperFormId.Death ? ReaperCombatRegistry.DeathWeaponDrawScale : 0.9f + snapshot.StageNumber * 0.045f; float correctedAngle = visualWeaponAngle + ReaperCombatRegistry.GetTextureRotationCorrection(snapshot.Form, snapshot.Stage); SpriteEffects weaponEffects = visualSwingDirection < 0 ? SpriteEffects.FlipVertically : SpriteEffects.None; if ((weaponEffects & SpriteEffects.FlipVertically) != 0) anchor.Y = texture.Height - anchor.Y; // Motionless casts do not receive decorative weapon echoes: those would // read as a second swing even when the held weapon itself stays still. int heldEchoCount = (ultimate && snapshot.Form == ReaperFormId.Void) || (!ultimate && snapshot.Form == ReaperFormId.Death) ? 0 : snapshot.Form == ReaperFormId.Soul ? 3 + snapshot.StageNumber : 2; for (int echo = heldEchoCount; echo >= 1; echo--) { float offset = (0.018f + visualCharge * 0.014f) * echo; Color echoColor = snapshot.Form == ReaperFormId.Soul ? Color.Lerp(primary, secondary, echo / (float)(heldEchoCount + 1)) : primary; Main.EntitySpriteDraw(texture, grip - Main.screenPosition, null, echoColor * (snapshot.Form == ReaperFormId.Soul ? 0.18f / Math.Max(1f, echo * 0.72f) : 0.12f / echo), correctedAngle - offset, anchor, scale * (1f + echo * 0.018f), weaponEffects); } Main.EntitySpriteDraw(texture, grip - Main.screenPosition, null, lightColor, correctedAngle, anchor, scale, weaponEffects); return false; } public override void SendExtraAI(BinaryWriter writer) { writer.Write(configured); if (!configured) return; snapshot.Write(writer); writer.WriteVector2(aim); writer.WriteVector2(specialTargetOffset); writer.WriteVector2(ultimateFocusWorld); writer.Write(ultimate); writer.Write(Math.Max(0, timer)); writer.Write(Math.Max(0, chargeFrames)); writer.Write(Math.Max(0, releaseTimer)); writer.Write(releaseRequested); writer.Write(specialReleased); writer.Write(bloodOvercharged); writer.Write(Math.Max(0, deathInvocationCooldown)); writer.Write(Math.Max(0, deathInvocationCount)); writer.Write(Math.Max(0, deathAssemblyTimer)); writer.Write(deathAssemblyCancelling); writer.Write(Math.Max(0, deathAssemblyCancelStartTimer)); writer.Write(Math.Max(0, deathAssemblyCancelTimer)); WritePath(writer, baseDashReady, baseDashStart, baseDashEnd); for (int index = 0; index < infernalDashReady.Length; index++) WritePath(writer, infernalDashReady[index], infernalDashStarts[index], infernalDashEnds[index]); } public override void ReceiveExtraAI(BinaryReader reader) { bool wasConfigured = configured; bool incomingConfigured = reader.ReadBoolean(); if (!incomingConfigured) { if (Main.netMode != NetmodeID.Server) configured = false; return; } SickleCombatSnapshot incomingSnapshot = SickleCombatSnapshot.Read(reader); Vector2 incomingAim = reader.ReadVector2().SafeNormalize(Vector2.UnitX); Vector2 incomingTargetOffset = reader.ReadVector2(); Vector2 incomingUltimateFocusWorld = reader.ReadVector2(); bool incomingUltimate = reader.ReadBoolean(); int incomingTimer = Math.Max(0, reader.ReadInt32()); int incomingChargeFrames = Math.Max(0, reader.ReadInt32()); int incomingReleaseTimer = Math.Max(0, reader.ReadInt32()); bool incomingReleaseRequested = reader.ReadBoolean(); bool incomingSpecialReleased = reader.ReadBoolean(); bool incomingBloodOvercharged = reader.ReadBoolean(); int incomingDeathInvocationCooldown = Math.Max(0, reader.ReadInt32()); int incomingDeathInvocationCount = Math.Max(0, reader.ReadInt32()); int incomingDeathAssemblyTimer = Math.Max(0, reader.ReadInt32()); bool incomingDeathAssemblyCancelling = reader.ReadBoolean(); int incomingDeathAssemblyCancelStartTimer = Math.Max(0, reader.ReadInt32()); int incomingDeathAssemblyCancelTimer = Math.Max(0, reader.ReadInt32()); ReadPath(reader, out bool incomingBaseReady, out Vector2 incomingBaseStart, out Vector2 incomingBaseEnd); bool[] incomingInfernalReady = new bool[infernalDashReady.Length]; Vector2[] incomingInfernalStarts = new Vector2[infernalDashStarts.Length]; Vector2[] incomingInfernalEnds = new Vector2[infernalDashEnds.Length]; for (int index = 0; index < incomingInfernalReady.Length; index++) { ReadPath(reader, out incomingInfernalReady[index], out incomingInfernalStarts[index], out incomingInfernalEnds[index]); } if (Main.netMode == NetmodeID.Server) return; configured = true; snapshot = incomingSnapshot; aim = incomingAim; specialTargetOffset = incomingTargetOffset; ultimateFocusWorld = incomingUltimateFocusWorld; ultimate = incomingUltimate; timer = incomingTimer; chargeFrames = incomingChargeFrames; releaseTimer = incomingReleaseTimer; releaseRequested = incomingReleaseRequested; specialReleased = incomingSpecialReleased; bloodOvercharged = incomingBloodOvercharged; deathInvocationCooldown = incomingDeathInvocationCooldown; deathInvocationCount = incomingDeathInvocationCount; deathAssemblyTimer = incomingDeathAssemblyTimer; deathAssemblyCancelling = incomingDeathAssemblyCancelling; deathAssemblyCancelStartTimer = incomingDeathAssemblyCancelStartTimer; deathAssemblyCancelTimer = incomingDeathAssemblyCancelTimer; baseDashReady = incomingBaseReady; baseDashStart = incomingBaseStart; baseDashEnd = incomingBaseEnd; for (int index = 0; index < incomingInfernalReady.Length; index++) { infernalDashReady[index] = incomingInfernalReady[index]; infernalDashStarts[index] = incomingInfernalStarts[index]; infernalDashEnds[index] = incomingInfernalEnds[index]; } if (!wasConfigured) { // Initial replication establishes a floor: a late-joining client does // not burst-play every event which happened before it saw the action. previousVisualTimer = incomingTimer; previousVisualReleaseTimer = incomingReleaseTimer; visualClockInitialized = true; } } private static void WritePath(BinaryWriter writer, bool ready, Vector2 start, Vector2 end) { writer.Write(ready); if (!ready) return; writer.WriteVector2(start); writer.WriteVector2(end); } private static void ReadPath(BinaryReader reader, out bool ready, out Vector2 start, out Vector2 end) { ready = reader.ReadBoolean(); if (!ready) { start = Vector2.Zero; end = Vector2.Zero; return; } start = reader.ReadVector2(); end = reader.ReadVector2(); } private void Configure(in SickleCombatSnapshot value, Vector2 direction, bool isUltimate, Vector2 playerCenter) { snapshot = value; aim = direction.SafeNormalize(Vector2.UnitX); specialTargetOffset = direction; if (!float.IsFinite(specialTargetOffset.X) || !float.IsFinite(specialTargetOffset.Y)) specialTargetOffset = aim * 240f; float targetDistance = specialTargetOffset.Length(); if (targetDistance > 2000f) specialTargetOffset *= 2000f / targetDistance; else if (targetDistance < 2f) specialTargetOffset = aim * 240f; ultimateFocusWorld = playerCenter + specialTargetOffset; if (!float.IsFinite(ultimateFocusWorld.X) || !float.IsFinite(ultimateFocusWorld.Y)) { ultimateFocusWorld = playerCenter + aim * 240f; } ultimate = isUltimate; configured = true; timer = 0; chargeFrames = 0; releaseTimer = 0; releaseRequested = false; specialReleased = false; bloodOvercharged = false; bloodSiphonDamage = 0L; deathInvocationCooldown = 0; deathInvocationCount = 0; deathAssemblyTimer = 0; deathAssemblyCancelling = false; deathAssemblyCancelStartTimer = 0; deathAssemblyCancelTimer = 0; baseDashStart = Vector2.Zero; baseDashEnd = Vector2.Zero; baseDashReady = false; Array.Clear(infernalDashStarts); Array.Clear(infernalDashEnds); Array.Clear(infernalDashReady); playedVisualEvents = 0UL; previousVisualTimer = -1; previousVisualReleaseTimer = -1; visualClockInitialized = true; previousVisualWeaponAngle = 0f; visualSwingDirection = 1; visualDirectionInitialized = false; serverAuthorized = Main.netMode != NetmodeID.MultiplayerClient; Projectile.GetGlobalProjectile().AuthorizeServerReaperProjectile(Projectile); Projectile.timeLeft = isUltimate ? 300 : value.Form == ReaperFormId.Blood ? 760 : 160; } internal void RecordBloodSiphonDamage(int damageDone) { if (!configured || ultimate || snapshot.Form != ReaperFormId.Blood || specialReleased || damageDone <= 0) { return; } bloodSiphonDamage = Math.Min(int.MaxValue / 2L, bloodSiphonDamage + damageDone); } internal bool UpdateSpecialTarget(Vector2 cursorOffset) { if (!configured || ultimate || snapshot.Form != ReaperFormId.Death || specialReleased || !float.IsFinite(cursorOffset.X) || !float.IsFinite(cursorOffset.Y)) { return false; } float distance = cursorOffset.Length(); if (distance > 2000f) cursorOffset *= 2000f / distance; else if (distance < 2f) cursorOffset = aim * 240f; specialTargetOffset = cursorOffset; aim = cursorOffset.SafeNormalize(aim); Projectile.velocity = aim; Projectile.netUpdate = true; return true; } internal bool RequestRelease() { if (!configured || !IsChargedSpecial || specialReleased || releaseRequested) return false; releaseRequested = true; Projectile.netUpdate = true; if (Main.netMode == NetmodeID.Server) { NetMessage.SendData(MessageID.SyncProjectile, -1, -1, null, Projectile.whoAmI); } return true; } internal static bool RequestLocalOwnerRelease(Player player) { if (Main.netMode != NetmodeID.MultiplayerClient || player.whoAmI != Main.myPlayer) { return false; } int controllerType = ModContent.ProjectileType< ReaperActionControllerProjectile>(); bool releasedAny = false; foreach (Projectile projectile in Main.ActiveProjectiles) { if (projectile.owner != player.whoAmI || projectile.type != controllerType || projectile.ModProjectile is not ReaperActionControllerProjectile controller) { continue; } releasedAny |= controller.RequestRelease(); } return releasedAny; } public override void OnKill(int timeLeft) { if (Projectile.owner < 0 || Projectile.owner >= Main.maxPlayers) return; Player player = Main.player[Projectile.owner]; if (player.heldProj == Projectile.whoAmI) player.heldProj = -1; } private void UpdateChargedSpecial(Player player) { if (snapshot.Form == ReaperFormId.Death) { UpdateDeathInvocation(player); return; } if (!specialReleased) { if (snapshot.Form == ReaperFormId.Blood && timer == 1 && Main.netMode != NetmodeID.MultiplayerClient) { float siphonRadius = snapshot.Stage switch { ReaperStage.StageI => 270f, ReaperStage.StageII => 360f, _ => 460f }; ReaperBloodDrainFieldProjectile.Spawn(Projectile.GetSource_FromThis(), player.whoAmI, snapshot, siphonRadius, Projectile.identity); } int minimumCharge = snapshot.Form == ReaperFormId.Blood ? 8 : 0; int maximumCharge = snapshot.Form == ReaperFormId.Blood ? GetBloodMaximumChargeFrames() : 45; if (releaseRequested && chargeFrames >= minimumCharge) { BeginChargedRelease(player); return; } chargeFrames = Math.Min(maximumCharge, chargeFrames + 1); if (chargeFrames >= maximumCharge || releaseRequested && chargeFrames >= minimumCharge) BeginChargedRelease(player); return; } releaseTimer++; Vector2 origin = player.MountedCenter; switch (snapshot.Form) { case ReaperFormId.Blood: { if (releaseTimer == 6 && Main.netMode != NetmodeID.MultiplayerClient) ExecuteBloodRangeExecution(player); FinishChargedAt(34); break; } case ReaperFormId.Frost: { float total = MathHelper.Lerp(1.7f, 2.4f, MathHelper.Clamp(chargeFrames / 45f, 0f, 1f)); bool fullCharge = chargeFrames >= 45; float length = snapshot.Stage switch { ReaperStage.StageI => 220f, ReaperStage.StageII => 285f, _ => 370f }; float width = snapshot.Stage switch { ReaperStage.StageI => 31f, ReaperStage.StageII => 38f, _ => 48f }; Vector2 focus = origin + aim * (snapshot.Stage >= ReaperStage.StageIII ? 70f : 35f); ChargedStrikeAt(7, player, focus - aim.RotatedBy(-0.55f) * length * 0.5f, aim.RotatedBy(-0.55f), ReaperStrikeShape.Line, length, width, total * 0.5f, 0); ChargedStrikeAt(14, player, focus - aim.RotatedBy(0.55f) * length * 0.5f, aim.RotatedBy(0.55f), ReaperStrikeShape.Line, length, width, total * 0.5f, fullCharge ? 2 : 1); FinishChargedAt(snapshot.Stage >= ReaperStage.StageIII ? 30 : 26); break; } case ReaperFormId.Soul: { int circles = snapshot.Stage switch { ReaperStage.StageI => 2, ReaperStage.StageII => 3, _ => 4 }; float minimum = snapshot.Stage switch { ReaperStage.StageI => 1.5f, ReaperStage.StageII => 2.25f, _ => 3f }; float maximum = snapshot.Stage switch { ReaperStage.StageI => 2.3f, ReaperStage.StageII => 3.15f, _ => 4.2f }; float total = MathHelper.Lerp(minimum, maximum, MathHelper.Clamp(chargeFrames / 45f, 0f, 1f)); int fullChargePhaseOffset = chargeFrames >= 45 ? 8 : 0; int bladesPerBeat = chargeFrames >= 45 ? 2 : 1; for (int index = 0; index < circles; index++) { Vector2 launchDirection = aim.RotatedBy(index * MathHelper.TwoPi / circles); SoulVolleyAt(6 + index * 8, player, origin, launchDirection, total / circles, fullChargePhaseOffset + index, bladesPerBeat); } FinishChargedAt(14 + circles * 8); break; } } } private void BeginChargedRelease(Player player) { if (specialReleased) return; specialReleased = true; releaseTimer = 0; bloodOvercharged = snapshot.Form == ReaperFormId.Blood && snapshot.Stage >= ReaperStage.StageII && chargeFrames >= GetBloodMaximumChargeFrames(); Projectile.netUpdate = true; } private void ExecuteBloodRangeExecution(Player player) { float radius = snapshot.Stage switch { ReaperStage.StageI => 270f, ReaperStage.StageII => 360f, _ => 460f }; int maximumCharge = GetBloodMaximumChargeFrames(); float charge = MathHelper.Clamp((chargeFrames - 8f) / Math.Max(1f, maximumCharge - 8f), 0f, 1f); float length = MathHelper.Lerp(118f, 182f, snapshot.StageNumber / 3f) * MathHelper.Lerp(0.88f, 1.14f, charge); float width = 15f + snapshot.StageNumber * 4f + (bloodOvercharged ? 6f : 0f); int scarLifetime = ReaperBloodBladeProjectile.GetScarLifetime( Math.Max(1, (int)snapshot.Skill1)); int executionDamage = (int)Math.Clamp(bloodSiphonDamage, 0L, int.MaxValue / 2L); if (executionDamage <= 0) return; HashSet roots = []; int phase = 0; foreach (NPC npc in Main.ActiveNPCs) { if (!npc.CanBeChasedBy(Projectile) || Vector2.DistanceSquared(npc.Center, player.Center) > radius * radius) { continue; } int root = npc.realLife >= 0 ? npc.realLife : npc.whoAmI; if (!roots.Add(root)) continue; NPC target = Main.npc[root]; uint hash = unchecked((uint)(Projectile.identity * 16777619 + root * 486187739)); hash ^= hash >> 15; float offset = ((hash & 0xFFFFu) / 65535f - 0.5f) * 1.15f; Vector2 direction = aim.RotatedBy(offset).SafeNormalize(aim); Vector2 center = target.Center; ReaperBloodExecutionProjectile.Spawn(Projectile.GetSource_FromThis(), player.whoAmI, snapshot, target, direction, length, width, executionDamage, phase, Projectile.identity); ReaperBloodScarProjectile.Spawn(Projectile.GetSource_FromThis(), player.whoAmI, snapshot, center, direction, length * 1.12f, width * 0.78f, scarLifetime, 0f, phase, Projectile.identity, exactDamage: Math.Max(1, executionDamage / 2)); phase++; } } private int GetBloodMaximumChargeFrames() { int level = Math.Clamp(Math.Max(1, (int)snapshot.Skill1), 1, 3); return level switch { 1 => 240, 2 => 420, _ => 600 }; } private void UpdateDeathInvocation(Player player) { Projectile.timeLeft = 2; if (specialReleased) { releaseTimer++; if (releaseTimer >= 8) Projectile.Kill(); return; } if (releaseRequested && deathInvocationCount > 0) { specialReleased = true; releaseTimer = 0; Projectile.netUpdate = true; return; } chargeFrames = Math.Min(int.MaxValue / 4, chargeFrames + 1); if (Main.netMode == NetmodeID.MultiplayerClient) return; deathInvocationCooldown--; if (deathInvocationCooldown > 0) return; // Reach the configured held-invocation cap in roughly 2.5 seconds. The // primary chain owns a separate, higher cap from the same upgrade node. float acceleration = MathHelper.Clamp(chargeFrames / 150f, 0f, 1f); acceleration = acceleration * acceleration * (3f - 2f * acceleration); float invocationTempo = MathHelper.Lerp(1f, snapshot.DeathMaximumTempo, acceleration); deathInvocationCooldown = Math.Clamp((int)Math.Round( 12f / Math.Max(1f, invocationTempo)), 4, 12); int invocation = deathInvocationCount; float orbitAngle = invocation * 2.3999632f + Projectile.identity * 0.071f; Vector2 summonPosition = player.Center + orbitAngle.ToRotationVector2() * ((120f + invocation % 3 * 34f) * snapshot.DeathRangeMultiplier); Vector2 cursorFocus = player.Center + specialTargetOffset; NPC? target = FindDeathInvocationTarget(cursorFocus, snapshot.DeathRangeMultiplier); Vector2 strikeFocus = target?.Center ?? cursorFocus; int echoIndex = ReaperDeathEchoScytheProjectile.Spawn( Projectile.GetSource_FromThis(), player.whoAmI, snapshot, invocation % 6, summonPosition, strikeFocus, Projectile.identity * 4096 + invocation); if (echoIndex < 0 || echoIndex >= Main.maxProjectiles) { // A released click still owes one actual attack. If the projectile // pool is momentarily full, keep the invocation uncommitted and // retry next tick instead of treating a missing echo as success. deathInvocationCooldown = 1; return; } deathInvocationCount++; Projectile.netUpdate = true; } private bool UpdateDeathAssemblyPrelude(Player player) { if (!HasDeathAssemblyPrelude) return false; if (deathAssemblyCancelling) { UpdateDeathAssemblyCancellation(player); return true; } if (releaseRequested && deathAssemblyTimer < snapshot.AssemblyFrames) { deathAssemblyCancelling = true; deathAssemblyCancelStartTimer = deathAssemblyTimer; deathAssemblyCancelTimer = 0; Projectile.netUpdate = true; UpdateDeathAssemblyCancellation(player); return true; } if (deathAssemblyTimer >= snapshot.AssemblyFrames) return false; deathAssemblyTimer++; Projectile.timeLeft = 2; float aimAngle = aim.ToRotation(); int facing = Math.Abs(aim.X) > 0.05f ? Math.Sign(aim.X) : player.direction; if (facing == 0) facing = 1; player.ChangeDir(facing); visualWeaponAngle = aimAngle - 1.45f * facing; visualSwingDirection = facing; visualDirectionInitialized = true; previousVisualWeaponAngle = visualWeaponAngle; visualCharge = 0f; Projectile.Center = player.MountedCenter; player.heldProj = Projectile.whoAmI; player.itemTime = Math.Max(player.itemTime, 2); player.itemAnimation = Math.Max(player.itemAnimation, 2); player.SetCompositeArmFront(true, Player.CompositeArmStretchAmount.Full, visualWeaponAngle - MathHelper.PiOver2); player.itemRotation = MathHelper.WrapAngle(visualWeaponAngle); if (deathAssemblyTimer == snapshot.AssemblyFrames) { Projectile.netUpdate = true; } return true; } private void UpdateDeathAssemblyCancellation(Player player) { deathAssemblyCancelTimer++; Projectile.timeLeft = 2; float aimAngle = aim.ToRotation(); int facing = Math.Abs(aim.X) > 0.05f ? Math.Sign(aim.X) : player.direction; if (facing == 0) facing = 1; player.ChangeDir(facing); visualWeaponAngle = aimAngle - 1.45f * facing; visualSwingDirection = facing; visualDirectionInitialized = true; previousVisualWeaponAngle = visualWeaponAngle; visualCharge = 0f; Projectile.Center = player.MountedCenter; player.heldProj = Projectile.whoAmI; player.itemTime = Math.Max(player.itemTime, 2); player.itemAnimation = Math.Max(player.itemAnimation, 2); player.SetCompositeArmFront(true, Player.CompositeArmStretchAmount.Full, visualWeaponAngle - MathHelper.PiOver2); player.itemRotation = MathHelper.WrapAngle(visualWeaponAngle); if (deathAssemblyCancelTimer >= ReaperDefinitions.AssemblyCancelFrames) Projectile.Kill(); } private static NPC? FindDeathInvocationTarget(Vector2 cursorFocus, float rangeMultiplier) { NPC? nearest = null; // Only snap to an enemy genuinely close to the cursor. The right-click // destination remains the cursor itself rather than silently selecting a // distant target and making the summoned scythes appear stationary. float targetingRadius = 320f * MathHelper.Clamp(rangeMultiplier, 0.5f, 1f); float best = targetingRadius * targetingRadius; foreach (NPC npc in Main.ActiveNPCs) { if (!npc.CanBeChasedBy()) continue; float distance = Vector2.DistanceSquared(npc.Center, cursorFocus); if (distance >= best) continue; best = distance; nearest = npc; } return nearest; } private static bool TryPayBloodOvercharge(Player player) { if (Main.netMode == NetmodeID.MultiplayerClient || player.dead) return false; int sacrifice = Math.Max(1, (int)Math.Ceiling(player.statLifeMax2 * 0.08f)); if (player.statLife <= sacrifice) return false; player.statLife -= sacrifice; CombatText.NewText(player.Hitbox, new Color(220, 25, 65), sacrifice, dramatic: true); if (Main.netMode == NetmodeID.Server) NetMessage.SendData(MessageID.PlayerLifeMana, -1, -1, null, player.whoAmI); return true; } private void ChargedStrikeAt(int eventTick, Player player, Vector2 origin, Vector2 direction, ReaperStrikeShape shape, float length, float width, float multiplier, int phase) { if (releaseTimer != eventTick || Main.netMode == NetmodeID.MultiplayerClient) return; ReaperStrikeProjectile.Spawn(Projectile.GetSource_FromThis(), player.whoAmI, snapshot, ReaperHitKind.Special, phase, origin, direction, shape, length, width, multiplier, actionId: Projectile.identity); } private void FinishChargedAt(int finishTick) { if (releaseTimer >= finishTick) Projectile.Kill(); } private void UpdateSpecial(Player player) { Vector2 origin = player.MountedCenter; switch (snapshot.Form) { case ReaperFormId.Base: if (timer == BaseDashStartTick) StartBaseDash(player); Vector2 baseOrigin = baseDashReady ? baseDashEnd : origin; StrikeAt(11, player, baseOrigin - aim * 18f, aim.RotatedBy(-0.48f), ReaperStrikeShape.Line, 175f, 29f, 1.65f, 0); FinishAt(28); break; case ReaperFormId.Bone: { Vector2 focus = origin + aim * (snapshot.Stage >= ReaperStage.StageIII ? 155f : 125f); Vector2 guillotineDirection = Vector2.UnitY; StrikeAt(12, player, focus - guillotineDirection * 120f, guillotineDirection, ReaperStrikeShape.Line, 240f, 31f, 1.8f, 0); if (snapshot.Stage >= ReaperStage.StageII) StrikeAt(22, player, focus, aim, ReaperStrikeShape.Circle, 105f, 25f, 0.9f, 1); if (snapshot.Stage >= ReaperStage.StageIII) StrikeAt(32, player, focus, aim, ReaperStrikeShape.Cross, 150f, 33f, 0.9f, 2); FinishAt(snapshot.Stage >= ReaperStage.StageIII ? 44 : snapshot.Stage >= ReaperStage.StageII ? 34 : 25); break; } case ReaperFormId.Infernal: if (snapshot.Stage >= ReaperStage.StageIII) { if (timer == 2) StartInfernalDash(player, 0, aim, 280f); if (timer == 15) { float side = player.direction == 0 ? 1f : player.direction; StartInfernalDash(player, 1, aim.RotatedBy(2.25f * side), 250f); } if (timer == 29) { float side = player.direction == 0 ? 1f : player.direction; StartInfernalDash(player, 2, aim.RotatedBy(-2.25f * side), 290f); } StrikeInfernalPathAt(3, player, 0, 31f, 2f); StrikeInfernalPathAt(16, player, 1, 28f, 1.3f); StrikeInfernalPathAt(30, player, 2, 34f, 1.8f); FinishAt(42); } else { if (timer == 2) StartInfernalDash(player, 0, aim, 300f); StrikeInfernalPathAt(3, player, 0, 30f, 2.2f); if (snapshot.Stage >= ReaperStage.StageII && timer == 19) { float side = player.direction == 0 ? 1f : player.direction; StartInfernalDash(player, 1, (-aim).RotatedBy(0.52f * side), 245f); } if (snapshot.Stage >= ReaperStage.StageII) StrikeInfernalPathAt(20, player, 1, 28f, 1.4f); int finish = snapshot.Stage >= ReaperStage.StageII ? 32 : 18; FinishAt(finish); } break; case ReaperFormId.Void: if (timer == VoidSpecialTeleportTick) ExecuteVoidTeleportSpecial(player); // Keep the replicated controller alive long enough for clients to // receive and render the cast. Killing it on the same tick as the // server-side teleport made Void the only special with no held // weapon, sound or opening presentation in multiplayer. FinishAt(VoidSpecialVisualEndTick); break; } } private void UpdateUltimate(Player player) { int visualDuration = snapshot.Form switch { ReaperFormId.Bone => 216, ReaperFormId.Blood => 210, ReaperFormId.Infernal => 204, ReaperFormId.Frost => 120, ReaperFormId.Soul => 116, ReaperFormId.Void => VoidUltimateBlackHoleTick, ReaperFormId.Death => ReaperDeathUltimateGeometry.Duration, _ => 1 }; Vector2 origin = player.MountedCenter; Vector2 focus = snapshot.Form switch { // Death locks the selected point in world space at cast start. Player // movement and camera movement must not drag the cut intersection. ReaperFormId.Death => ultimateFocusWorld, ReaperFormId.Void => player.Center + specialTargetOffset, _ => origin + aim * 180f }; ReaperUltimateVisualSystem.ReportUltimate(player, snapshot.Form, aim, focus, Projectile.identity, timer, visualDuration); switch (snapshot.Form) { case ReaperFormId.Bone: foreach (int tick in new[] { 30, 66, 102, 138 }) StrikeAt(tick, player, focus, aim, ReaperStrikeShape.Circle, 82f, 24f, 1.25f, tick); StrikeAt(168, player, focus, aim, ReaperStrikeShape.Circle, 150f, 34f, 2.5f, 4); StrikeAt(207, player, origin, aim, ReaperStrikeShape.Line, 520f, 52f, 4f, 5); FinishAt(216); break; case ReaperFormId.Blood: if (timer == 18 && Main.netMode != NetmodeID.MultiplayerClient) { for (int index = 0; index < 6; index++) { Vector2 launch = aim.RotatedBy(index * MathHelper.TwoPi / 6f); ReaperBloodBladeProjectile.Spawn(Projectile.GetSource_FromThis(), player.whoAmI, snapshot, origin + launch * 54f, launch * 14f, index, Projectile.identity, ReaperHitKind.Ultimate, damageMultiplier: 0.75f, maximumPasses: 4, scarLifetime: ReaperBloodBladeProjectile.GetScarLifetime( Math.Max(1, (int)snapshot.Skill1))); } } if (timer == 166 && Main.netMode != NetmodeID.MultiplayerClient) ExecuteBloodUltimateFinal(player); FinishAt(210); break; case ReaperFormId.Infernal: for (int index = 0; index < 5; index++) { int eventTick = 20 + index * 20; if (timer == eventTick) { float side = aim.X < -0.01f ? -1f : 1f; Vector2 route = aim.RotatedBy(index * MathHelper.Pi * 0.8f * side); StartInfernalDash(player, index, route, 250f); } StrikeInfernalPathAt(eventTick, player, index, 29f, 1.25f); } StrikeAt(130, player, focus, aim, ReaperStrikeShape.Cross, 230f, 40f, 2.25f, 5); StrikeAt(190, player, focus, aim, ReaperStrikeShape.Circle, 225f, 54f, 3.5f, 6); FinishAt(204); break; case ReaperFormId.Frost: StrikeAt(1, player, focus, aim, ReaperStrikeShape.Circle, 230f, 25f, 1.2f, 0); for (int index = 0; index < 4; index++) StrikeAt(30 + index * 18, player, focus, aim.RotatedBy(index * MathHelper.PiOver2), ReaperStrikeShape.Line, 230f, 24f, 0.8f, index + 1); StrikeAt(100, player, focus, aim.RotatedBy(-0.5f), ReaperStrikeShape.Line, 330f, 45f, 3f, 5); StrikeAt(110, player, focus, aim.RotatedBy(0.5f), ReaperStrikeShape.Line, 330f, 45f, 3f, 6); FinishAt(120); break; case ReaperFormId.Soul: StrikeAt(20, player, focus, aim, ReaperStrikeShape.Circle, 180f, 28f, 1.2f, 0); for (int index = 0; index < 8; index++) StrikeAt(32 + index * 8, player, focus + aim.RotatedBy(index * MathHelper.PiOver4) * 45f, aim.RotatedBy(index * MathHelper.PiOver4), ReaperStrikeShape.Line, 190f, 22f, 0.8f, index + 1); StrikeAt(104, player, focus, aim, ReaperStrikeShape.Circle, 245f, 48f, 4f, 9); FinishAt(116); break; case ReaperFormId.Void: if (timer == VoidUltimateBlackHoleTick && Main.netMode != NetmodeID.MultiplayerClient) { int level = Math.Clamp(Math.Max(1, (int)snapshot.Skill3), 1, 3); Vector2 target = player.Center + specialTargetOffset; ReaperVoidBlackHoleProjectile.Spawn(Projectile.GetSource_FromThis(), player.whoAmI, snapshot, target, 160f + level * 24f, ReaperVoidArcRiftProjectile.GetLifetime(level), 520f + level * 45f, 0.34f, Projectile.identity); } // Creating the black-hole tear is the end of the cast. The // persistent projectile owns everything that follows. FinishAt(VoidUltimateBlackHoleTick); break; case ReaperFormId.Death: for (int index = 0; index < ReaperDeathUltimateGeometry.CutCount; index++) StrikeDeathWorldCutAt(ReaperDeathUltimateGeometry.GetCutTick(index), player, focus, index); if (timer == ReaperDeathUltimateGeometry.ShatterTick && Main.netMode != NetmodeID.MultiplayerClient) ExecuteDeathUltimateShatter(player); FinishAt(ReaperDeathUltimateGeometry.Duration); break; default: Projectile.Kill(); break; } } private void StrikeAt(int eventTick, Player player, Vector2 origin, Vector2 direction, ReaperStrikeShape shape, float length, float width, float multiplier, int phase) { if (timer != eventTick || Main.netMode == NetmodeID.MultiplayerClient) return; ReaperStrikeProjectile.Spawn(Projectile.GetSource_FromThis(), player.whoAmI, snapshot, ultimate ? ReaperHitKind.Ultimate : ReaperHitKind.Special, phase, origin, direction, shape, length, width, multiplier, actionId: Projectile.identity); } private void StrikeDeathWorldCutAt(int eventTick, Player player, Vector2 focus, int cutIndex) { if (timer != eventTick || Main.netMode == NetmodeID.MultiplayerClient) return; Vector2 axis = ReaperDeathUltimateGeometry.GetCutAxis( Projectile.identity, cutIndex); Vector2 start = focus - axis * (ReaperDeathUltimateGeometry.CutLength * 0.5f); ReaperStrikeProjectile.Spawn(Projectile.GetSource_FromThis(), player.whoAmI, snapshot, ReaperHitKind.Ultimate, cutIndex, start, axis, ReaperStrikeShape.Line, ReaperDeathUltimateGeometry.CutLength, ReaperDeathUltimateGeometry.CutCollisionWidth, 0.28f, actionId: Projectile.identity); } private void SoulVolleyAt(int eventTick, Player player, Vector2 origin, Vector2 direction, float multiplier, int phase, int bladeCount) { if (releaseTimer != eventTick || Main.netMode == NetmodeID.MultiplayerClient) return; bladeCount = Math.Clamp(bladeCount, 1, 2); float perBladeMultiplier = multiplier / bladeCount; Vector2 normal = direction.RotatedBy(MathHelper.PiOver2); for (int blade = 0; blade < bladeCount; blade++) { float centered = blade - (bladeCount - 1) * 0.5f; Vector2 launch = direction.RotatedBy(centered * 0.16f); SoulSickleProjectile.Spawn(Projectile.GetSource_FromThis(), player.whoAmI, snapshot, origin + normal * centered * 24f + launch * 32f, launch * (15f + snapshot.StageNumber), phase, Projectile.identity, ReaperHitKind.Special, perBladeMultiplier, variant: phase * 2 + blade); } } private void ExecuteDeathUltimateShatter(Player player) { HashSet processedRoots = []; bool centerVisualSpawned = false; foreach (NPC npc in Main.ActiveNPCs) { int root = npc.realLife >= 0 ? npc.realLife : npc.whoAmI; if (!processedRoots.Add(root) || root < 0 || root >= Main.maxNPCs) continue; NPC target = Main.npc[root]; if (!target.active) continue; ReaperCombatGlobalNPC status = target.GetGlobalNPC(); int storedDamage = status.ConsumeDeathUltimateDamage(target, player.whoAmI, Projectile.identity); if (storedDamage <= 0) continue; int shatterIndex = ReaperDeathShatterProjectile.Spawn( Projectile.GetSource_FromThis(), player.whoAmI, snapshot, target, storedDamage, Projectile.identity, ultimateFocusWorld, drawCenterVisual: !centerVisualSpawned); centerVisualSpawned |= shatterIndex >= 0; } } private void ExecuteBloodUltimateFinal(Player player) { NPC? target = ReaperProjectileHelper.FindTarget(Projectile, 820f); Vector2 focus = target?.Center ?? player.Center + aim * 260f; Vector2 approach = (focus - player.Center).SafeNormalize(aim); if (target is not null) { float distance = Vector2.Distance(player.Center, target.Center) + 92f; ReaperMovementHelper.MoveAlong(player, approach, distance, out _, out _); } Vector2 scarDirection = approach.RotatedBy(-0.18f * (player.direction == 0 ? 1 : player.direction)); ReaperBloodScarProjectile.Spawn(Projectile.GetSource_FromThis(), player.whoAmI, snapshot, focus, scarDirection, 1180f, 74f, ReaperBloodBladeProjectile.GetScarLifetime(Math.Max(1, (int)snapshot.Skill1)), 0.26f, 63, Projectile.identity); } private void StartInfernalDash(Player player, int phase, Vector2 direction, float distance) { if (phase < 0 || phase >= infernalDashReady.Length) return; if (Main.netMode == NetmodeID.MultiplayerClient) return; float acceptedDistance = ReaperMovementHelper.TraceAlong( player, direction, distance, out Vector2 start, out Vector2 end); infernalDashStarts[phase] = start; infernalDashEnds[phase] = end; infernalDashReady[phase] = acceptedDistance > 0f; Projectile.netUpdate = true; } private void ApplyInfernalDisplacement(Player player) { if (snapshot.Form != ReaperFormId.Infernal || Main.netMode == NetmodeID.MultiplayerClient && Projectile.owner != Main.myPlayer) { return; } int phase = -1; int travelFrames = ultimate ? 10 : snapshot.Stage >= ReaperStage.StageIII ? 9 : 10; for (int index = infernalDashReady.Length - 1; index >= 0; index--) { int candidateStart = ultimate ? 20 + index * 20 : snapshot.Stage >= ReaperStage.StageIII ? index switch { 0 => 2, 1 => 15, 2 => 29, _ => int.MaxValue } : index switch { 0 => 2, 1 => 19, _ => int.MaxValue }; if (candidateStart == int.MaxValue || timer < candidateStart || timer >= candidateStart + travelFrames || !infernalDashReady[index]) { continue; } phase = index; break; } if (phase < 0) return; Vector2 path = infernalDashEnds[phase] - infernalDashStarts[phase]; Vector2 velocity = path / travelFrames; if (!float.IsFinite(velocity.X) || !float.IsFinite(velocity.Y)) return; Vector2 candidate = player.position + velocity; if (!Collision.SolidCollision(candidate, player.width, player.height)) player.velocity = velocity; } private void ExecuteVoidTeleportSpecial(Player player) { if (Main.netMode == NetmodeID.MultiplayerClient) return; Vector2 start = player.Center; Vector2 requestedTarget = start + specialTargetOffset; bool teleported = ReaperMovementHelper.TeleportToTarget(player, requestedTarget, 2000f, out start, out Vector2 end); // A blocked or very close cursor destination must not turn the whole // special into a silent no-op. Keep the server-authoritative movement // safety rule, but still tear the selected world-space path when there is // nowhere safe to place the player. if (!teleported) { Vector2 fallback = requestedTarget - start; float fallbackLength = Math.Min(2000f, fallback.Length()); Vector2 fallbackAxis = fallback.SafeNormalize(aim); if (fallbackLength <= 4f) fallbackLength = 160f; end = start + fallbackAxis * fallbackLength; } Vector2 path = end - start; float length = path.Length(); if (length <= 4f) return; Vector2 axis = path / length; float strikeWidth = 24f + snapshot.StageNumber * 6f; float strikeMultiplier = 1.45f + snapshot.StageNumber * 0.20f; ReaperStrikeProjectile.Spawn(Projectile.GetSource_FromThis(), player.whoAmI, snapshot, ReaperHitKind.Special, 0, start, axis, ReaperStrikeShape.Line, length, strikeWidth, strikeMultiplier, actionId: Projectile.identity); int level = Math.Clamp(Math.Max(1, (int)snapshot.Skill2), 1, 3); float riftWidth = 20f + snapshot.StageNumber * 5f + level * 4f; ReaperVoidRiftProjectile.Spawn(Projectile.GetSource_FromThis(), player.whoAmI, snapshot, (start + end) * 0.5f, axis, length, riftWidth, ReaperVoidArcRiftProjectile.GetLifetime(level), 0f, 0.20f, 0, Projectile.identity); } private void StartBaseDash(Player player) { if (Main.netMode == NetmodeID.MultiplayerClient) return; Vector2 direction = new Vector2(aim.X, Math.Min(-0.28f, aim.Y)).SafeNormalize(aim); float acceptedDistance = ReaperMovementHelper.TraceAlong( player, direction, 92f, out baseDashStart, out baseDashEnd); baseDashReady = acceptedDistance > 0f; Projectile.netUpdate = true; } private void ApplyBaseDisplacement(Player player) { if (ultimate || snapshot.Form != ReaperFormId.Base || !baseDashReady || Main.netMode == NetmodeID.MultiplayerClient && Projectile.owner != Main.myPlayer) { return; } int elapsed = timer - BaseDashStartTick; if (elapsed < 0 || elapsed > BaseDashTravelFrames) return; if (elapsed == BaseDashTravelFrames) { player.velocity = Vector2.Zero; return; } Vector2 velocity = (baseDashEnd - baseDashStart) / BaseDashTravelFrames; if (!float.IsFinite(velocity.X) || !float.IsFinite(velocity.Y)) return; Vector2 candidate = player.position + velocity; if (!Collision.SolidCollision(candidate, player.width, player.height)) player.velocity = velocity; else player.velocity = Vector2.Zero; } private void StrikeInfernalPathAt(int eventTick, Player player, int phase, float width, float multiplier) { if (timer != eventTick || Main.netMode == NetmodeID.MultiplayerClient || phase < 0 || phase >= infernalDashReady.Length) return; Vector2 start = infernalDashReady[phase] ? infernalDashStarts[phase] : player.MountedCenter; Vector2 path = infernalDashReady[phase] ? infernalDashEnds[phase] - start : aim; float length = Math.Max(8f, path.Length()); ReaperStrikeProjectile.Spawn(Projectile.GetSource_FromThis(), player.whoAmI, snapshot, ultimate ? ReaperHitKind.Ultimate : ReaperHitKind.Special, phase, start, path, ReaperStrikeShape.Line, length, width, multiplier, actionId: Projectile.identity); ReaperInfernalTrailProjectile.Spawn(Projectile.GetSource_FromThis(), player.whoAmI, snapshot, start, start + path.SafeNormalize(aim) * length, Math.Max(9f, width * 0.72f), phase, Projectile.identity); } private void FinishAt(int finishTick) { if (timer >= finishTick) Projectile.Kill(); } private void UpdatePlayerPresentation(Player player) { float aimAngle = aim.ToRotation(); int facing = Math.Abs(aim.X) > 0.05f ? Math.Sign(aim.X) : player.direction; if (facing == 0) facing = 1; player.ChangeDir(facing); visualCharge = 0f; if (ultimate) { if (snapshot.Form == ReaperFormId.Death) { if (timer < 20) { visualWeaponAngle = aimAngle - 2.25f * facing; } else if (timer < 128) { int cut = Math.Clamp((timer - 20) / 6, 0, 17); float local = MathHelper.Clamp(((timer - 20) % 6) / 5f, 0f, 1f); int direction = (cut & 1) == 0 ? facing : -facing; visualWeaponAngle = MathHelper.Lerp( aimAngle - 2.35f * direction, aimAngle + 2.15f * direction, SmoothStep(local)); } else { visualWeaponAngle = aimAngle - 1.35f * facing; } } else if (snapshot.Form == ReaperFormId.Void) { // The black hole supplies the motion; the held scythe remains // poised instead of using the shared ultimate spin animation. visualWeaponAngle = aimAngle - 1.65f * facing; } else { float sweep = timer * 0.085f * facing; visualWeaponAngle = aimAngle - 1.65f * facing + sweep; } } else if (IsChargedSpecial) { int maximumCharge = snapshot.Form == ReaperFormId.Blood ? GetBloodMaximumChargeFrames() : snapshot.Form == ReaperFormId.Death ? 600 : 45; visualCharge = MathHelper.Clamp(chargeFrames / (float)maximumCharge, 0f, 1f); if (!specialReleased) { float drawBack = snapshot.Form switch { ReaperFormId.Blood => -2.25f, ReaperFormId.Frost => -1.82f, ReaperFormId.Soul => -2.7f, ReaperFormId.Death => -1.45f, _ => -2f }; float tremor = snapshot.Form != ReaperFormId.Death && visualCharge >= 0.98f ? (float)Math.Sin(Main.GlobalTimeWrappedHourly * 48f) * 0.025f : 0f; visualWeaponAngle = aimAngle + (drawBack + tremor) * facing; } else { int releaseDuration = snapshot.Form switch { ReaperFormId.Blood => snapshot.Stage >= ReaperStage.StageIII ? 32 : 26, ReaperFormId.Frost => snapshot.Stage >= ReaperStage.StageIII ? 30 : 26, ReaperFormId.Soul => 14 + (snapshot.Stage switch { ReaperStage.StageI => 2, ReaperStage.StageII => 3, _ => 4 }) * 8, ReaperFormId.Death => 8, _ => 30 }; float progress = SmoothStep(MathHelper.Clamp(releaseTimer / (float)releaseDuration, 0f, 1f)); if (snapshot.Form == ReaperFormId.Soul) visualWeaponAngle = aimAngle - 2.7f * facing + progress * MathHelper.TwoPi * facing; else if (snapshot.Form == ReaperFormId.Death) visualWeaponAngle = aimAngle - 1.45f * facing; else visualWeaponAngle = MathHelper.Lerp(aimAngle - 2.25f * facing, aimAngle + 1.45f * facing, progress); } } else { visualWeaponAngle = GetInstantSpecialWeaponAngle(aimAngle, facing); } if (visualDirectionInitialized) { float delta = MathHelper.WrapAngle(visualWeaponAngle - previousVisualWeaponAngle); if (Math.Abs(delta) > 0.002f) visualSwingDirection = delta >= 0f ? 1 : -1; } else { visualDirectionInitialized = true; } previousVisualWeaponAngle = visualWeaponAngle; Projectile.Center = player.MountedCenter; player.heldProj = Projectile.whoAmI; player.itemTime = Math.Max(player.itemTime, 2); player.itemAnimation = Math.Max(player.itemAnimation, 2); player.SetCompositeArmFront(true, Player.CompositeArmStretchAmount.Full, visualWeaponAngle - MathHelper.PiOver2); player.itemRotation = MathHelper.WrapAngle(visualWeaponAngle); } private float GetInstantSpecialWeaponAngle(float aimAngle, int facing) { int duration = snapshot.Form switch { ReaperFormId.Base => 28, ReaperFormId.Bone => snapshot.Stage >= ReaperStage.StageIII ? 44 : snapshot.Stage >= ReaperStage.StageII ? 34 : 25, ReaperFormId.Infernal => snapshot.Stage >= ReaperStage.StageIII ? 42 : snapshot.Stage >= ReaperStage.StageII ? 32 : 18, ReaperFormId.Void => VoidSpecialVisualEndTick, ReaperFormId.Death => 50, _ => 30 }; float progress = MathHelper.Clamp(timer / (float)Math.Max(1, duration), 0f, 1f); switch (snapshot.Form) { case ReaperFormId.Infernal: { int segmentLength = snapshot.Stage >= ReaperStage.StageIII ? 14 : 16; int segment = Math.Max(0, (timer - 1) / segmentLength); float local = SmoothStep(((timer - 1) % segmentLength) / (float)segmentLength); int direction = segment % 2 == 0 ? facing : -facing; return MathHelper.Lerp(aimAngle - 1.55f * direction, aimAngle + 1.25f * direction, local); } case ReaperFormId.Void: return aimAngle - 1.65f * facing; case ReaperFormId.Death: { if (timer < 36) { int segment = Math.Max(0, (timer - 1) / 5); float local = SmoothStep(((timer - 1) % 5) / 5f); int direction = segment % 2 == 0 ? facing : -facing; return MathHelper.Lerp(aimAngle - 1.3f * direction, aimAngle + 1.3f * direction, local); } float finish = SmoothStep(MathHelper.Clamp((timer - 36f) / 14f, 0f, 1f)); return MathHelper.Lerp(aimAngle + 2.4f * facing, aimAngle - 1.2f * facing, finish); } default: return MathHelper.Lerp(aimAngle - 2.35f * facing, aimAngle + 1.25f * facing, SmoothStep(progress)); } } private void DrawSpecialStoryboard(Player player, Vector2 grip) { Texture2D pixel = TextureAssets.MagicPixel.Value; switch (snapshot.Form) { case ReaperFormId.Base: DrawBaseSpecial(pixel, grip); break; case ReaperFormId.Bone: DrawBoneSpecial(pixel, grip); break; case ReaperFormId.Blood: DrawBloodSpecial(pixel, grip); break; case ReaperFormId.Infernal: DrawInfernalSpecial(pixel, grip); break; case ReaperFormId.Frost: DrawFrostSpecial(pixel, grip); break; case ReaperFormId.Soul: DrawSoulSpecial(pixel, grip); break; case ReaperFormId.Void: DrawVoidSpecial(pixel, grip); break; case ReaperFormId.Death: DrawDeathSpecial(pixel, grip); break; } } private void DrawBaseSpecial(Texture2D pixel, Vector2 grip) { float opacity = WindowOpacity(timer, 2, 13, 11); if (opacity <= 0f) return; if (baseDashReady) { float dashProgress = SmoothStep(MathHelper.Clamp( (timer - BaseDashStartTick + 1f) / BaseDashTravelFrames, 0f, 1f)); Vector2 dashHead = Vector2.Lerp(baseDashStart, baseDashEnd, dashProgress); Vector2 axis = (baseDashEnd - baseDashStart) .SafeNormalize(aim); Vector2 normal = axis.RotatedBy(MathHelper.PiOver2); DrawVisualLine(pixel, baseDashStart, dashHead, new Color(7, 38, 50, 155) * opacity, 18f); DrawVisualLine(pixel, baseDashStart, dashHead, new Color(40, 185, 205, 0) * opacity * 0.62f, 7.5f); DrawVisualLine(pixel, baseDashStart, dashHead, new Color(205, 255, 250, 0) * opacity * 0.86f, 1.7f); for (int lane = -1; lane <= 1; lane += 2) { Vector2 offset = normal * lane * 7f; DrawVisualLine(pixel, baseDashStart + offset, dashHead + offset, new Color(75, 220, 230, 0) * opacity * 0.28f, 2.2f); } float ringReveal = SmoothStep(MathHelper.Clamp( (timer - BaseDashStartTick) / 5f, 0f, 1f)); DrawArc(pixel, baseDashStart, 15f + ringReveal * 18f, aim.ToRotation() + MathHelper.Pi * 0.62f, MathHelper.Pi * 0.76f, new Color(105, 235, 240, 0) * opacity * (1f - ringReveal) * 0.55f, 2.4f, 10); } float reveal = SmoothStep(MathHelper.Clamp((timer - 5f) / 8f, 0f, 1f)); float fade = 1f - MathHelper.Clamp((timer - 15f) / 10f, 0f, 1f); float startAngle = aim.ToRotation() - 1.42f; Vector2 center = grip - aim * 24f; DrawArc(pixel, center, 142f, startAngle, 2.45f * reveal, new Color(8, 45, 56, 155) * fade * 0.82f, 22f, 22); DrawArc(pixel, center, 143.5f, startAngle, 2.45f * reveal, new Color(35, 185, 205, 0) * fade * 0.58f, 9f, 22); DrawArc(pixel, center, 145f, startAngle, 2.45f * reveal, new Color(215, 255, 250, 0) * fade * 0.88f, 2.1f, 22); DrawArc(pixel, center, 119f, startAngle + 0.08f, 2.25f * reveal, new Color(70, 220, 230, 0) * fade * 0.24f, 3.4f, 20); } private void DrawBoneSpecial(Texture2D pixel, Vector2 grip) { int stage = Math.Max(1, snapshot.StageNumber); int finish = stage >= 3 ? 44 : stage == 2 ? 34 : 25; float opacity = 1f - MathHelper.Clamp((timer - (finish - 10f)) / 10f, 0f, 1f); float unfold = SmoothStep(MathHelper.Clamp(timer / 7f, 0f, 1f)); if (opacity <= 0f || unfold <= 0f) return; Vector2 focus = grip + aim * (stage >= 3 ? 155f : 125f); float halfWidth = (58f + stage * 22f) * unfold; float halfHeight = (105f + stage * 20f) * unfold; Color marrow = new Color(55, 225, 245, 0) * opacity; Color ivory = new Color(235, 245, 220, 0) * opacity; Vector2 topLeft = focus + new Vector2(-halfWidth, -halfHeight); Vector2 topRight = focus + new Vector2(halfWidth, -halfHeight); Vector2 bottomLeft = focus + new Vector2(-halfWidth, halfHeight); Vector2 bottomRight = focus + new Vector2(halfWidth, halfHeight); DrawBoneBeam(pixel, topLeft, bottomLeft, marrow, ivory, 8f, 5); DrawBoneBeam(pixel, topRight, bottomRight, marrow, ivory, 8f, 5); DrawBoneBeam(pixel, topLeft, topRight, marrow, ivory, 9f, 4); // Inward curving ribs keep all three stages recognisably part of the same // gallows; later stages close more of the cage instead of changing motif. for (int rib = 0; rib < 2 + stage; rib++) { float y = MathHelper.Lerp(-halfHeight * 0.65f, halfHeight * 0.62f, (rib + 1f) / (3f + stage)); float ribWidth = halfWidth * (0.72f + rib * 0.035f); Vector2 left = focus + new Vector2(-halfWidth, y); Vector2 inward = focus + new Vector2(-ribWidth * 0.12f, y + 12f); Vector2 right = focus + new Vector2(halfWidth, y); DrawQuadraticCurve(pixel, left, focus + new Vector2(-ribWidth * 0.52f, y + 15f), inward, marrow * 0.48f, 7f, 7); DrawQuadraticCurve(pixel, right, focus + new Vector2(ribWidth * 0.52f, y + 15f), focus + new Vector2(ribWidth * 0.12f, y + 12f), marrow * 0.48f, 7f, 7); DrawQuadraticCurve(pixel, left, focus + new Vector2(-ribWidth * 0.52f, y + 15f), inward, ivory * 0.64f, 2f, 7); DrawQuadraticCurve(pixel, right, focus + new Vector2(ribWidth * 0.52f, y + 15f), focus + new Vector2(ribWidth * 0.12f, y + 12f), ivory * 0.64f, 2f, 7); } float bladeFall = SmoothStep(MathHelper.Clamp((timer - 7f) / 6f, 0f, 1f)); float bladeY = MathHelper.Lerp(-halfHeight - 34f, halfHeight + 16f, bladeFall); Vector2 bladeStart = focus + new Vector2(0f, -halfHeight - 46f); Vector2 bladeEnd = focus + new Vector2(0f, bladeY); DrawVisualLine(pixel, bladeStart, bladeEnd, marrow * 0.42f, 24f); DrawVisualLine(pixel, bladeStart, bladeEnd, ivory * 0.92f, 5f); Vector2 edgeLeft = bladeEnd + new Vector2(-34f - stage * 5f, -16f); Vector2 edgeRight = bladeEnd + new Vector2(34f + stage * 5f, -16f); DrawVisualLine(pixel, edgeLeft, bladeEnd, ivory, 5f); DrawVisualLine(pixel, bladeEnd, edgeRight, ivory, 5f); if (stage >= 2) { float jaw = SmoothStep(MathHelper.Clamp((timer - 17f) / 7f, 0f, 1f)); float jawOffset = MathHelper.Lerp(halfWidth, 15f, jaw); DrawScytheGlyph(pixel, focus + new Vector2(-jawOffset, 7f), Vector2.UnitX, 52f, marrow * 0.5f); DrawScytheGlyph(pixel, focus + new Vector2(jawOffset, 7f), -Vector2.UnitX, 52f, marrow * 0.5f); DrawRing(pixel, focus, halfWidth * 0.88f, marrow * 0.28f, 5f, 24); } if (stage >= 3) { float close = SmoothStep(MathHelper.Clamp((timer - 27f) / 7f, 0f, 1f)); DrawVisualLine(pixel, focus - Vector2.UnitX * halfWidth * close, focus + Vector2.UnitX * halfWidth * close, marrow * 0.54f, 19f); DrawVisualLine(pixel, focus - Vector2.UnitX * halfWidth * close, focus + Vector2.UnitX * halfWidth * close, ivory * 0.86f, 4f); Vector2 palm = focus + new Vector2(0f, -halfHeight - 70f); for (int finger = -2; finger <= 2; finger++) { Vector2 knuckle = focus + new Vector2(finger * halfWidth * 0.25f, -halfHeight - 22f); Vector2 tip = focus + new Vector2(finger * halfWidth * 0.34f, -halfHeight + 18f); DrawVisualLine(pixel, palm, knuckle, marrow * 0.3f, 9f); DrawVisualLine(pixel, knuckle, tip, ivory * 0.45f, 3f); } } } private void DrawBloodSpecial(Texture2D pixel, Vector2 grip) { if (!specialReleased) { float pulse = 0.82f + (float)Math.Sin(Main.GlobalTimeWrappedHourly * 9f) * 0.13f; Vector2 blade = grip + visualWeaponAngle.ToRotationVector2() * 120f; int threads = 4 + Math.Max(1, snapshot.StageNumber); for (int index = 0; index < threads; index++) { float angle = MathHelper.TwoPi * index / threads + Main.GlobalTimeWrappedHourly * 0.55f; Vector2 start = grip + angle.ToRotationVector2() * (72f + visualCharge * 32f); Vector2 bend = Vector2.Lerp(start, blade, 0.48f) + (angle + MathHelper.PiOver2).ToRotationVector2() * 18f; DrawQuadraticCurve(pixel, start, bend, blade, new Color(245, 25, 55, 0) * (0.24f + visualCharge * 0.5f) * pulse, 1.5f + visualCharge * 1.8f, 8); } DrawRing(pixel, grip, 24f + visualCharge * 18f, new Color(255, 85, 105, 0) * (0.28f + visualCharge * 0.36f), 2f + visualCharge * 2f, 24); return; } // Target-local strike and scar projectiles own the release presentation. // Keep only a restrained contraction at the caster so the range siphon // visibly collapses before those per-target wounds open. float contraction = 1f - SmoothStep(MathHelper.Clamp(releaseTimer / 9f, 0f, 1f)); if (contraction > 0.001f) DrawRing(pixel, grip, MathHelper.Lerp(34f, 118f, contraction), new Color(248, 38, 72, 0) * (contraction * 0.58f), 3.2f + contraction * 3f, 48); } private void DrawTimedBloodCrescent(Texture2D pixel, Vector2 grip, Vector2 direction, int startTick, float radius, float thickness, float strength) { int age = releaseTimer - startTick; if (age < 0 || age > 21) return; float reveal = SmoothStep(MathHelper.Clamp(age / 6f, 0f, 1f)); float fade = 1f - MathHelper.Clamp((age - 10f) / 11f, 0f, 1f); float opacity = fade * strength; Vector2 center = grip - direction * (42f + thickness * 0.08f); float startAngle = direction.ToRotation() - MathHelper.Pi * 0.59f; float sweep = MathHelper.Pi * 1.18f * reveal; Color shadow = new Color(68, 0, 16, 0); Color blood = new Color(210, 10, 38, 0); Color hot = new Color(255, 52, 62, 0); Color edge = new Color(255, 239, 218, 0); DrawArc(pixel, center, radius, startAngle, sweep, shadow * opacity * 0.42f, thickness * 1.32f, 18); for (int echo = 2; echo >= 1; echo--) { DrawArc(pixel, center - direction * (echo * 9f), radius - echo * 8f, startAngle - echo * 0.035f, sweep, blood * opacity * (0.13f / echo), thickness * (1f - echo * 0.12f), 18); } DrawArc(pixel, center, radius, startAngle, sweep, blood * opacity * 0.68f, thickness, 18); DrawArc(pixel, center, radius + thickness * 0.18f, startAngle, sweep, hot * opacity * 0.5f, thickness * 0.42f, 18); DrawArc(pixel, center, radius + thickness * 0.48f, startAngle, sweep, edge * opacity * 0.96f, Math.Max(3f, thickness * 0.075f), 18); DrawArc(pixel, center, radius - thickness * 0.19f, startAngle + 0.08f, Math.Max(0f, sweep - 0.16f), hot * opacity * 0.42f, 3.4f, 16); if (age >= 5 && age <= 12) { Vector2 impact = center + direction * (radius + thickness * 0.35f); DrawStarBurst(pixel, impact, 18f + (age - 5f) * 5f, new Color(255, 205, 145, 0) * (1f - (age - 5f) / 8f), 9); } } private void DrawInfernalSpecial(Texture2D pixel, Vector2 grip) { int pathCount = snapshot.Stage >= ReaperStage.StageIII ? 3 : snapshot.Stage >= ReaperStage.StageII ? 2 : 1; int[] starts = { 2, 15, 29 }; for (int index = 0; index < pathCount; index++) { if (!infernalDashReady[index]) continue; int age = timer - starts[index]; if (age < 0) continue; float opacity = 1f - MathHelper.Clamp((age - 13f) / 26f, 0f, 1f); Vector2 start = infernalDashStarts[index]; Vector2 end = infernalDashEnds[index]; DrawVisualLine(pixel, start, end, Color.Black * opacity * 0.78f, 24f + index * 2f); DrawVisualLine(pixel, start, end, new Color(235, 55, 8, 0) * opacity * 0.68f, 15f + index * 2f); DrawVisualLine(pixel, start, end, new Color(255, 205, 45, 0) * opacity * 0.78f, 5f); DrawVisualLine(pixel, start, end, new Color(255, 250, 210, 0) * opacity * 0.72f, 1.7f); Vector2 direction = (end - start).SafeNormalize(aim); Vector2 normal = direction.RotatedBy(MathHelper.PiOver2); for (int ember = 1; ember <= 7; ember++) { float amount = ember / 8f; float side = ((ember + index) & 1) == 0 ? 1f : -1f; Vector2 point = Vector2.Lerp(start, end, amount); float height = 9f + (float)Math.Sin((timer + ember * 5f) * 0.31f) * 5f; DrawVisualLine(pixel, point, point + normal * side * height, new Color(255, 105, 15, 0) * opacity * 0.52f, 2.4f); } DrawScytheGlyph(pixel, start, direction, 78f, new Color(255, 100, 18, 0) * opacity * 0.22f); float fan = SmoothStep(MathHelper.Clamp(age / 6f, 0f, 1f)); DrawArc(pixel, end, 54f + index * 5f, direction.ToRotation() - 1.35f, 2.7f * fan, new Color(255, 135, 22, 0) * opacity * 0.55f, 13f, 14); } if (snapshot.Stage >= ReaperStage.StageIII && timer >= 31 && infernalDashReady[0] && infernalDashReady[1] && infernalDashReady[2]) { float flare = WindowOpacity(timer, 31, 5, 8); DrawVisualLine(pixel, infernalDashEnds[2], infernalDashStarts[0], new Color(255, 75, 8, 0) * flare * 0.48f, 10f); Vector2 center = (infernalDashStarts[0] + infernalDashEnds[0] + infernalDashEnds[1] + infernalDashEnds[2]) * 0.25f; DrawRing(pixel, center, 72f, new Color(255, 220, 60, 0) * flare * 0.45f, 7f, 20); } } private void DrawInfernalUltimateRoute(Texture2D pixel) { for (int index = 0; index < infernalDashReady.Length; index++) { if (!infernalDashReady[index]) continue; int eventTick = 20 + index * 20; float opacity = WindowOpacity(timer, eventTick, 92 - index * 10, 26); if (opacity <= 0f) continue; Vector2 start = infernalDashStarts[index]; Vector2 end = infernalDashEnds[index]; DrawVisualLine(pixel, start, end, Color.Black * opacity * 0.9f, 34f); DrawVisualLine(pixel, start, end, new Color(220, 45, 5, 0) * opacity * 0.78f, 22f); DrawVisualLine(pixel, start, end, new Color(255, 180, 30, 0) * opacity * 0.88f, 9f); DrawVisualLine(pixel, start, end, new Color(255, 250, 205, 0) * opacity, 2.5f); DrawRing(pixel, end, 26f + index * 3f, new Color(255, 210, 55, 0) * opacity * 0.62f, 6f, 16); } if (timer >= 102 && infernalDashReady[0] && infernalDashReady[4]) { float seal = WindowOpacity(timer, 102, 53, 28); Vector2 center = Vector2.Zero; int count = 0; for (int index = 0; index < infernalDashReady.Length; index++) { if (!infernalDashReady[index]) continue; center += infernalDashEnds[index]; count++; } if (count > 0) center /= count; DrawRing(pixel, center, 118f, new Color(255, 65, 8, 0) * seal * 0.42f, 18f, 24); DrawRing(pixel, center, 118f, new Color(255, 235, 130, 0) * seal * 0.7f, 2.3f, 24); } } private void DrawFrostSpecial(Texture2D pixel, Vector2 grip) { int stage = Math.Max(1, snapshot.StageNumber); if (!specialReleased) { float radius = 46f + visualCharge * (22f + stage * 7f); Vector2 mirror = grip - aim * (42f + stage * 9f); DrawRegularPolygon(pixel, mirror, radius, 6, new Color(125, 220, 255, 0) * (0.32f + visualCharge * 0.38f), 4f + visualCharge * 3f, MathHelper.PiOver2); DrawRegularPolygon(pixel, mirror, radius * 0.76f, 6, new Color(235, 255, 255, 0) * (0.28f + visualCharge * 0.42f), 1.8f, MathHelper.PiOver2); DrawScytheGlyph(pixel, mirror, aim, 58f, new Color(205, 250, 255, 0) * visualCharge * 0.2f); return; } float length = stage switch { 1 => 220f, 2 => 285f, _ => 370f }; Vector2 focus = grip + aim * (stage >= 3 ? 70f : 35f); int mirrorCount = stage switch { 1 => 1, 2 => 2, _ => 4 }; Vector2 normal = aim.RotatedBy(MathHelper.PiOver2); for (int index = 0; index < mirrorCount; index++) { float side = index % 2 == 0 ? -1f : 1f; float row = index / 2f; Vector2 center = focus + normal * side * (74f + row * 52f) - aim * row * 34f; float appear = WindowOpacity(releaseTimer, index * 2, 15, 8); DrawRegularPolygon(pixel, center, 43f + stage * 5f, 6, new Color(95, 195, 255, 0) * appear * 0.48f, 6f, MathHelper.PiOver2); DrawRegularPolygon(pixel, center, 34f + stage * 4f, 6, new Color(240, 255, 255, 0) * appear * 0.62f, 2f, MathHelper.PiOver2); } DrawTimedCrystalBlade(pixel, focus, aim.RotatedBy(-0.55f), length, 3); DrawTimedCrystalBlade(pixel, focus, aim.RotatedBy(0.55f), length, 10); if (stage >= 3) { float coffin = WindowOpacity(releaseTimer, 8, 10, 10); DrawRegularPolygon(pixel, focus, 108f, 8, new Color(90, 185, 245, 0) * coffin * 0.42f, 18f, MathHelper.PiOver4); DrawRegularPolygon(pixel, focus, 108f, 8, new Color(245, 255, 255, 0) * coffin * 0.78f, 3f, MathHelper.PiOver4); } } private void DrawTimedCrystalBlade(Texture2D pixel, Vector2 focus, Vector2 direction, float length, int startTick) { float opacity = WindowOpacity(releaseTimer, startTick, 5, 10); if (opacity <= 0f) return; Vector2 start = focus - direction * length * 0.5f; Vector2 end = focus + direction * length * 0.5f; DrawVisualLine(pixel, start, end, new Color(25, 75, 130, 0) * opacity * 0.5f, 34f); DrawVisualLine(pixel, start, end, new Color(85, 205, 255, 0) * opacity * 0.72f, 19f); DrawVisualLine(pixel, start, end, new Color(240, 255, 255, 0) * opacity, 3.8f); Vector2 normal = direction.RotatedBy(MathHelper.PiOver2); for (int shard = 1; shard < 6; shard++) { Vector2 point = Vector2.Lerp(start, end, shard / 6f); float side = (shard & 1) == 0 ? 1f : -1f; DrawVisualLine(pixel, point, point + normal * side * (13f + shard * 2f), new Color(175, 240, 255, 0) * opacity * 0.62f, 2.2f); } } private void DrawSoulSpecial(Texture2D pixel, Vector2 grip) { int stage = Math.Max(1, snapshot.StageNumber); int circles = stage switch { 1 => 2, 2 => 3, _ => 4 }; if (!specialReleased) { for (int ring = 0; ring < circles; ring++) { float radius = 42f + ring * 16f + visualCharge * (16f + ring * 7f); float rotation = Main.GlobalTimeWrappedHourly * (ring % 2 == 0 ? 0.55f : -0.42f); DrawArc(pixel, grip, radius, rotation, MathHelper.TwoPi * (0.72f + visualCharge * 0.24f), Color.Lerp(new Color(105, 75, 245, 0), new Color(80, 245, 255, 0), ring / (float)Math.Max(1, circles - 1)) * (0.25f + visualCharge * 0.38f), 3f + ring, 20); } return; } for (int index = 0; index < circles; index++) { int eventTick = 6 + index * 8; float opacity = WindowOpacity(releaseTimer, eventTick - 3, 7, 12); if (opacity <= 0f) continue; float radius = stage switch { 1 => 92f + index * 48f, 2 => 92f + index * 58f, _ => 96f + index * 70f }; Color soul = Color.Lerp(new Color(110, 75, 245, 0), new Color(70, 245, 255, 0), index / (float)Math.Max(1, circles - 1)); DrawRing(pixel, grip, radius, soul * opacity * 0.3f, 17f + index * 2f, 28); DrawRing(pixel, grip, radius, new Color(215, 255, 255, 0) * opacity * 0.72f, 2.5f, 28); int heralds = 2 + stage; for (int herald = 0; herald < heralds; herald++) { float angle = MathHelper.TwoPi * herald / heralds + index * 0.53f; Vector2 position = grip + angle.ToRotationVector2() * radius; DrawScytheGlyph(pixel, position, (-angle.ToRotationVector2()).RotatedBy(0.25f), 42f + stage * 5f, soul * opacity * 0.28f); } } if (stage >= 3) { float reaper = WindowOpacity(releaseTimer, 28, 5, 12); Vector2 hood = grip - Vector2.UnitY * 145f; DrawArc(pixel, hood, 72f, MathHelper.Pi, MathHelper.Pi, new Color(45, 20, 110, 0) * reaper * 0.5f, 34f, 16); DrawScytheGlyph(pixel, hood + aim * 18f, aim, 180f, new Color(95, 235, 255, 0) * reaper * 0.42f); } } private void DrawVoidSpecial(Texture2D pixel, Vector2 grip) { float reveal = SmoothStep(MathHelper.Clamp(timer / 5f, 0f, 1f)); float fade = 1f - SmoothStep(MathHelper.Clamp( (timer - 8f) / Math.Max(1f, VoidSpecialVisualEndTick - 8f), 0f, 1f)); float opacity = reveal * fade; if (opacity <= 0.001f) return; // A compact world-sampled opening surrounds the held blade while the // authoritative persistent path arrives. It uses the same Void backdrop // renderer as ordinary and ultimate rifts, so there is no unrelated box // or flat purple placeholder. Vector2 bladeAxis = visualWeaponAngle.ToRotationVector2(); Vector2 openingCenter = grip + bladeAxis * (92f + snapshot.StageNumber * 7f); ReaperVoidBackdropRenderer.DrawPersistentRift(Main.spriteBatch, openingCenter, bladeAxis, 118f + snapshot.StageNumber * 18f, 20f + snapshot.StageNumber * 5f, Projectile.identity * 397 ^ Projectile.owner * 31, reveal, opacity * 0.88f); } private void DrawCoordinateGate(Texture2D pixel, Vector2 center, Vector2 forward, float radius, float opacity) { if (opacity <= 0f || radius <= 1f) return; Vector2 right = forward.SafeNormalize(Vector2.UnitX); Vector2 up = right.RotatedBy(MathHelper.PiOver2); Color purple = new Color(185, 45, 255, 0) * opacity; Color white = new Color(245, 225, 255, 0) * opacity; Vector2 a = center - right * radius - up * radius; Vector2 b = center + right * radius - up * radius; Vector2 c = center + right * radius + up * radius; Vector2 d = center - right * radius + up * radius; DrawVisualLine(pixel, a, b, purple * 0.62f, 8f); DrawVisualLine(pixel, b, c, purple * 0.62f, 8f); DrawVisualLine(pixel, c, d, purple * 0.62f, 8f); DrawVisualLine(pixel, d, a, purple * 0.62f, 8f); DrawVisualLine(pixel, a, b, white * 0.82f, 1.7f); DrawVisualLine(pixel, b, c, white * 0.82f, 1.7f); DrawVisualLine(pixel, c, d, white * 0.82f, 1.7f); DrawVisualLine(pixel, d, a, white * 0.82f, 1.7f); DrawVisualLine(pixel, center - right * radius * 0.78f, center + right * radius * 0.78f, purple * 0.4f, 2f); DrawVisualLine(pixel, center - up * radius * 0.78f, center + up * radius * 0.78f, purple * 0.4f, 2f); for (int tick = -2; tick <= 2; tick++) { Vector2 mark = center + right * radius * tick / 3f; DrawVisualLine(pixel, mark - up * 5f, mark + up * 5f, white * 0.6f, 1.5f); } } private void DrawTimedVoidCut(Texture2D pixel, Vector2 start, Vector2 direction, float length, int startTick) { float opacity = WindowOpacity(timer, startTick, 4, 10); if (opacity <= 0f) return; direction = direction.SafeNormalize(aim); Vector2 end = start + direction * length; Vector2 normal = direction.RotatedBy(MathHelper.PiOver2); DrawVisualLine(pixel, start, end, Color.Black * opacity * 0.92f, 31f); DrawVisualLine(pixel, start + normal * 13f, end + normal * 13f, new Color(160, 30, 240, 0) * opacity * 0.72f, 5f); DrawVisualLine(pixel, start - normal * 13f, end - normal * 13f, new Color(160, 30, 240, 0) * opacity * 0.72f, 5f); DrawVisualLine(pixel, start + normal * 16f, end + normal * 16f, new Color(250, 235, 255, 0) * opacity, 1.8f); DrawVisualLine(pixel, start - normal * 16f, end - normal * 16f, new Color(250, 235, 255, 0) * opacity, 1.8f); int fragments = 5; for (int index = 1; index <= fragments; index++) { Vector2 point = Vector2.Lerp(start, end, index / (fragments + 1f)); float side = (index & 1) == 0 ? 1f : -1f; DrawRegularPolygon(pixel, point + normal * side * 25f, 6f + index, index % 2 == 0 ? 4 : 3, new Color(205, 65, 255, 0) * opacity * 0.5f, 2f, direction.ToRotation()); } } private void DrawDeathSpecial(Texture2D pixel, Vector2 grip) { float finisher = WindowOpacity(timer, 35, 5, 11); if (finisher > 0f) { Vector2 center = grip; DrawArc(pixel, center - aim * 65f, 235f, aim.ToRotation() - 1.55f, 2.95f, Color.Black * finisher * 0.9f, 86f, 20); DrawArc(pixel, center - aim * 65f, 238f, aim.ToRotation() - 1.55f, 2.95f, new Color(210, 12, 42, 0) * finisher * 0.68f, 48f, 20); DrawArc(pixel, center - aim * 65f, 261f, aim.ToRotation() - 1.55f, 2.95f, new Color(255, 225, 210, 0) * finisher * 0.9f, 4.5f, 20); for (int phase = 0; phase < 6; phase++) { float angle = MathHelper.TwoPi * phase / 6f + timer * 0.035f; Vector2 rune = center + angle.ToRotationVector2() * 82f; DrawRegularPolygon(pixel, rune, 11f, 6, (GetDeathPhaseColor(phase) with { A = 0 }) * finisher * 0.65f, 2f, angle); } } } private static Color GetDeathPhaseColor(int phase) => phase switch { 0 => new Color(80, 235, 255), 1 => new Color(245, 35, 70), 2 => new Color(255, 125, 20), 3 => new Color(135, 220, 255), 4 => new Color(120, 100, 255), _ => new Color(205, 50, 255) }; private static void DrawBoneBeam(Texture2D pixel, Vector2 start, Vector2 end, Color glow, Color core, float width, int joints) { DrawVisualLine(pixel, start, end, glow * 0.42f, width * 1.7f); DrawVisualLine(pixel, start, end, core * 0.78f, width * 0.56f); Vector2 direction = (end - start).SafeNormalize(Vector2.UnitY); Vector2 normal = direction.RotatedBy(MathHelper.PiOver2); for (int index = 0; index <= joints; index++) { Vector2 joint = Vector2.Lerp(start, end, index / (float)Math.Max(1, joints)); DrawVisualLine(pixel, joint - normal * width * 0.7f, joint + normal * width * 0.7f, core * 0.72f, Math.Max(1.4f, width * 0.32f)); } } private static void DrawScytheGlyph(Texture2D pixel, Vector2 grip, Vector2 direction, float size, Color color) { direction = direction.SafeNormalize(Vector2.UnitX); Vector2 normal = direction.RotatedBy(-MathHelper.PiOver2); Vector2 handleEnd = grip + direction * size * 0.62f; DrawVisualLine(pixel, grip - direction * size * 0.25f, handleEnd, color, Math.Max(2f, size * 0.045f)); DrawArc(pixel, handleEnd - normal * size * 0.08f, size * 0.38f, direction.ToRotation() - 1.78f, 2.35f, color, Math.Max(2f, size * 0.07f), 10); } private static void DrawStarBurst(Texture2D pixel, Vector2 center, float radius, Color color, int rays) { for (int index = 0; index < rays; index++) { float angle = MathHelper.TwoPi * index / rays; float length = radius * (index % 3 == 0 ? 1f : 0.58f); DrawVisualLine(pixel, center + angle.ToRotationVector2() * 3f, center + angle.ToRotationVector2() * length, color, index % 3 == 0 ? 3f : 1.6f); } } private static void DrawRing(Texture2D pixel, Vector2 center, float radius, Color color, float width, int segments) { DrawArc(pixel, center, radius, 0f, MathHelper.TwoPi, color, width, segments); } private static void DrawArc(Texture2D pixel, Vector2 center, float radius, float startAngle, float sweep, Color color, float width, int segments) { if (radius <= 0.5f || width <= 0.05f || segments < 2 || Math.Abs(sweep) <= 0.001f) return; segments = Math.Max(segments, Math.Min(160, (int)Math.Ceiling(Math.Abs(sweep) * radius / 18f))); Vector2 previous = center + startAngle.ToRotationVector2() * radius; for (int index = 1; index <= segments; index++) { float amount = index / (float)segments; Vector2 next = center + (startAngle + sweep * amount).ToRotationVector2() * radius; DrawVisualLine(pixel, previous, next, color, width); previous = next; } } private static void DrawRegularPolygon(Texture2D pixel, Vector2 center, float radius, int sides, Color color, float width, float rotation) { if (sides < 3 || radius <= 0.5f) return; Vector2 previous = center + rotation.ToRotationVector2() * radius; for (int index = 1; index <= sides; index++) { Vector2 next = center + (rotation + MathHelper.TwoPi * index / sides).ToRotationVector2() * radius; DrawVisualLine(pixel, previous, next, color, width); previous = next; } } private static void DrawQuadraticCurve(Texture2D pixel, Vector2 start, Vector2 control, Vector2 end, Color color, float width, int segments) { Vector2 previous = start; for (int index = 1; index <= segments; index++) { float amount = index / (float)segments; float inverse = 1f - amount; Vector2 next = start * (inverse * inverse) + control * (2f * inverse * amount) + end * (amount * amount); DrawVisualLine(pixel, previous, next, color, width); previous = next; } } private static float WindowOpacity(int current, int start, int sustain, int fade) { int age = current - start; if (age < 0 || age >= sustain + fade) return 0f; float enter = MathHelper.Clamp(age / 3f, 0f, 1f); float exit = age <= sustain ? 1f : 1f - (age - sustain) / (float)Math.Max(1, fade); return SmoothStep(enter) * SmoothStep(MathHelper.Clamp(exit, 0f, 1f)); } private void DrawChargeSeal(Vector2 center, Color outer, Color core) { Texture2D pixel = TextureAssets.MagicPixel.Value; float pulse = 0.82f + (float)Math.Sin(Main.GlobalTimeWrappedHourly * 7f) * 0.12f; float radius = 38f + visualCharge * 24f; int segments = 28; Vector2 previous = center + Vector2.UnitX * radius; for (int index = 1; index <= segments; index++) { Vector2 next = center + (MathHelper.TwoPi * index / segments).ToRotationVector2() * radius; DrawVisualLine(pixel, previous, next, outer * (0.25f + visualCharge * 0.42f) * pulse, 2.6f + visualCharge * 1.8f); DrawVisualLine(pixel, previous, next, core * (0.32f + visualCharge * 0.5f) * pulse, 0.9f); previous = next; } int runeCount = 3 + (int)snapshot.Stage; for (int index = 0; index < runeCount; index++) { float angle = MathHelper.TwoPi * index / runeCount + Main.GlobalTimeWrappedHourly * (snapshot.Form == ReaperFormId.Soul ? 0.7f : 0.25f); Vector2 runeCenter = center + angle.ToRotationVector2() * radius; Vector2 tangent = (angle + MathHelper.PiOver2).ToRotationVector2() * (3f + visualCharge * 3f); DrawVisualLine(pixel, runeCenter - tangent, runeCenter + tangent, core * (0.45f + visualCharge * 0.45f), 1.2f); } } private static void DrawVisualLine(Texture2D pixel, Vector2 start, Vector2 end, Color color, float width) { DeathDomainPrimitiveTextureSystem.DrawSmoothWorldLine(start, end, color, width); } private static float SmoothStep(float value) => value * value * (3f - 2f * value); private void SpawnAmbientVisuals(Player player) { if (Main.netMode == NetmodeID.Server) return; Color color = ReaperCombatRegistry.GetPrimaryColor(snapshot.Form); Lighting.AddLight(player.Center, color.ToVector3() * (ultimate ? 0.75f : 0.25f)); if (ultimate) { SpawnUltimateSoundVisuals(player); if (Main.rand.NextBool(2)) { Vector2 radius = Main.rand.NextVector2CircularEdge(180f, 120f); SpawnVisualDust(player.MountedCenter + radius, -radius.SafeNormalize(Vector2.Zero) * Main.rand.NextFloat(1f, 4f), ReaperCombatRegistry.GetDust(snapshot.Form), color, 1.25f); } CommitVisualClock(); return; } SpawnSpecialSoundVisuals(player); SpawnSpecialAmbientDust(player); CommitVisualClock(); } private void SpawnSpecialSoundVisuals(Player player) { Vector2 position = player.Center; if (ReachedVisualTick(1) && ConsumeVisualEvent(0)) { SoundStyle start = snapshot.Form switch { ReaperFormId.Bone => SoundID.Dig, ReaperFormId.Blood => SoundID.NPCDeath6, ReaperFormId.Infernal => SoundID.Item74, ReaperFormId.Frost => SoundID.Shatter, ReaperFormId.Soul => SoundID.NPCDeath6, ReaperFormId.Void => SoundID.Item8, ReaperFormId.Death => SoundID.Item122, _ => SoundID.Item71 }; PlayTuned(start, position, 0.54f, -0.32f, 0.08f); PlayTuned(SoundID.Item8, position, 0.34f, snapshot.Form == ReaperFormId.Void ? -0.45f : -0.15f, 0.05f); } if (IsChargedSpecial && !specialReleased) { int maximumCharge = snapshot.Form == ReaperFormId.Blood ? 600 : 45; if (chargeFrames >= maximumCharge && ConsumeVisualEvent(1)) { PlayTuned(SoundID.Item29, position, 0.58f, snapshot.Form == ReaperFormId.Frost ? 0.25f : -0.05f, 0.02f); TriggerSpecialImpact(position, aim, 2.2f, ReaperCombatRegistry.GetSecondaryColor(snapshot.Form), 0.12f, 5); } return; } switch (snapshot.Form) { case ReaperFormId.Base: if (ReachedVisualTick(3) && ConsumeVisualEvent(4)) PlayTuned(SoundID.Item8, position, 0.5f, 0.12f, 0.08f); if (ReachedVisualTick(11) && ConsumeVisualEvent(5)) { Vector2 impactPosition = baseDashReady ? baseDashEnd + aim * 72f : position + aim * 95f; PlayTuned(SoundID.Item71, impactPosition, 0.82f, 0.12f, 0.08f); PlayTuned(SoundID.NPCDeath6, impactPosition, 0.28f, 0.3f, 0.06f); TriggerSpecialImpact(impactPosition, aim, 3.6f, new Color(125, 235, 240), 0f, 0); SpawnVisualBurst(impactPosition, DustID.AncientLight, new Color(125, 235, 240), 13, 4.3f, 0.95f); } break; case ReaperFormId.Bone: PlayBoneSpecialEvents(position); break; case ReaperFormId.Blood: PlayBloodSpecialEvents(position); break; case ReaperFormId.Infernal: PlayInfernalSpecialEvents(position); break; case ReaperFormId.Frost: PlayFrostSpecialEvents(position); break; case ReaperFormId.Soul: PlaySoulSpecialEvents(position); break; case ReaperFormId.Void: PlayVoidSpecialEvents(position); break; case ReaperFormId.Death: PlayDeathSpecialEvents(position); break; } } private void PlayBoneSpecialEvents(Vector2 position) { if (ReachedVisualTick(12) && ConsumeVisualEvent(4)) { PlayTuned(SoundID.Item71, position, 0.82f, -0.28f, 0.07f); PlayTuned(SoundID.Dig, position, 0.64f, -0.38f, 0.05f); TriggerSpecialImpact(position, Vector2.UnitY, 4.2f, new Color(235, 245, 220), 0.2f, 6); SpawnVisualBurst(position + aim * 125f, DustID.DungeonSpirit, new Color(85, 230, 245), 14, 4f, 1f); } if (snapshot.Stage >= ReaperStage.StageII && ReachedVisualTick(22) && ConsumeVisualEvent(5)) { PlayTuned(SoundID.Shatter, position, 0.72f, -0.32f, 0.05f); PlayTuned(SoundID.Dig, position, 0.46f, 0.05f, 0.05f); TriggerSpecialImpact(position, aim, 4.8f, new Color(90, 235, 255), 0.18f, 6); } if (snapshot.Stage >= ReaperStage.StageIII && ReachedVisualTick(32) && ConsumeVisualEvent(6)) { PlayTuned(SoundID.Item122, position, 0.8f, -0.28f, 0.04f); PlayTuned(SoundID.NPCDeath6, position, 0.55f, -0.42f, 0.05f); TriggerSpecialImpact(position, aim, 6.4f, new Color(235, 245, 220), 0.3f, 8); } } private void PlayBloodSpecialEvents(Vector2 position) { if (ReachedVisualTick(1, releaseClock: true) && ConsumeVisualEvent(4)) { PlayTuned(SoundID.NPCDeath6, position, 0.68f, -0.42f, 0.05f); PlayTuned(SoundID.Item8, position, 0.5f, -0.25f, 0.04f); } if (ReachedVisualTick(7, releaseClock: true) && ConsumeVisualEvent(5)) { PlayTuned(SoundID.Item71, position, 0.96f, -0.26f, 0.06f); PlayTuned(SoundID.Item14, position, 0.55f, -0.15f, 0.04f); TriggerSpecialImpact(position, aim, snapshot.Stage >= ReaperStage.StageIII ? 8f : 5.5f, new Color(255, 222, 200), snapshot.Stage >= ReaperStage.StageIII ? 0.4f : 0.27f, 8); SpawnVisualBurst(position + aim * 180f, DustID.Blood, new Color(245, 35, 60), 18 + snapshot.StageNumber * 4, 5.2f, 1.1f); } if (snapshot.Stage >= ReaperStage.StageIII && ReachedVisualTick(17, releaseClock: true) && ConsumeVisualEvent(6)) { PlayTuned(SoundID.Item71, position, 0.88f, 0.06f, 0.05f); PlayTuned(SoundID.Shatter, position, 0.64f, -0.18f, 0.04f); TriggerSpecialImpact(position, aim.RotatedBy(0.38f), 7f, new Color(255, 235, 215), 0.34f, 7); } } private void PlayInfernalSpecialEvents(Vector2 position) { int count = snapshot.Stage >= ReaperStage.StageIII ? 3 : snapshot.Stage >= ReaperStage.StageII ? 2 : 1; int[] ticks = { 3, 16, 30 }; for (int index = 0; index < count; index++) { if (!ReachedVisualTick(ticks[index]) || !ConsumeVisualEvent(4 + index)) continue; Vector2 soundPosition = infernalDashReady[index] ? infernalDashEnds[index] : position; PlayTuned(SoundID.Item74, soundPosition, 0.78f, -0.18f + index * 0.12f, 0.07f); PlayTuned(SoundID.Item71, soundPosition, 0.68f, 0.08f + index * 0.08f, 0.06f); if (index == count - 1) PlayTuned(SoundID.Item14, soundPosition, 0.54f, -0.05f, 0.05f); TriggerSpecialImpact(soundPosition, aim, index == count - 1 ? 6.8f : 4.2f, new Color(255, 215, 90), index == count - 1 ? 0.31f : 0.16f, 6); SpawnVisualBurst(soundPosition, DustID.Torch, new Color(255, 110, 18), 13 + index * 4, 5.5f, 1.15f); } } private void PlayFrostSpecialEvents(Vector2 position) { if (ReachedVisualTick(1, releaseClock: true) && ConsumeVisualEvent(4)) PlayTuned(SoundID.Item29, position, 0.5f, 0.22f, 0.03f); if (ReachedVisualTick(7, releaseClock: true) && ConsumeVisualEvent(5)) { PlayTuned(SoundID.Item71, position, 0.78f, 0.28f, 0.05f); PlayTuned(SoundID.Shatter, position, 0.68f, 0.08f, 0.04f); TriggerSpecialImpact(position, aim.RotatedBy(-0.55f), 4.6f, new Color(210, 250, 255), 0.2f, 6); } if (ReachedVisualTick(14, releaseClock: true) && ConsumeVisualEvent(6)) { PlayTuned(SoundID.Item71, position, 0.82f, 0.42f, 0.04f); PlayTuned(SoundID.Shatter, position, 0.85f, -0.12f, 0.04f); TriggerSpecialImpact(position, aim.RotatedBy(0.55f), snapshot.Stage >= ReaperStage.StageIII ? 7.4f : 5.2f, Color.White, snapshot.Stage >= ReaperStage.StageIII ? 0.38f : 0.24f, 8); SpawnVisualBurst(position + aim * 65f, DustID.IceTorch, new Color(150, 225, 255), 16 + snapshot.StageNumber * 4, 4.5f, 1.05f); } } private void PlaySoulSpecialEvents(Vector2 position) { if (ReachedVisualTick(1, releaseClock: true) && ConsumeVisualEvent(4)) PlayTuned(SoundID.NPCDeath6, position, 0.52f, -0.18f, 0.05f); int circles = snapshot.Stage switch { ReaperStage.StageI => 2, ReaperStage.StageII => 3, _ => 4 }; for (int index = 0; index < circles; index++) { int tick = 6 + index * 8; if (!ReachedVisualTick(tick, releaseClock: true) || !ConsumeVisualEvent(5 + index)) continue; PlayTuned(SoundID.Item71, position, 0.64f + index * 0.06f, -0.18f + index * 0.12f, 0.06f); if (index == circles - 1) PlayTuned(SoundID.NPCDeath6, position, 0.52f, -0.35f, 0.04f); TriggerSpecialImpact(position, aim.RotatedBy(index * MathHelper.TwoPi / circles), index == circles - 1 ? 6f : 3.2f, index == circles - 1 ? new Color(105, 245, 255) : new Color(130, 100, 255), index == circles - 1 ? 0.28f : 0.12f, 6); } } private void PlayVoidSpecialEvents(Vector2 position) { if (!ReachedVisualTick(VoidSpecialTeleportTick) || !ConsumeVisualEvent(4)) return; PlayTuned(SoundID.Item71, position, 0.88f, 0.22f, 0.04f); PlayTuned(SoundID.Item122, position, 0.62f, -0.42f, 0.03f); TriggerSpecialImpact(position, aim, 7.2f, new Color(235, 215, 255), 0.31f, 6); SpawnVisualBurst(position, DustID.Shadowflame, new Color(190, 55, 255), 18, 5.4f, 1.05f); } private void PlayDeathSpecialEvents(Vector2 position) { for (int phase = 0; phase < 6; phase++) { int eventTick = 4 + phase * 5; if (!ReachedVisualTick(eventTick) || !ConsumeVisualEvent(4 + phase)) continue; Vector2 eventPosition = position; PlayTuned(SoundID.Item71, eventPosition, 0.58f + phase * 0.045f, -0.16f + phase * 0.09f, 0.04f); if (phase is 0 or 2 or 4) PlayTuned(phase == 2 ? SoundID.Item74 : SoundID.NPCDeath6, eventPosition, 0.3f, -0.3f + phase * 0.06f, 0.04f); TriggerSpecialImpact(eventPosition, aim.RotatedBy(MathHelper.PiOver2), 2.8f + phase * 0.3f, GetDeathPhaseColor(phase), 0.1f, 4); } if (ReachedVisualTick(38) && ConsumeVisualEvent(12)) { PlayTuned(SoundID.Item122, position, 0.95f, -0.34f, 0.03f); PlayTuned(SoundID.Item14, position, 0.7f, -0.16f, 0.04f); TriggerSpecialImpact(position, aim, 9f, new Color(255, 220, 205), 0.44f, 9); SpawnVisualBurst(position, DustID.Shadowflame, new Color(235, 25, 65), 28, 6f, 1.2f); } } private void SpawnUltimateSoundVisuals(Player player) { // Ultimate audio is placed on the local listener. Unlike ordinary attacks, // the complete cinematic mix must not disappear merely because its owner is // at the edge of the sync range. Vector2 listener = Main.LocalPlayer.active ? Main.LocalPlayer.Center : player.Center; if (ReachedVisualTick(1) && ConsumeVisualEvent(0)) { SoundStyle ritual = snapshot.Form switch { ReaperFormId.Bone => SoundID.NPCDeath6, ReaperFormId.Blood => SoundID.NPCDeath6, ReaperFormId.Infernal => SoundID.Item74, ReaperFormId.Frost => SoundID.Item29, ReaperFormId.Soul => SoundID.NPCDeath6, ReaperFormId.Void => SoundID.Item8, _ => SoundID.Item122 }; PlayTuned(ritual, listener, 0.7f, -0.42f, 0.04f); PlayTuned(SoundID.Item8, listener, 0.45f, -0.35f, 0.03f); } switch (snapshot.Form) { case ReaperFormId.Bone: for (int index = 0; index < 4; index++) PlayUltimateCut(listener, 30 + index * 36, 4 + index, -0.32f + index * 0.09f, SoundID.Dig, new Color(210, 250, 245), 4.8f); PlayUltimateCut(listener, 168, 8, -0.24f, SoundID.NPCDeath6, new Color(225, 250, 235), 7.5f); PlayUltimateFinal(listener, 207, 9, new Color(235, 245, 220), -0.36f); break; case ReaperFormId.Blood: for (int index = 0; index < 6; index++) PlayUltimateCut(listener, 30 + index * 15, 4 + index, -0.28f + index * 0.08f, index % 2 == 0 ? SoundID.NPCDeath6 : SoundID.Item14, new Color(255, 80, 95), 4.5f + index * 0.25f); PlayUltimateFinal(listener, 180, 12, new Color(255, 225, 205), -0.28f); break; case ReaperFormId.Infernal: for (int index = 0; index < 5; index++) PlayUltimateCut(listener, 20 + index * 20, 4 + index, -0.18f + index * 0.1f, SoundID.Item74, new Color(255, 190, 55), 5.2f); PlayUltimateCut(listener, 130, 10, -0.12f, SoundID.Item14, new Color(255, 225, 105), 8f); PlayUltimateFinal(listener, 190, 11, new Color(255, 242, 195), -0.18f); break; case ReaperFormId.Frost: for (int index = 0; index < 4; index++) PlayUltimateCut(listener, 30 + index * 18, 4 + index, 0.12f + index * 0.08f, SoundID.Shatter, new Color(195, 245, 255), 4.6f); PlayUltimateCut(listener, 100, 9, 0.18f, SoundID.Shatter, new Color(225, 255, 255), 7f); PlayUltimateFinal(listener, 110, 10, Color.White, 0.02f); break; case ReaperFormId.Soul: PlayUltimateCut(listener, 20, 4, -0.25f, SoundID.NPCDeath6, new Color(130, 105, 255), 4.5f); for (int index = 0; index < 8; index++) PlayUltimateCut(listener, 32 + index * 8, 5 + index, -0.15f + index * 0.055f, index % 2 == 0 ? SoundID.NPCDeath6 : SoundID.Item8, new Color(90, 235, 255), 3.8f); PlayUltimateFinal(listener, 104, 14, new Color(180, 250, 255), -0.24f); break; case ReaperFormId.Void: PlayUltimateFinal(listener, VoidUltimateBlackHoleTick, 4, new Color(250, 235, 255), -0.45f); break; case ReaperFormId.Death: for (int index = 0; index < 6; index++) PlayUltimateCut(listener, 36 + index * 18, 4 + index, -0.22f + index * 0.08f, index switch { 0 => SoundID.Dig, 1 => SoundID.NPCDeath6, 2 => SoundID.Item74, 3 => SoundID.Shatter, 4 => SoundID.Item8, _ => SoundID.Item122 }, GetDeathPhaseColor(index), 5.8f); PlayUltimateFinal(listener, ReaperDeathUltimateGeometry.ShatterTick, 12, new Color(190, 14, 48), -0.38f, flashStrength: 0.12f); break; } } private void PlayUltimateCut(Vector2 listener, int eventTick, int eventIndex, float pitch, SoundStyle accent, Color flash, float shake) { if (!ReachedVisualTick(eventTick) || !ConsumeVisualEvent(eventIndex)) return; PlayTuned(SoundID.Item71, listener, 0.78f, pitch, 0.035f); PlayTuned(accent, listener, 0.42f, pitch - 0.12f, 0.025f); ReaperVfxDirector.TriggerGlobalImpact(aim, shake, 7, flash, 0.18f, 6, 0.24f); } private void PlayUltimateFinal(Vector2 listener, int eventTick, int eventIndex, Color flash, float pitch, float flashStrength = 0.68f) { if (!ReachedVisualTick(eventTick) || !ConsumeVisualEvent(eventIndex)) return; PlayTuned(SoundID.Item122, listener, 1f, pitch, 0.02f); PlayTuned(SoundID.Item14, listener, 0.76f, pitch + 0.14f, 0.025f); PlayTuned(SoundID.NPCDeath6, listener, 0.45f, pitch - 0.12f, 0.02f); ReaperVfxDirector.TriggerGlobalImpact(aim, 12f, 14, flash, MathHelper.Clamp(flashStrength, 0f, 0.68f), 12, 0.58f); } private void SpawnSpecialAmbientDust(Player player) { if (IsChargedSpecial && !specialReleased) { if (!Main.rand.NextBool(2)) return; Vector2 radius = Main.rand.NextVector2CircularEdge(58f + visualCharge * 42f, 42f + visualCharge * 28f); Vector2 velocity = -radius.SafeNormalize(Vector2.Zero) * Main.rand.NextFloat(1.2f, 3.4f); int dustType = snapshot.Form switch { ReaperFormId.Blood => DustID.Blood, ReaperFormId.Frost => DustID.IceTorch, _ => DustID.AncientLight }; SpawnVisualDust(player.MountedCenter + radius, velocity, dustType, ReaperCombatRegistry.GetPrimaryColor(snapshot.Form), 0.8f + visualCharge * 0.45f); return; } switch (snapshot.Form) { case ReaperFormId.Infernal: { int count = snapshot.Stage >= ReaperStage.StageIII ? 3 : snapshot.Stage >= ReaperStage.StageII ? 2 : 1; for (int index = 0; index < count; index++) { if (!infernalDashReady[index] || !Main.rand.NextBool(3)) continue; Vector2 point = Vector2.Lerp(infernalDashStarts[index], infernalDashEnds[index], Main.rand.NextFloat()); SpawnVisualDust(point, Main.rand.NextVector2Circular(1.5f, 2.8f), DustID.Torch, new Color(255, 105, 15), 0.95f); } break; } case ReaperFormId.Blood: if (specialReleased && Main.rand.NextBool(2)) { Vector2 offset = Main.rand.NextVector2CircularEdge(130f, 95f); SpawnVisualDust(player.Center + offset, -offset.SafeNormalize(Vector2.Zero) * 2.4f, DustID.Blood, new Color(235, 30, 55), 1.05f); } break; case ReaperFormId.Frost: if (specialReleased && Main.rand.NextBool(2)) SpawnVisualDust(player.Center + Main.rand.NextVector2Circular(130f, 100f), Main.rand.NextVector2Circular(1.5f, 1.5f), DustID.IceTorch, new Color(155, 225, 255), 0.9f); break; case ReaperFormId.Soul: if (specialReleased && Main.rand.NextBool(2)) { Vector2 offset = Main.rand.NextVector2CircularEdge(170f, 170f); SpawnVisualDust(player.Center + offset, -offset.SafeNormalize(Vector2.Zero) * 1.8f, DustID.AncientLight, new Color(105, 210, 255), 0.9f); } break; default: if (Main.rand.NextBool(4)) { Vector2 radius = Main.rand.NextVector2CircularEdge(70f, 50f); SpawnVisualDust(player.MountedCenter + radius, -radius.SafeNormalize(Vector2.Zero) * Main.rand.NextFloat(1f, 2.6f), ReaperCombatRegistry.GetDust(snapshot.Form), ReaperCombatRegistry.GetPrimaryColor(snapshot.Form), 0.85f); } break; } } private bool ConsumeVisualEvent(int eventIndex) { if (eventIndex < 0 || eventIndex >= 64) return false; ulong flag = 1UL << eventIndex; if ((playedVisualEvents & flag) != 0UL) return false; playedVisualEvents |= flag; return true; } private bool ReachedVisualTick(int eventTick, bool releaseClock = false) { if (!visualClockInitialized) return false; int current = releaseClock ? releaseTimer : timer; int previous = releaseClock ? previousVisualReleaseTimer : previousVisualTimer; return previous < eventTick && current >= eventTick; } private void CommitVisualClock() { if (!visualClockInitialized) return; // Never rewind this presentation clock when an older ExtraAI packet // arrives. Future event crossings remain catchable after local time heals. previousVisualTimer = Math.Max(previousVisualTimer, timer); previousVisualReleaseTimer = Math.Max(previousVisualReleaseTimer, releaseTimer); } private static void PlayTuned(SoundStyle style, Vector2 position, float volume, float pitch, float variance) { SoundEngine.PlaySound(style with { Volume = MathHelper.Clamp(volume, 0f, 1f), Pitch = MathHelper.Clamp(pitch, -1f, 1f), PitchVariance = MathHelper.Clamp(variance, 0f, 1f), MaxInstances = 2, SoundLimitBehavior = SoundLimitBehavior.IgnoreNew }, position); } private static void TriggerSpecialImpact(Vector2 position, Vector2 direction, float strength, Color color, float opacity, int frames) { if (!Main.LocalPlayer.active || Vector2.DistanceSquared(Main.LocalPlayer.Center, position) > 1500f * 1500f) { return; } ReaperVfxDirector.TriggerGlobalImpact(direction, strength, frames, color, opacity, frames, Math.Min(0.42f, opacity + 0.08f)); } private static void SpawnVisualBurst(Vector2 center, int dustType, Color color, int count, float speed, float scale) { for (int index = 0; index < count; index++) { float angle = MathHelper.TwoPi * index / Math.Max(1, count); Vector2 velocity = angle.ToRotationVector2() * Main.rand.NextFloat(speed * 0.45f, speed); SpawnVisualDust(center, velocity, dustType, color, scale * Main.rand.NextFloat(0.8f, 1.18f)); } } private static void SpawnVisualDust(Vector2 position, Vector2 velocity, int dustType, Color color, float scale) { Dust dust = Dust.NewDustPerfect(position, dustType, velocity, 60, color, scale); dust.noGravity = true; } }