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