using SoulHarvest.Common;
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 ReaperStrikeProjectile : ModProjectile
{
private const int DeathCutPointCount = 65;
private readonly Vector2[] deathCutPoints = new Vector2[DeathCutPointCount];
private SickleCombatSnapshot snapshot;
private ReaperHitKind hitKind;
private ReaperStrikeShape shape;
private int phase;
private int delay;
private int age;
private float length;
private float width;
private bool configured;
private bool serverAuthorized;
private bool serverDamageTriggered;
private int authoritativeDamage;
private Vector2 authoritativeCenter;
private Vector2 authoritativeVelocity;
public override string Texture => "Terraria/Images/Projectile_0";
public static int Spawn(Terraria.DataStructures.IEntitySource source, int owner,
in SickleCombatSnapshot snapshot, ReaperHitKind hitKind, int phase,
Vector2 origin, Vector2 direction, ReaperStrikeShape shape, float length, float width,
float damageMultiplier, int delay = 0, int actionId = -1)
{
if (Main.netMode == NetmodeID.MultiplayerClient)
return -1;
direction = direction.SafeNormalize(Vector2.UnitX);
int index = Projectile.NewProjectile(source, origin, direction, ModContent.ProjectileType<ReaperStrikeProjectile>(),
Math.Max(1, (int)Math.Round(snapshot.Damage * damageMultiplier)), snapshot.Knockback, owner);
if (index < 0 || index >= Main.maxProjectiles)
return -1;
Projectile projectile = Main.projectile[index];
projectile.originalDamage = snapshot.Damage;
projectile.CritChance = hitKind == ReaperHitKind.Special ? snapshot.CritChance : 0;
if (projectile.ModProjectile is ReaperStrikeProjectile strike)
strike.Configure(snapshot, hitKind, phase, shape, length, width, delay);
projectile.GetGlobalProjectile<MyGlobalProjectile>().ConfigureReaperProjectile(
projectile, snapshot, hitKind, phase, direction.ToRotation(), actionId);
ReaperProjectileHelper.SyncNewProjectile(projectile);
return index;
}
public override void SetDefaults()
{
Projectile.width = 4;
Projectile.height = 4;
Projectile.friendly = true;
Projectile.hostile = false;
Projectile.tileCollide = false;
Projectile.ignoreWater = true;
Projectile.penetrate = -1;
Projectile.timeLeft = 240;
// This projectile owns the client-side slash telegraph. `hide` projectiles
// are not submitted to Terraria's normal projectile draw cache unless a
// DrawBehind hook explicitly adds them, so PreDraw never ran for any of the
// server-authoritative form strikes.
Projectile.hide = false;
Projectile.netImportant = true;
Projectile.DamageType = DamageClass.Melee;
Projectile.usesLocalNPCImmunity = true;
Projectile.localNPCHitCooldown = 10;
}
public override bool ShouldUpdatePosition() => false;
// The complete visible slash window is authoritative. Local immunity is
// longer than the remaining lifetime, so each actor still hits a target once
// instead of leaving five decorative frames with no collision.
public override bool? CanDamage()
{
// ReaperStrike is created by the server but retains the casting player's
// owner for attribution. Terraria normally lets that owning client run
// friendly-projectile collision, which makes the slash look successful
// locally while the dedicated server never executes the Reaper hit hooks.
// The server explicitly calls Damage below; clients are presentation-only.
if (Main.netMode == NetmodeID.MultiplayerClient)
return false;
return configured && age >= delay && age < delay + 8 ? null : false;
}
public override void AI()
{
if (Main.netMode == NetmodeID.Server)
{
if (!serverAuthorized)
{
Projectile.Kill();
return;
}
Projectile.damage = authoritativeDamage;
Projectile.Center = authoritativeCenter;
Projectile.velocity = authoritativeVelocity;
}
if (!configured)
return;
age++;
if (Main.netMode == NetmodeID.Server && !serverDamageTriggered
&& age >= Math.Max(1, delay))
{
serverDamageTriggered = true;
ReaperProjectileHelper.DamageOnDedicatedServer(Projectile);
}
int visualLifetime = GetVisualLifetime();
if (IsDeathUltimateWorldCut && Main.netMode != NetmodeID.Server)
RecordDeathUltimateWorldCut(visualLifetime);
Projectile.timeLeft = Math.Max(2, visualLifetime + 1 - age);
if (age == Math.Max(1, delay) && Main.netMode != NetmodeID.Server)
SpawnBurst();
if (age >= visualLifetime)
Projectile.Kill();
}
public override bool? Colliding(Rectangle projHitbox, Rectangle targetHitbox)
{
Vector2 direction = Projectile.velocity.SafeNormalize(Vector2.UnitX);
return shape switch
{
ReaperStrikeShape.Circle => CircleIntersects(targetHitbox, Projectile.Center, length),
ReaperStrikeShape.Cross => LineIntersects(targetHitbox, direction) || LineIntersects(targetHitbox, direction.RotatedBy(MathHelper.PiOver2)),
_ => LineIntersects(targetHitbox, direction)
};
}
public override void OnHitNPC(NPC target, NPC.HitInfo hit, int damageDone)
{
if (Main.netMode == NetmodeID.Server || !configured
|| snapshot.Form != ReaperFormId.Base
|| hitKind != ReaperHitKind.Special)
{
return;
}
Vector2 axis = Projectile.velocity.SafeNormalize(Vector2.UnitX);
Vector2 slash = axis.RotatedBy(-0.48f)
.SafeNormalize(Vector2.UnitY);
ReaperVfxDirector.TriggerGlobalImpact(slash, 3.2f, 4,
new Color(90, 220, 230), 0f, 0, 0.08f);
SoundEngine.PlaySound(SoundID.Item71 with
{
Volume = 0.52f,
Pitch = 0.3f,
PitchVariance = 0.07f
}, target.Center);
for (int index = 0; index < 13; index++)
{
Vector2 velocity = slash.RotatedBy(
Main.rand.NextFloat(-0.68f, 0.68f))
* Main.rand.NextFloat(3f, 7.8f);
Dust dust = Dust.NewDustPerfect(
target.Center + Main.rand.NextVector2Circular(8f, 8f),
DustID.AncientLight, velocity, 25,
index % 4 == 0 ? new Color(225, 255, 250)
: new Color(65, 220, 235),
Main.rand.NextFloat(0.78f, 1.16f));
dust.noGravity = true;
}
Vector2 towardOwner = Projectile.owner >= 0
&& Projectile.owner < Main.maxPlayers
? (Main.player[Projectile.owner].Center - target.Center)
.SafeNormalize(-axis)
: -axis;
for (int index = 0; index < 5; index++)
{
Dust soul = Dust.NewDustPerfect(
target.Center + Main.rand.NextVector2Circular(11f, 11f),
DustID.AncientLight,
towardOwner.RotatedBy(Main.rand.NextFloat(-0.34f, 0.34f))
* Main.rand.NextFloat(1.4f, 3.2f),
80, new Color(85, 225, 235),
Main.rand.NextFloat(0.62f, 0.92f));
soul.noGravity = true;
}
}
public override bool PreDraw(ref Color lightColor)
{
if (!configured || age < delay || age > delay + 7)
{
if (!IsDeathUltimateWorldCut)
return false;
}
if (IsDeathUltimateWorldCut)
{
DrawDeathUltimateShatterFragments();
return false;
}
// Void circle strikes are the invisible collision sweep of the held
// weapon. Its exact blade-tip tear is already drawn by the shared trail;
// a conventional purple ring would contradict the cut-out background.
if (snapshot.Form == ReaperFormId.Void && shape == ReaperStrikeShape.Circle)
return false;
float opacity = 1f - (age - delay) / 8f;
Color outer = ReaperCombatRegistry.GetPrimaryColor(snapshot.Form) with { A = 0 };
Color core = ReaperCombatRegistry.GetSecondaryColor(snapshot.Form) with { A = 0 };
Vector2 direction = Projectile.velocity.SafeNormalize(Vector2.UnitX);
if (shape == ReaperStrikeShape.Circle)
{
const int segments = 28;
Vector2 previous = Projectile.Center + Vector2.UnitX * length;
for (int index = 1; index <= segments; index++)
{
Vector2 next = Projectile.Center + (MathHelper.TwoPi * index / segments).ToRotationVector2() * length;
DrawLine(previous, next, width * 0.42f, outer * opacity);
DrawLine(previous, next, Math.Max(2f, width * 0.12f), core * opacity);
previous = next;
}
}
else
{
DrawSlash(direction, outer, core, opacity);
if (shape == ReaperStrikeShape.Cross)
DrawSlash(direction.RotatedBy(MathHelper.PiOver2), outer, core, opacity);
}
DrawEnergyMotes(direction, core, opacity);
return false;
}
public override void SendExtraAI(BinaryWriter writer)
{
writer.Write(configured);
if (!configured)
return;
snapshot.Write(writer);
writer.Write((byte)hitKind);
writer.Write((byte)shape);
writer.Write((byte)Math.Clamp(phase, 0, byte.MaxValue));
writer.Write((short)Math.Clamp(delay, 0, short.MaxValue));
writer.Write(length);
writer.Write(width);
}
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);
ReaperHitKind incomingHitKind = (ReaperHitKind)reader.ReadByte();
ReaperStrikeShape incomingShape = (ReaperStrikeShape)reader.ReadByte();
int incomingPhase = reader.ReadByte();
int incomingDelay = reader.ReadInt16();
float incomingLength = reader.ReadSingle();
float incomingWidth = reader.ReadSingle();
if (Main.netMode == NetmodeID.Server)
return;
configured = true;
snapshot = incomingSnapshot;
hitKind = incomingHitKind;
shape = incomingShape;
phase = incomingPhase;
delay = incomingDelay;
length = incomingLength;
width = incomingWidth;
}
private void Configure(in SickleCombatSnapshot value, ReaperHitKind kind, int hitPhase,
ReaperStrikeShape strikeShape, float strikeLength, float strikeWidth, int strikeDelay)
{
snapshot = value;
hitKind = kind;
phase = hitPhase;
shape = strikeShape;
length = Math.Max(8f, strikeLength);
width = Math.Max(2f, strikeWidth);
delay = Math.Max(0, strikeDelay);
configured = true;
serverAuthorized = Main.netMode != NetmodeID.MultiplayerClient;
serverDamageTriggered = false;
authoritativeDamage = Math.Max(1, Projectile.damage);
authoritativeCenter = Projectile.Center;
authoritativeVelocity = Projectile.velocity;
Projectile.timeLeft = delay + 10;
}
private bool LineIntersects(Rectangle targetHitbox, Vector2 direction)
{
Vector2 start = shape == ReaperStrikeShape.Cross ? Projectile.Center - direction * length : Projectile.Center;
Vector2 end = Projectile.Center + direction * length;
float collisionPoint = 0f;
return Collision.CheckAABBvLineCollision(targetHitbox.TopLeft(), targetHitbox.Size(), start, end, width, ref collisionPoint);
}
private static bool CircleIntersects(Rectangle target, Vector2 center, float radius)
{
float closestX = MathHelper.Clamp(center.X, target.Left, target.Right);
float closestY = MathHelper.Clamp(center.Y, target.Top, target.Bottom);
return Vector2.DistanceSquared(center, new Vector2(closestX, closestY)) <= radius * radius;
}
private void DrawSlash(Vector2 direction, Color outer, Color core, float opacity)
{
Vector2 start = shape == ReaperStrikeShape.Cross ? Projectile.Center - direction * length : Projectile.Center;
Vector2 end = Projectile.Center + direction * length;
DrawLine(start, end, width, outer * opacity * 0.72f);
DrawLine(start, end, Math.Max(2f, width * 0.22f), core * opacity);
}
private bool IsDeathUltimateWorldCut => configured
&& snapshot.Form == ReaperFormId.Death
&& hitKind == ReaperHitKind.Ultimate
&& shape == ReaperStrikeShape.Line;
private int GetVisualLifetime()
{
if (!IsDeathUltimateWorldCut)
return delay + 8;
// Every cut remains fixed in the world until the shared shatter tick,
// then fractures for the rest of the controller timeline.
return Math.Max(18, ReaperDeathUltimateGeometry.ShatterTick
- ReaperDeathUltimateGeometry.GetCutTick(phase) + 18);
}
private void RecordDeathUltimateWorldCut(int lifetime)
{
Vector2 direction = Projectile.velocity.SafeNormalize(Vector2.UnitX);
for (int index = 0; index < deathCutPoints.Length; index++)
{
deathCutPoints[index] = Projectile.Center + direction * length
* (index / (float)(deathCutPoints.Length - 1));
}
float completion = Smooth01(age / 4f);
float fracture = Smooth01(MathHelper.Clamp(
(age - (lifetime - 18f)) / 18f, 0f, 1f));
float terminalFade = 1f - Smooth01(MathHelper.Clamp(
(fracture - 0.78f) / 0.22f, 0f, 1f));
DeathDomainTrailVisualSystem.Record(Projectile.owner,
Projectile.identity, deathCutPoints,
Math.Max(ReaperDeathUltimateGeometry.CutVisualWidth, width * 3.1f),
completion * terminalFade * 0.98f,
mergeOverlappingRims: true, fracture: fracture);
}
private void DrawDeathUltimateShatterFragments()
{
int lifetime = GetVisualLifetime();
float fracture = Smooth01(MathHelper.Clamp(
(age - (lifetime - 18f)) / 18f, 0f, 1f));
if (fracture <= 0.001f)
return;
Vector2 axis = Projectile.velocity.SafeNormalize(Vector2.UnitX);
Vector2 normal = axis.RotatedBy(MathHelper.PiOver2);
for (int shard = 0; shard < 7; shard++)
{
uint hash = unchecked((uint)(Projectile.identity * 747796405
+ shard * 2891336453 + phase * 97));
hash ^= hash >> 16;
float along = 0.08f + (hash & 0xFFFFu) / 65535f * 0.84f;
float side = (shard & 1) == 0 ? -1f : 1f;
float tilt = 0.22f + ((hash >> 16) & 0xFFu) / 255f * 0.48f;
Vector2 origin = Projectile.Center + axis * length * along;
Vector2 direction = (normal * side + axis * tilt)
.SafeNormalize(normal * side);
float branchLength = (22f + shard % 3 * 9f)
* MathHelper.Lerp(0.35f, 1f, fracture);
Vector2 end = origin + direction * branchLength;
DeathDomainPrimitiveTextureSystem.DrawSmoothWorldLine(origin, end,
new Color(30, 0, 14) * (fracture * 0.84f), 5.5f);
DeathDomainPrimitiveTextureSystem.DrawSmoothWorldLine(origin, end,
new Color(255, 34, 82) * (fracture * 0.78f), 1.7f);
}
}
private static float Smooth01(float value)
{
value = MathHelper.Clamp(value, 0f, 1f);
return value * value * (3f - 2f * value);
}
private static void DrawLine(Vector2 start, Vector2 end, float drawWidth, Color color)
{
DeathDomainPrimitiveTextureSystem.DrawSmoothWorldLine(start, end, color, drawWidth);
}
private void DrawEnergyMotes(Vector2 direction, Color color, float opacity)
{
int motes = shape == ReaperStrikeShape.Circle ? 5 : 3;
for (int index = 0; index < motes; index++)
{
float travel = (age * 0.13f + index / (float)motes + phase * 0.071f) % 1f;
Vector2 point;
if (shape == ReaperStrikeShape.Circle)
{
float angle = travel * MathHelper.TwoPi + phase * 0.19f;
point = Projectile.Center + angle.ToRotationVector2() * length;
}
else
{
Vector2 start = shape == ReaperStrikeShape.Cross
? Projectile.Center - direction * length
: Projectile.Center;
Vector2 end = Projectile.Center + direction * length;
point = Vector2.Lerp(start, end, travel);
if (shape == ReaperStrikeShape.Cross && (index & 1) != 0)
{
Vector2 cross = direction.RotatedBy(MathHelper.PiOver2);
point = Vector2.Lerp(Projectile.Center - cross * length,
Projectile.Center + cross * length, travel);
}
}
float radius = 1.8f + (index % 2) * 0.8f;
DeathDomainPrimitiveTextureSystem.DrawSmoothDisc(Main.spriteBatch,
point - Main.screenPosition, radius + 2.2f,
color * (opacity * 0.25f));
DeathDomainPrimitiveTextureSystem.DrawSmoothDisc(Main.spriteBatch,
point - Main.screenPosition, radius,
color * (opacity * 0.82f));
}
}
private void SpawnBurst()
{
Color color = ReaperCombatRegistry.GetPrimaryColor(snapshot.Form);
int dustType = ReaperCombatRegistry.GetDust(snapshot.Form);
int count = shape == ReaperStrikeShape.Circle ? 24 : 14;
for (int index = 0; index < count; index++)
{
Vector2 velocity = (MathHelper.TwoPi * index / count).ToRotationVector2() * Main.rand.NextFloat(1.2f, 4.2f);
Dust dust = Dust.NewDustPerfect(Projectile.Center, dustType, velocity, 70, color, 0.9f);
dust.noGravity = true;
}
}
}
using SoulHarvest.Common;
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 ReaperStrikeProjectile : ModProjectile
{
private const int DeathCutPointCount = 65;
private readonly Vector2[] deathCutPoints = new Vector2[DeathCutPointCount];
private SickleCombatSnapshot snapshot;
private ReaperHitKind hitKind;
private ReaperStrikeShape shape;
private int phase;
private int delay;
private int age;
private float length;
private float width;
private bool configured;
private bool serverAuthorized;
private bool serverDamageTriggered;
private int authoritativeDamage;
private Vector2 authoritativeCenter;
private Vector2 authoritativeVelocity;
public override string Texture => "Terraria/Images/Projectile_0";
public static int Spawn(Terraria.DataStructures.IEntitySource source, int owner,
in SickleCombatSnapshot snapshot, ReaperHitKind hitKind, int phase,
Vector2 origin, Vector2 direction, ReaperStrikeShape shape, float length, float width,
float damageMultiplier, int delay = 0, int actionId = -1)
{
if (Main.netMode == NetmodeID.MultiplayerClient)
return -1;
direction = direction.SafeNormalize(Vector2.UnitX);
int index = Projectile.NewProjectile(source, origin, direction, ModContent.ProjectileType<ReaperStrikeProjectile>(),
Math.Max(1, (int)Math.Round(snapshot.Damage * damageMultiplier)), snapshot.Knockback, owner);
if (index < 0 || index >= Main.maxProjectiles)
return -1;
Projectile projectile = Main.projectile[index];
projectile.originalDamage = snapshot.Damage;
projectile.CritChance = hitKind == ReaperHitKind.Special ? snapshot.CritChance : 0;
if (projectile.ModProjectile is ReaperStrikeProjectile strike)
strike.Configure(snapshot, hitKind, phase, shape, length, width, delay);
projectile.GetGlobalProjectile<MyGlobalProjectile>().ConfigureReaperProjectile(
projectile, snapshot, hitKind, phase, direction.ToRotation(), actionId);
ReaperProjectileHelper.SyncNewProjectile(projectile);
return index;
}
public override void SetDefaults()
{
Projectile.width = 4;
Projectile.height = 4;
Projectile.friendly = true;
Projectile.hostile = false;
Projectile.tileCollide = false;
Projectile.ignoreWater = true;
Projectile.penetrate = -1;
Projectile.timeLeft = 240;
// This projectile owns the client-side slash telegraph. `hide` projectiles
// are not submitted to Terraria's normal projectile draw cache unless a
// DrawBehind hook explicitly adds them, so PreDraw never ran for any of the
// server-authoritative form strikes.
Projectile.hide = false;
Projectile.netImportant = true;
Projectile.DamageType = DamageClass.Melee;
Projectile.usesLocalNPCImmunity = true;
Projectile.localNPCHitCooldown = 10;
}
public override bool ShouldUpdatePosition() => false;
// The complete visible slash window is authoritative. Local immunity is
// longer than the remaining lifetime, so each actor still hits a target once
// instead of leaving five decorative frames with no collision.
public override bool? CanDamage()
{
// ReaperStrike is created by the server but retains the casting player's
// owner for attribution. Terraria normally lets that owning client run
// friendly-projectile collision, which makes the slash look successful
// locally while the dedicated server never executes the Reaper hit hooks.
// The server explicitly calls Damage below; clients are presentation-only.
if (Main.netMode == NetmodeID.MultiplayerClient)
return false;
return configured && age >= delay && age < delay + 8 ? null : false;
}
public override void AI()
{
if (Main.netMode == NetmodeID.Server)
{
if (!serverAuthorized)
{
Projectile.Kill();
return;
}
Projectile.damage = authoritativeDamage;
Projectile.Center = authoritativeCenter;
Projectile.velocity = authoritativeVelocity;
}
if (!configured)
return;
age++;
if (Main.netMode == NetmodeID.Server && !serverDamageTriggered
&& age >= Math.Max(1, delay))
{
serverDamageTriggered = true;
ReaperProjectileHelper.DamageOnDedicatedServer(Projectile);
}
int visualLifetime = GetVisualLifetime();
if (IsDeathUltimateWorldCut && Main.netMode != NetmodeID.Server)
RecordDeathUltimateWorldCut(visualLifetime);
Projectile.timeLeft = Math.Max(2, visualLifetime + 1 - age);
if (age == Math.Max(1, delay) && Main.netMode != NetmodeID.Server)
SpawnBurst();
if (age >= visualLifetime)
Projectile.Kill();
}
public override bool? Colliding(Rectangle projHitbox, Rectangle targetHitbox)
{
Vector2 direction = Projectile.velocity.SafeNormalize(Vector2.UnitX);
return shape switch
{
ReaperStrikeShape.Circle => CircleIntersects(targetHitbox, Projectile.Center, length),
ReaperStrikeShape.Cross => LineIntersects(targetHitbox, direction) || LineIntersects(targetHitbox, direction.RotatedBy(MathHelper.PiOver2)),
_ => LineIntersects(targetHitbox, direction)
};
}
public override void OnHitNPC(NPC target, NPC.HitInfo hit, int damageDone)
{
if (Main.netMode == NetmodeID.Server || !configured
|| snapshot.Form != ReaperFormId.Base
|| hitKind != ReaperHitKind.Special)
{
return;
}
Vector2 axis = Projectile.velocity.SafeNormalize(Vector2.UnitX);
Vector2 slash = axis.RotatedBy(-0.48f)
.SafeNormalize(Vector2.UnitY);
ReaperVfxDirector.TriggerGlobalImpact(slash, 3.2f, 4,
new Color(90, 220, 230), 0f, 0, 0.08f);
SoundEngine.PlaySound(SoundID.Item71 with
{
Volume = 0.52f,
Pitch = 0.3f,
PitchVariance = 0.07f
}, target.Center);
for (int index = 0; index < 13; index++)
{
Vector2 velocity = slash.RotatedBy(
Main.rand.NextFloat(-0.68f, 0.68f))
* Main.rand.NextFloat(3f, 7.8f);
Dust dust = Dust.NewDustPerfect(
target.Center + Main.rand.NextVector2Circular(8f, 8f),
DustID.AncientLight, velocity, 25,
index % 4 == 0 ? new Color(225, 255, 250)
: new Color(65, 220, 235),
Main.rand.NextFloat(0.78f, 1.16f));
dust.noGravity = true;
}
Vector2 towardOwner = Projectile.owner >= 0
&& Projectile.owner < Main.maxPlayers
? (Main.player[Projectile.owner].Center - target.Center)
.SafeNormalize(-axis)
: -axis;
for (int index = 0; index < 5; index++)
{
Dust soul = Dust.NewDustPerfect(
target.Center + Main.rand.NextVector2Circular(11f, 11f),
DustID.AncientLight,
towardOwner.RotatedBy(Main.rand.NextFloat(-0.34f, 0.34f))
* Main.rand.NextFloat(1.4f, 3.2f),
80, new Color(85, 225, 235),
Main.rand.NextFloat(0.62f, 0.92f));
soul.noGravity = true;
}
}
public override bool PreDraw(ref Color lightColor)
{
if (!configured || age < delay || age > delay + 7)
{
if (!IsDeathUltimateWorldCut)
return false;
}
if (IsDeathUltimateWorldCut)
{
DrawDeathUltimateShatterFragments();
return false;
}
// Void circle strikes are the invisible collision sweep of the held
// weapon. Its exact blade-tip tear is already drawn by the shared trail;
// a conventional purple ring would contradict the cut-out background.
if (snapshot.Form == ReaperFormId.Void && shape == ReaperStrikeShape.Circle)
return false;
float opacity = 1f - (age - delay) / 8f;
Color outer = ReaperCombatRegistry.GetPrimaryColor(snapshot.Form) with { A = 0 };
Color core = ReaperCombatRegistry.GetSecondaryColor(snapshot.Form) with { A = 0 };
Vector2 direction = Projectile.velocity.SafeNormalize(Vector2.UnitX);
if (shape == ReaperStrikeShape.Circle)
{
const int segments = 28;
Vector2 previous = Projectile.Center + Vector2.UnitX * length;
for (int index = 1; index <= segments; index++)
{
Vector2 next = Projectile.Center + (MathHelper.TwoPi * index / segments).ToRotationVector2() * length;
DrawLine(previous, next, width * 0.42f, outer * opacity);
DrawLine(previous, next, Math.Max(2f, width * 0.12f), core * opacity);
previous = next;
}
}
else
{
DrawSlash(direction, outer, core, opacity);
if (shape == ReaperStrikeShape.Cross)
DrawSlash(direction.RotatedBy(MathHelper.PiOver2), outer, core, opacity);
}
DrawEnergyMotes(direction, core, opacity);
return false;
}
public override void SendExtraAI(BinaryWriter writer)
{
writer.Write(configured);
if (!configured)
return;
snapshot.Write(writer);
writer.Write((byte)hitKind);
writer.Write((byte)shape);
writer.Write((byte)Math.Clamp(phase, 0, byte.MaxValue));
writer.Write((short)Math.Clamp(delay, 0, short.MaxValue));
writer.Write(length);
writer.Write(width);
}
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);
ReaperHitKind incomingHitKind = (ReaperHitKind)reader.ReadByte();
ReaperStrikeShape incomingShape = (ReaperStrikeShape)reader.ReadByte();
int incomingPhase = reader.ReadByte();
int incomingDelay = reader.ReadInt16();
float incomingLength = reader.ReadSingle();
float incomingWidth = reader.ReadSingle();
if (Main.netMode == NetmodeID.Server)
return;
configured = true;
snapshot = incomingSnapshot;
hitKind = incomingHitKind;
shape = incomingShape;
phase = incomingPhase;
delay = incomingDelay;
length = incomingLength;
width = incomingWidth;
}
private void Configure(in SickleCombatSnapshot value, ReaperHitKind kind, int hitPhase,
ReaperStrikeShape strikeShape, float strikeLength, float strikeWidth, int strikeDelay)
{
snapshot = value;
hitKind = kind;
phase = hitPhase;
shape = strikeShape;
length = Math.Max(8f, strikeLength);
width = Math.Max(2f, strikeWidth);
delay = Math.Max(0, strikeDelay);
configured = true;
serverAuthorized = Main.netMode != NetmodeID.MultiplayerClient;
serverDamageTriggered = false;
authoritativeDamage = Math.Max(1, Projectile.damage);
authoritativeCenter = Projectile.Center;
authoritativeVelocity = Projectile.velocity;
Projectile.timeLeft = delay + 10;
}
private bool LineIntersects(Rectangle targetHitbox, Vector2 direction)
{
Vector2 start = shape == ReaperStrikeShape.Cross ? Projectile.Center - direction * length : Projectile.Center;
Vector2 end = Projectile.Center + direction * length;
float collisionPoint = 0f;
return Collision.CheckAABBvLineCollision(targetHitbox.TopLeft(), targetHitbox.Size(), start, end, width, ref collisionPoint);
}
private static bool CircleIntersects(Rectangle target, Vector2 center, float radius)
{
float closestX = MathHelper.Clamp(center.X, target.Left, target.Right);
float closestY = MathHelper.Clamp(center.Y, target.Top, target.Bottom);
return Vector2.DistanceSquared(center, new Vector2(closestX, closestY)) <= radius * radius;
}
private void DrawSlash(Vector2 direction, Color outer, Color core, float opacity)
{
Vector2 start = shape == ReaperStrikeShape.Cross ? Projectile.Center - direction * length : Projectile.Center;
Vector2 end = Projectile.Center + direction * length;
DrawLine(start, end, width, outer * opacity * 0.72f);
DrawLine(start, end, Math.Max(2f, width * 0.22f), core * opacity);
}
private bool IsDeathUltimateWorldCut => configured
&& snapshot.Form == ReaperFormId.Death
&& hitKind == ReaperHitKind.Ultimate
&& shape == ReaperStrikeShape.Line;
private int GetVisualLifetime()
{
if (!IsDeathUltimateWorldCut)
return delay + 8;
// Every cut remains fixed in the world until the shared shatter tick,
// then fractures for the rest of the controller timeline.
return Math.Max(18, ReaperDeathUltimateGeometry.ShatterTick
- ReaperDeathUltimateGeometry.GetCutTick(phase) + 18);
}
private void RecordDeathUltimateWorldCut(int lifetime)
{
Vector2 direction = Projectile.velocity.SafeNormalize(Vector2.UnitX);
for (int index = 0; index < deathCutPoints.Length; index++)
{
deathCutPoints[index] = Projectile.Center + direction * length
* (index / (float)(deathCutPoints.Length - 1));
}
float completion = Smooth01(age / 4f);
float fracture = Smooth01(MathHelper.Clamp(
(age - (lifetime - 18f)) / 18f, 0f, 1f));
float terminalFade = 1f - Smooth01(MathHelper.Clamp(
(fracture - 0.78f) / 0.22f, 0f, 1f));
DeathDomainTrailVisualSystem.Record(Projectile.owner,
Projectile.identity, deathCutPoints,
Math.Max(ReaperDeathUltimateGeometry.CutVisualWidth, width * 3.1f),
completion * terminalFade * 0.98f,
mergeOverlappingRims: true, fracture: fracture);
}
private void DrawDeathUltimateShatterFragments()
{
int lifetime = GetVisualLifetime();
float fracture = Smooth01(MathHelper.Clamp(
(age - (lifetime - 18f)) / 18f, 0f, 1f));
if (fracture <= 0.001f)
return;
Vector2 axis = Projectile.velocity.SafeNormalize(Vector2.UnitX);
Vector2 normal = axis.RotatedBy(MathHelper.PiOver2);
for (int shard = 0; shard < 7; shard++)
{
uint hash = unchecked((uint)(Projectile.identity * 747796405
+ shard * 2891336453 + phase * 97));
hash ^= hash >> 16;
float along = 0.08f + (hash & 0xFFFFu) / 65535f * 0.84f;
float side = (shard & 1) == 0 ? -1f : 1f;
float tilt = 0.22f + ((hash >> 16) & 0xFFu) / 255f * 0.48f;
Vector2 origin = Projectile.Center + axis * length * along;
Vector2 direction = (normal * side + axis * tilt)
.SafeNormalize(normal * side);
float branchLength = (22f + shard % 3 * 9f)
* MathHelper.Lerp(0.35f, 1f, fracture);
Vector2 end = origin + direction * branchLength;
DeathDomainPrimitiveTextureSystem.DrawSmoothWorldLine(origin, end,
new Color(30, 0, 14) * (fracture * 0.84f), 5.5f);
DeathDomainPrimitiveTextureSystem.DrawSmoothWorldLine(origin, end,
new Color(255, 34, 82) * (fracture * 0.78f), 1.7f);
}
}
private static float Smooth01(float value)
{
value = MathHelper.Clamp(value, 0f, 1f);
return value * value * (3f - 2f * value);
}
private static void DrawLine(Vector2 start, Vector2 end, float drawWidth, Color color)
{
DeathDomainPrimitiveTextureSystem.DrawSmoothWorldLine(start, end, color, drawWidth);
}
private void DrawEnergyMotes(Vector2 direction, Color color, float opacity)
{
int motes = shape == ReaperStrikeShape.Circle ? 5 : 3;
for (int index = 0; index < motes; index++)
{
float travel = (age * 0.13f + index / (float)motes + phase * 0.071f) % 1f;
Vector2 point;
if (shape == ReaperStrikeShape.Circle)
{
float angle = travel * MathHelper.TwoPi + phase * 0.19f;
point = Projectile.Center + angle.ToRotationVector2() * length;
}
else
{
Vector2 start = shape == ReaperStrikeShape.Cross
? Projectile.Center - direction * length
: Projectile.Center;
Vector2 end = Projectile.Center + direction * length;
point = Vector2.Lerp(start, end, travel);
if (shape == ReaperStrikeShape.Cross && (index & 1) != 0)
{
Vector2 cross = direction.RotatedBy(MathHelper.PiOver2);
point = Vector2.Lerp(Projectile.Center - cross * length,
Projectile.Center + cross * length, travel);
}
}
float radius = 1.8f + (index % 2) * 0.8f;
DeathDomainPrimitiveTextureSystem.DrawSmoothDisc(Main.spriteBatch,
point - Main.screenPosition, radius + 2.2f,
color * (opacity * 0.25f));
DeathDomainPrimitiveTextureSystem.DrawSmoothDisc(Main.spriteBatch,
point - Main.screenPosition, radius,
color * (opacity * 0.82f));
}
}
private void SpawnBurst()
{
Color color = ReaperCombatRegistry.GetPrimaryColor(snapshot.Form);
int dustType = ReaperCombatRegistry.GetDust(snapshot.Form);
int count = shape == ReaperStrikeShape.Circle ? 24 : 14;
for (int index = 0; index < count; index++)
{
Vector2 velocity = (MathHelper.TwoPi * index / count).ToRotationVector2() * Main.rand.NextFloat(1.2f, 4.2f);
Dust dust = Dust.NewDustPerfect(Projectile.Center, dustType, velocity, 70, color, 0.9f);
dust.noGravity = true;
}
}
}