using SoulHarvest.Common;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using ReLogic.Content;
using System;
using System.Collections.Generic;
using System.IO;
using Terraria;
using Terraria.ID;
using Terraria.ModLoader;
namespace SoulHarvest.Projectiles;
/// <summary>
/// Shared Blood crescent projectile for primary attacks and the ultimate. Every
/// blade records its real world-space route; that exact route remains as the
/// damaging blood wound instead of being replaced by a guessed straight line.
/// </summary>
public sealed class ReaperBloodBladeProjectile : ModProjectile
{
private const int MaximumTrailPoints = 240;
private const int HomingDelayFrames = 10;
private const float TargetSearchRadius = 560f;
private const float TargetRetentionRadius = 720f;
private readonly List<ReaperBloodBladeTrailPoint> trail = [];
private SickleCombatSnapshot snapshot;
private ReaperHitKind hitKind;
private int bladeIndex;
private int actionId;
private int targetIndex = -1;
private int pass;
private int passTimer;
private int maximumPasses;
private int scarLifetime;
private int activeTimer;
private bool lingering;
private bool configured;
private bool serverAuthorized;
public override string Texture => "Terraria/Images/Projectile_0";
internal static int Spawn(Terraria.DataStructures.IEntitySource source, int owner,
in SickleCombatSnapshot snapshot, Vector2 position, Vector2 velocity,
int bladeIndex, int actionId, ReaperHitKind hitKind = ReaperHitKind.Ultimate,
float damageMultiplier = 0.28f, int maximumPasses = 4, int scarLifetime = 180)
{
if (Main.netMode == NetmodeID.MultiplayerClient)
return -1;
int index = Projectile.NewProjectile(source, position, velocity,
ModContent.ProjectileType<ReaperBloodBladeProjectile>(),
Math.Max(1, (int)Math.Round(snapshot.Damage * damageMultiplier)),
snapshot.Knockback * 0.38f, owner);
if (index < 0 || index >= Main.maxProjectiles)
return -1;
Projectile projectile = Main.projectile[index];
projectile.originalDamage = snapshot.Damage;
projectile.CritChance = snapshot.CritChance;
if (projectile.ModProjectile is ReaperBloodBladeProjectile blade)
blade.Configure(snapshot, bladeIndex, actionId, hitKind, maximumPasses, scarLifetime);
projectile.GetGlobalProjectile<MyGlobalProjectile>().ConfigureReaperProjectile(
projectile, snapshot, hitKind, bladeIndex, velocity.ToRotation(), actionId);
projectile.localNPCHitCooldown = 24;
ReaperProjectileHelper.SyncNewProjectile(projectile);
return index;
}
internal static int GetScarLifetime(int skillLevel) => skillLevel switch
{
>= 3 => 300,
2 => 210,
1 => 150,
_ => 120
};
public override void SetDefaults()
{
Projectile.width = 34;
Projectile.height = 34;
Projectile.friendly = true;
Projectile.hostile = false;
Projectile.tileCollide = false;
Projectile.ignoreWater = true;
Projectile.penetrate = -1;
Projectile.timeLeft = 480;
Projectile.netImportant = true;
Projectile.DamageType = DamageClass.Melee;
Projectile.usesLocalNPCImmunity = true;
Projectile.localNPCHitCooldown = 24;
}
public override bool? CanDamage()
=> configured && (!lingering || trail.Count >= 2) ? null : false;
public override void AI()
{
if (Main.netMode == NetmodeID.Server && !serverAuthorized)
{
Projectile.Kill();
return;
}
if (!configured)
return;
AgeTrail();
if (lingering)
{
Projectile.velocity = Vector2.Zero;
Projectile.timeLeft = 2;
if (trail.Count < 2)
Projectile.Kill();
return;
}
activeTimer++;
RecordTrailPoint(Projectile.Center);
NPC? target = GetOrFindTarget();
if (target is null)
{
Projectile.velocity *= 0.985f;
Projectile.rotation += 0.32f;
if (activeTimer >= 70)
BeginLingering();
return;
}
passTimer++;
float spacing = MathHelper.TwoPi / Math.Max(3, maximumPasses + 2);
float angle = bladeIndex * spacing + pass * MathHelper.Pi + (pass & 1) * 0.34f;
Vector2 attackAxis = angle.ToRotationVector2();
Vector2 destination = target.Center + attackAxis * 148f;
Vector2 desired = (destination - Projectile.Center).SafeNormalize(Projectile.velocity)
* (22f + pass * 1.25f);
Projectile.velocity = Vector2.Lerp(Projectile.velocity, desired, 0.16f);
int spinDirection = Math.Sign(Projectile.velocity.X);
if (spinDirection == 0)
spinDirection = 1;
Projectile.rotation += 0.34f * spinDirection;
Projectile.GetGlobalProjectile<MyGlobalProjectile>()
.UpdateReaperAttackAngle(Projectile.velocity.ToRotation());
if (Vector2.DistanceSquared(Projectile.Center, destination) <= 34f * 34f
|| passTimer >= 31)
{
CompletePass();
}
}
public override bool? Colliding(Rectangle projHitbox, Rectangle targetHitbox)
{
if (!configured)
return false;
// The visible head and every already-recorded span are live at the same
// time. ModifyHitNPC separates their 75%/25% damage budgets.
if (!lingering && projHitbox.Intersects(targetHitbox))
return true;
if (trail.Count < 2)
return false;
float collisionPoint = 0f;
for (int index = 1; index < trail.Count; index++)
{
Vector2 start = trail[index - 1].Position;
Vector2 end = trail[index].Position;
if (Vector2.DistanceSquared(start, end) > 180f * 180f)
continue;
if (Collision.CheckAABBvLineCollision(targetHitbox.TopLeft(), targetHitbox.Size(),
start, end, 11f, ref collisionPoint))
{
return true;
}
}
return false;
}
public override void ModifyHitNPC(NPC target, ref NPC.HitModifiers modifiers)
{
bool directHeadHit = !lingering && Projectile.Hitbox.Intersects(target.Hitbox);
if (!directHeadHit && !lingering)
{
// The flying actor is configured at 75%; one third of that is the
// requested 25% base damage for an already-carved trail span.
modifiers.SourceDamage *= 1f / 3f;
}
}
public override void OnHitNPC(NPC target, NPC.HitInfo hit, int damageDone)
{
bool directHeadHit = !lingering && Projectile.Hitbox.Intersects(target.Hitbox);
// Residual ticks use a shorter gate so a target can still be struck by the
// approaching 75% blade head after touching an older crossing trail.
Projectile.localNPCImmunity[target.whoAmI] = directHeadHit ? 24 : 8;
}
public override bool PreDraw(ref Color lightColor)
{
if (!configured || Main.dedServ)
return false;
DrawRecordedBloodScar();
if (lingering)
return false;
// Reuse the original primary-attack crescent projectile artwork. This is
// an energy blade, not a duplicated Blood scythe weapon model.
Asset<Texture2D> asset = ModContent.Request<Texture2D>(
"SoulHarvest/Projectiles/BloodCrescentProjectile");
Texture2D texture = asset.Value;
Vector2 origin = texture.Size() * 0.5f;
float rotation = Projectile.rotation;
Main.EntitySpriteDraw(texture, Projectile.Center - Main.screenPosition, null,
new Color(245, 25, 55, 0) * 0.38f, rotation, origin, 1.10f,
SpriteEffects.None);
Main.EntitySpriteDraw(texture, Projectile.Center - Main.screenPosition, null,
Color.White, rotation, origin, 0.92f, SpriteEffects.None);
return false;
}
public override void SendExtraAI(BinaryWriter writer)
{
writer.Write(configured);
if (!configured)
return;
snapshot.Write(writer);
writer.Write((byte)hitKind);
writer.Write((byte)Math.Clamp(bladeIndex, 0, byte.MaxValue));
writer.Write(actionId);
writer.Write((short)targetIndex);
writer.Write((byte)Math.Clamp(pass, 0, byte.MaxValue));
writer.Write((byte)Math.Clamp(passTimer, 0, byte.MaxValue));
writer.Write((byte)Math.Clamp(maximumPasses, 1, byte.MaxValue));
writer.Write((short)Math.Clamp(scarLifetime, 1, short.MaxValue));
writer.Write((short)Math.Clamp(activeTimer, 0, short.MaxValue));
writer.Write(lingering);
}
public override void ReceiveExtraAI(BinaryReader reader)
{
bool incoming = reader.ReadBoolean();
if (!incoming)
{
if (Main.netMode != NetmodeID.Server)
configured = false;
return;
}
SickleCombatSnapshot incomingSnapshot = SickleCombatSnapshot.Read(reader);
ReaperHitKind incomingHitKind = (ReaperHitKind)reader.ReadByte();
int incomingBlade = reader.ReadByte();
int incomingAction = reader.ReadInt32();
int incomingTarget = reader.ReadInt16();
int incomingPass = reader.ReadByte();
int incomingPassTimer = reader.ReadByte();
int incomingMaximumPasses = reader.ReadByte();
int incomingScarLifetime = reader.ReadInt16();
int incomingActiveTimer = reader.ReadInt16();
bool incomingLingering = reader.ReadBoolean();
if (Main.netMode == NetmodeID.Server)
return;
snapshot = incomingSnapshot;
hitKind = incomingHitKind;
bladeIndex = incomingBlade;
actionId = incomingAction;
targetIndex = incomingTarget;
pass = incomingPass;
passTimer = incomingPassTimer;
maximumPasses = Math.Max(1, incomingMaximumPasses);
scarLifetime = incomingScarLifetime;
activeTimer = incomingActiveTimer;
lingering = incomingLingering;
configured = true;
}
private void Configure(in SickleCombatSnapshot value, int index, int parentActionId,
ReaperHitKind kind, int passCount, int residualLifetime)
{
snapshot = value;
hitKind = kind;
bladeIndex = Math.Max(0, index);
actionId = parentActionId;
targetIndex = -1;
pass = 0;
passTimer = 0;
maximumPasses = Math.Clamp(passCount, 1, 8);
scarLifetime = Math.Clamp(residualLifetime, 60, 300);
activeTimer = 0;
lingering = false;
trail.Clear();
RecordTrailPoint(Projectile.Center);
configured = true;
serverAuthorized = Main.netMode != NetmodeID.MultiplayerClient;
}
private NPC? GetOrFindTarget()
{
if (targetIndex >= 0 && targetIndex < Main.maxNPCs)
{
NPC current = Main.npc[targetIndex];
if (ReaperTargeting.IsValidWeaponTarget(current)
&& (ReaperTargeting.IsTrainingDummy(current)
|| current.CanBeChasedBy(Projectile))
&& Vector2.DistanceSquared(Projectile.Center, current.Center)
<= TargetRetentionRadius * TargetRetentionRadius)
return current;
targetIndex = -1;
}
// Give the blade a readable launch phase, then acquire only from a local
// bubble centred on its current position. It no longer turns immediately
// toward an enemy which happened to exist far across the screen at spawn.
if (activeTimer < HomingDelayFrames)
return null;
NPC? target = ReaperProjectileHelper.FindTarget(Projectile,
TargetSearchRadius);
targetIndex = target?.whoAmI ?? -1;
if (Main.netMode != NetmodeID.MultiplayerClient)
Projectile.netUpdate = true;
return target;
}
private void CompletePass()
{
pass++;
passTimer = 0;
Projectile.netUpdate = true;
if (pass >= maximumPasses)
BeginLingering();
}
private void BeginLingering()
{
if (lingering)
return;
lingering = true;
Projectile.velocity = Vector2.Zero;
Projectile.damage = Math.Max(1, (int)Math.Round(snapshot.Damage * 0.25f));
Projectile.localNPCHitCooldown = 30;
Array.Fill(Projectile.localNPCImmunity, 0);
Projectile.netUpdate = true;
}
private void RecordTrailPoint(Vector2 position)
{
if (!float.IsFinite(position.X) || !float.IsFinite(position.Y))
return;
if (trail.Count > 0)
{
float distanceSquared = Vector2.DistanceSquared(trail[^1].Position, position);
if (distanceSquared < 4f * 4f)
{
trail[^1].Position = position;
trail[^1].Age = 0;
return;
}
if (distanceSquared > 200f * 200f)
{
trail.Add(new ReaperBloodBladeTrailPoint { Position = position, Age = 0 });
if (trail.Count > MaximumTrailPoints)
trail.RemoveAt(0);
return;
}
}
trail.Add(new ReaperBloodBladeTrailPoint { Position = position, Age = 0 });
if (trail.Count > MaximumTrailPoints)
trail.RemoveAt(0);
}
private void AgeTrail()
{
for (int index = trail.Count - 1; index >= 0; index--)
{
if (++trail[index].Age > scarLifetime)
trail.RemoveAt(index);
}
}
private void DrawRecordedBloodScar()
{
if (trail.Count < 2)
return;
int count = trail.Count;
for (int index = 1; index < count; index++)
{
ReaperBloodBladeTrailPoint older = trail[index - 1];
ReaperBloodBladeTrailPoint newer = trail[index];
if (Vector2.DistanceSquared(older.Position, newer.Position) > 180f * 180f)
continue;
float progress = (index - 0.5f) / Math.Max(1f, count - 1f);
float taper = (float)Math.Pow(Math.Max(0f, Math.Sin(progress * MathHelper.Pi)), 0.38f);
float age = Math.Max(older.Age, newer.Age);
float fade = 1f - Smooth01((age - scarLifetime * 0.72f)
/ Math.Max(1f, scarLifetime * 0.28f));
float wave = 1f + (float)Math.Sin(progress * 31f + actionId * 0.071f) * 0.07f;
float width = Math.Max(0.7f, 14f * taper * wave);
DrawScarStrip(older.Position, newer.Position, width, fade);
}
}
private static void DrawScarStrip(Vector2 start, Vector2 end, float width, float opacity)
=> ReaperBloodTrailRenderer.DrawSegment(start, end, width, opacity);
private static float Smooth01(float value)
{
value = MathHelper.Clamp(value, 0f, 1f);
return value * value * (3f - 2f * value);
}
}
using SoulHarvest.Common;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using ReLogic.Content;
using System;
using System.Collections.Generic;
using System.IO;
using Terraria;
using Terraria.ID;
using Terraria.ModLoader;
namespace SoulHarvest.Projectiles;
/// <summary>
/// Shared Blood crescent projectile for primary attacks and the ultimate. Every
/// blade records its real world-space route; that exact route remains as the
/// damaging blood wound instead of being replaced by a guessed straight line.
/// </summary>
public sealed class ReaperBloodBladeProjectile : ModProjectile
{
private const int MaximumTrailPoints = 240;
private const int HomingDelayFrames = 10;
private const float TargetSearchRadius = 560f;
private const float TargetRetentionRadius = 720f;
private readonly List<ReaperBloodBladeTrailPoint> trail = [];
private SickleCombatSnapshot snapshot;
private ReaperHitKind hitKind;
private int bladeIndex;
private int actionId;
private int targetIndex = -1;
private int pass;
private int passTimer;
private int maximumPasses;
private int scarLifetime;
private int activeTimer;
private bool lingering;
private bool configured;
private bool serverAuthorized;
public override string Texture => "Terraria/Images/Projectile_0";
internal static int Spawn(Terraria.DataStructures.IEntitySource source, int owner,
in SickleCombatSnapshot snapshot, Vector2 position, Vector2 velocity,
int bladeIndex, int actionId, ReaperHitKind hitKind = ReaperHitKind.Ultimate,
float damageMultiplier = 0.28f, int maximumPasses = 4, int scarLifetime = 180)
{
if (Main.netMode == NetmodeID.MultiplayerClient)
return -1;
int index = Projectile.NewProjectile(source, position, velocity,
ModContent.ProjectileType<ReaperBloodBladeProjectile>(),
Math.Max(1, (int)Math.Round(snapshot.Damage * damageMultiplier)),
snapshot.Knockback * 0.38f, owner);
if (index < 0 || index >= Main.maxProjectiles)
return -1;
Projectile projectile = Main.projectile[index];
projectile.originalDamage = snapshot.Damage;
projectile.CritChance = snapshot.CritChance;
if (projectile.ModProjectile is ReaperBloodBladeProjectile blade)
blade.Configure(snapshot, bladeIndex, actionId, hitKind, maximumPasses, scarLifetime);
projectile.GetGlobalProjectile<MyGlobalProjectile>().ConfigureReaperProjectile(
projectile, snapshot, hitKind, bladeIndex, velocity.ToRotation(), actionId);
projectile.localNPCHitCooldown = 24;
ReaperProjectileHelper.SyncNewProjectile(projectile);
return index;
}
internal static int GetScarLifetime(int skillLevel) => skillLevel switch
{
>= 3 => 300,
2 => 210,
1 => 150,
_ => 120
};
public override void SetDefaults()
{
Projectile.width = 34;
Projectile.height = 34;
Projectile.friendly = true;
Projectile.hostile = false;
Projectile.tileCollide = false;
Projectile.ignoreWater = true;
Projectile.penetrate = -1;
Projectile.timeLeft = 480;
Projectile.netImportant = true;
Projectile.DamageType = DamageClass.Melee;
Projectile.usesLocalNPCImmunity = true;
Projectile.localNPCHitCooldown = 24;
}
public override bool? CanDamage()
=> configured && (!lingering || trail.Count >= 2) ? null : false;
public override void AI()
{
if (Main.netMode == NetmodeID.Server && !serverAuthorized)
{
Projectile.Kill();
return;
}
if (!configured)
return;
AgeTrail();
if (lingering)
{
Projectile.velocity = Vector2.Zero;
Projectile.timeLeft = 2;
if (trail.Count < 2)
Projectile.Kill();
return;
}
activeTimer++;
RecordTrailPoint(Projectile.Center);
NPC? target = GetOrFindTarget();
if (target is null)
{
Projectile.velocity *= 0.985f;
Projectile.rotation += 0.32f;
if (activeTimer >= 70)
BeginLingering();
return;
}
passTimer++;
float spacing = MathHelper.TwoPi / Math.Max(3, maximumPasses + 2);
float angle = bladeIndex * spacing + pass * MathHelper.Pi + (pass & 1) * 0.34f;
Vector2 attackAxis = angle.ToRotationVector2();
Vector2 destination = target.Center + attackAxis * 148f;
Vector2 desired = (destination - Projectile.Center).SafeNormalize(Projectile.velocity)
* (22f + pass * 1.25f);
Projectile.velocity = Vector2.Lerp(Projectile.velocity, desired, 0.16f);
int spinDirection = Math.Sign(Projectile.velocity.X);
if (spinDirection == 0)
spinDirection = 1;
Projectile.rotation += 0.34f * spinDirection;
Projectile.GetGlobalProjectile<MyGlobalProjectile>()
.UpdateReaperAttackAngle(Projectile.velocity.ToRotation());
if (Vector2.DistanceSquared(Projectile.Center, destination) <= 34f * 34f
|| passTimer >= 31)
{
CompletePass();
}
}
public override bool? Colliding(Rectangle projHitbox, Rectangle targetHitbox)
{
if (!configured)
return false;
// The visible head and every already-recorded span are live at the same
// time. ModifyHitNPC separates their 75%/25% damage budgets.
if (!lingering && projHitbox.Intersects(targetHitbox))
return true;
if (trail.Count < 2)
return false;
float collisionPoint = 0f;
for (int index = 1; index < trail.Count; index++)
{
Vector2 start = trail[index - 1].Position;
Vector2 end = trail[index].Position;
if (Vector2.DistanceSquared(start, end) > 180f * 180f)
continue;
if (Collision.CheckAABBvLineCollision(targetHitbox.TopLeft(), targetHitbox.Size(),
start, end, 11f, ref collisionPoint))
{
return true;
}
}
return false;
}
public override void ModifyHitNPC(NPC target, ref NPC.HitModifiers modifiers)
{
bool directHeadHit = !lingering && Projectile.Hitbox.Intersects(target.Hitbox);
if (!directHeadHit && !lingering)
{
// The flying actor is configured at 75%; one third of that is the
// requested 25% base damage for an already-carved trail span.
modifiers.SourceDamage *= 1f / 3f;
}
}
public override void OnHitNPC(NPC target, NPC.HitInfo hit, int damageDone)
{
bool directHeadHit = !lingering && Projectile.Hitbox.Intersects(target.Hitbox);
// Residual ticks use a shorter gate so a target can still be struck by the
// approaching 75% blade head after touching an older crossing trail.
Projectile.localNPCImmunity[target.whoAmI] = directHeadHit ? 24 : 8;
}
public override bool PreDraw(ref Color lightColor)
{
if (!configured || Main.dedServ)
return false;
DrawRecordedBloodScar();
if (lingering)
return false;
// Reuse the original primary-attack crescent projectile artwork. This is
// an energy blade, not a duplicated Blood scythe weapon model.
Asset<Texture2D> asset = ModContent.Request<Texture2D>(
"SoulHarvest/Projectiles/BloodCrescentProjectile");
Texture2D texture = asset.Value;
Vector2 origin = texture.Size() * 0.5f;
float rotation = Projectile.rotation;
Main.EntitySpriteDraw(texture, Projectile.Center - Main.screenPosition, null,
new Color(245, 25, 55, 0) * 0.38f, rotation, origin, 1.10f,
SpriteEffects.None);
Main.EntitySpriteDraw(texture, Projectile.Center - Main.screenPosition, null,
Color.White, rotation, origin, 0.92f, SpriteEffects.None);
return false;
}
public override void SendExtraAI(BinaryWriter writer)
{
writer.Write(configured);
if (!configured)
return;
snapshot.Write(writer);
writer.Write((byte)hitKind);
writer.Write((byte)Math.Clamp(bladeIndex, 0, byte.MaxValue));
writer.Write(actionId);
writer.Write((short)targetIndex);
writer.Write((byte)Math.Clamp(pass, 0, byte.MaxValue));
writer.Write((byte)Math.Clamp(passTimer, 0, byte.MaxValue));
writer.Write((byte)Math.Clamp(maximumPasses, 1, byte.MaxValue));
writer.Write((short)Math.Clamp(scarLifetime, 1, short.MaxValue));
writer.Write((short)Math.Clamp(activeTimer, 0, short.MaxValue));
writer.Write(lingering);
}
public override void ReceiveExtraAI(BinaryReader reader)
{
bool incoming = reader.ReadBoolean();
if (!incoming)
{
if (Main.netMode != NetmodeID.Server)
configured = false;
return;
}
SickleCombatSnapshot incomingSnapshot = SickleCombatSnapshot.Read(reader);
ReaperHitKind incomingHitKind = (ReaperHitKind)reader.ReadByte();
int incomingBlade = reader.ReadByte();
int incomingAction = reader.ReadInt32();
int incomingTarget = reader.ReadInt16();
int incomingPass = reader.ReadByte();
int incomingPassTimer = reader.ReadByte();
int incomingMaximumPasses = reader.ReadByte();
int incomingScarLifetime = reader.ReadInt16();
int incomingActiveTimer = reader.ReadInt16();
bool incomingLingering = reader.ReadBoolean();
if (Main.netMode == NetmodeID.Server)
return;
snapshot = incomingSnapshot;
hitKind = incomingHitKind;
bladeIndex = incomingBlade;
actionId = incomingAction;
targetIndex = incomingTarget;
pass = incomingPass;
passTimer = incomingPassTimer;
maximumPasses = Math.Max(1, incomingMaximumPasses);
scarLifetime = incomingScarLifetime;
activeTimer = incomingActiveTimer;
lingering = incomingLingering;
configured = true;
}
private void Configure(in SickleCombatSnapshot value, int index, int parentActionId,
ReaperHitKind kind, int passCount, int residualLifetime)
{
snapshot = value;
hitKind = kind;
bladeIndex = Math.Max(0, index);
actionId = parentActionId;
targetIndex = -1;
pass = 0;
passTimer = 0;
maximumPasses = Math.Clamp(passCount, 1, 8);
scarLifetime = Math.Clamp(residualLifetime, 60, 300);
activeTimer = 0;
lingering = false;
trail.Clear();
RecordTrailPoint(Projectile.Center);
configured = true;
serverAuthorized = Main.netMode != NetmodeID.MultiplayerClient;
}
private NPC? GetOrFindTarget()
{
if (targetIndex >= 0 && targetIndex < Main.maxNPCs)
{
NPC current = Main.npc[targetIndex];
if (ReaperTargeting.IsValidWeaponTarget(current)
&& (ReaperTargeting.IsTrainingDummy(current)
|| current.CanBeChasedBy(Projectile))
&& Vector2.DistanceSquared(Projectile.Center, current.Center)
<= TargetRetentionRadius * TargetRetentionRadius)
return current;
targetIndex = -1;
}
// Give the blade a readable launch phase, then acquire only from a local
// bubble centred on its current position. It no longer turns immediately
// toward an enemy which happened to exist far across the screen at spawn.
if (activeTimer < HomingDelayFrames)
return null;
NPC? target = ReaperProjectileHelper.FindTarget(Projectile,
TargetSearchRadius);
targetIndex = target?.whoAmI ?? -1;
if (Main.netMode != NetmodeID.MultiplayerClient)
Projectile.netUpdate = true;
return target;
}
private void CompletePass()
{
pass++;
passTimer = 0;
Projectile.netUpdate = true;
if (pass >= maximumPasses)
BeginLingering();
}
private void BeginLingering()
{
if (lingering)
return;
lingering = true;
Projectile.velocity = Vector2.Zero;
Projectile.damage = Math.Max(1, (int)Math.Round(snapshot.Damage * 0.25f));
Projectile.localNPCHitCooldown = 30;
Array.Fill(Projectile.localNPCImmunity, 0);
Projectile.netUpdate = true;
}
private void RecordTrailPoint(Vector2 position)
{
if (!float.IsFinite(position.X) || !float.IsFinite(position.Y))
return;
if (trail.Count > 0)
{
float distanceSquared = Vector2.DistanceSquared(trail[^1].Position, position);
if (distanceSquared < 4f * 4f)
{
trail[^1].Position = position;
trail[^1].Age = 0;
return;
}
if (distanceSquared > 200f * 200f)
{
trail.Add(new ReaperBloodBladeTrailPoint { Position = position, Age = 0 });
if (trail.Count > MaximumTrailPoints)
trail.RemoveAt(0);
return;
}
}
trail.Add(new ReaperBloodBladeTrailPoint { Position = position, Age = 0 });
if (trail.Count > MaximumTrailPoints)
trail.RemoveAt(0);
}
private void AgeTrail()
{
for (int index = trail.Count - 1; index >= 0; index--)
{
if (++trail[index].Age > scarLifetime)
trail.RemoveAt(index);
}
}
private void DrawRecordedBloodScar()
{
if (trail.Count < 2)
return;
int count = trail.Count;
for (int index = 1; index < count; index++)
{
ReaperBloodBladeTrailPoint older = trail[index - 1];
ReaperBloodBladeTrailPoint newer = trail[index];
if (Vector2.DistanceSquared(older.Position, newer.Position) > 180f * 180f)
continue;
float progress = (index - 0.5f) / Math.Max(1f, count - 1f);
float taper = (float)Math.Pow(Math.Max(0f, Math.Sin(progress * MathHelper.Pi)), 0.38f);
float age = Math.Max(older.Age, newer.Age);
float fade = 1f - Smooth01((age - scarLifetime * 0.72f)
/ Math.Max(1f, scarLifetime * 0.28f));
float wave = 1f + (float)Math.Sin(progress * 31f + actionId * 0.071f) * 0.07f;
float width = Math.Max(0.7f, 14f * taper * wave);
DrawScarStrip(older.Position, newer.Position, width, fade);
}
}
private static void DrawScarStrip(Vector2 start, Vector2 end, float width, float opacity)
=> ReaperBloodTrailRenderer.DrawSegment(start, end, width, opacity);
private static float Smooth01(float value)
{
value = MathHelper.Clamp(value, 0f, 1f);
return value * value * (3f - 2f * value);
}
}