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<ReaperActionControllerProjectile>(), 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<Texture2D>(
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<Texture2D>(
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<MyGlobalProjectile>().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<int> 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<int> 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<ReaperCombatGlobalNPC>();
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;
}
}
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<ReaperActionControllerProjectile>(), 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<Texture2D>(
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<Texture2D>(
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<MyGlobalProjectile>().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<int> 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<int> 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<ReaperCombatGlobalNPC>();
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;
}
}