using SoulHarvest.Common;
using Microsoft.Xna.Framework;
using System;
using System.Collections.Generic;
using System.IO;
using Terraria;
using Terraria.ID;
using Terraria.ModLoader;
namespace SoulHarvest.Projectiles;
/// <summary>
/// The authoritative residual cut carved by a Void primary swing. Its geometry
/// is copied from the held weapon's actual blade-tip history, so drawing,
/// collision and attraction all use one curved world-space path.
/// </summary>
public sealed class ReaperVoidArcRiftProjectile : ModProjectile
{
private const int MaximumPoints = 24;
private readonly List<Vector2> points = [];
private SickleCombatSnapshot snapshot;
private float baseWidth;
private float pullRadius;
private int lifetime;
private int seed;
private int age;
private bool configured;
private bool serverAuthorized;
public override string Texture => "Terraria/Images/Projectile_0";
internal static int GetLifetime(int skillLevel) => skillLevel switch
{
>= 3 => 300,
2 => 210,
1 => 150,
_ => 120
};
internal static int Spawn(Terraria.DataStructures.IEntitySource source, int owner,
in SickleCombatSnapshot snapshot, Vector2[] newestFirstPoints, int validPointCount,
int phase, int actionId, float damageMultiplier = 0.50f)
{
if (Main.netMode == NetmodeID.MultiplayerClient || validPointCount < 2)
return -1;
List<Vector2> ordered = [];
int usable = Math.Min(Math.Min(validPointCount, newestFirstPoints.Length), MaximumPoints);
for (int index = usable - 1; index >= 0; index--)
{
Vector2 point = newestFirstPoints[index];
if (!float.IsFinite(point.X) || !float.IsFinite(point.Y))
continue;
if (ordered.Count == 0 || Vector2.DistanceSquared(ordered[^1], point) >= 3f * 3f)
ordered.Add(point);
}
if (ordered.Count < 2)
return -1;
Vector2 center = Vector2.Zero;
foreach (Vector2 point in ordered)
center += point;
center /= ordered.Count;
Vector2 direction = (ordered[^1] - ordered[0]).SafeNormalize(Vector2.UnitX);
int indexProjectile = Projectile.NewProjectile(source, center, direction,
ModContent.ProjectileType<ReaperVoidArcRiftProjectile>(),
Math.Max(1, (int)Math.Ceiling(snapshot.Damage
* MathHelper.Clamp(damageMultiplier, 0.05f, 2f))),
snapshot.Knockback * 0.10f, owner);
if (indexProjectile < 0 || indexProjectile >= Main.maxProjectiles)
return -1;
Projectile projectile = Main.projectile[indexProjectile];
projectile.originalDamage = snapshot.Damage;
int skillLevel = Math.Clamp(Math.Max(1, (int)snapshot.Skill2), 1, 3);
int stableSeed = unchecked(actionId * 486187739 + phase * 16777619 + owner * 97);
if (projectile.ModProjectile is ReaperVoidArcRiftProjectile rift)
{
rift.Configure(snapshot, ordered,
6f + snapshot.StageNumber * 2.2f,
120f + skillLevel * 38f, GetLifetime(skillLevel), stableSeed);
}
projectile.GetGlobalProjectile<MyGlobalProjectile>().ConfigureReaperProjectile(
projectile, snapshot, ReaperHitKind.DamageOverTime, phase,
direction.ToRotation(), actionId);
projectile.localNPCHitCooldown = 30;
ReaperProjectileHelper.SyncNewProjectile(projectile);
return indexProjectile;
}
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 = 300;
Projectile.netImportant = true;
Projectile.DamageType = DamageClass.Melee;
Projectile.usesLocalNPCImmunity = true;
Projectile.localNPCHitCooldown = 30;
}
public override bool ShouldUpdatePosition() => false;
public override bool? CanDamage()
=> configured && age >= 4 && Projectile.timeLeft > 15 ? null : false;
public override void AI()
{
if (Main.netMode == NetmodeID.Server && !serverAuthorized)
{
Projectile.Kill();
return;
}
if (!configured)
return;
age++;
if (Main.netMode != NetmodeID.MultiplayerClient)
PullNearbyEnemies();
}
public override bool? Colliding(Rectangle projHitbox, Rectangle targetHitbox)
{
float collisionPoint = 0f;
for (int index = 1; index < points.Count; index++)
{
float taper = GetTaper(index - 0.5f);
if (Collision.CheckAABBvLineCollision(targetHitbox.TopLeft(), targetHitbox.Size(),
points[index - 1], points[index], Math.Max(2f, baseWidth * taper * 0.75f),
ref collisionPoint))
{
return true;
}
}
return false;
}
public override bool PreDraw(ref Color lightColor)
{
if (!configured || Main.dedServ || points.Count < 2)
return false;
float reveal = Smooth01(age / 9f);
float fade = Smooth01(Projectile.timeLeft / 36f);
int visibleSegments = Math.Clamp((int)Math.Ceiling((points.Count - 1) * reveal), 1,
points.Count - 1);
float visualBaseWidth = snapshot.Stage >= ReaperStage.StageIII
? baseWidth * 2f
: baseWidth;
for (int index = 1; index <= visibleSegments; index++)
{
float width = Math.Max(0.55f, visualBaseWidth * GetTaper(index - 0.5f));
ReaperVoidBackdropRenderer.DrawTrailStrip(Main.spriteBatch,
points[index - 1], points[index], width, fade,
drawStars: true, branchSeed: seed, branchIndex: index);
}
return false;
}
public override void SendExtraAI(BinaryWriter writer)
{
writer.Write(configured);
if (!configured)
return;
snapshot.Write(writer);
writer.Write(baseWidth);
writer.Write(pullRadius);
writer.Write((short)lifetime);
writer.Write(seed);
writer.Write((short)Math.Clamp(age, 0, short.MaxValue));
writer.Write((byte)Math.Min(points.Count, MaximumPoints));
for (int index = 0; index < points.Count && index < MaximumPoints; index++)
writer.WriteVector2(points[index]);
}
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);
float incomingWidth = reader.ReadSingle();
float incomingPullRadius = reader.ReadSingle();
int incomingLifetime = reader.ReadInt16();
int incomingSeed = reader.ReadInt32();
int incomingAge = reader.ReadInt16();
int count = reader.ReadByte();
List<Vector2> incomingPoints = [];
for (int index = 0; index < count; index++)
incomingPoints.Add(reader.ReadVector2());
if (Main.netMode == NetmodeID.Server)
return;
snapshot = incomingSnapshot;
baseWidth = incomingWidth;
pullRadius = incomingPullRadius;
lifetime = incomingLifetime;
seed = incomingSeed;
age = incomingAge;
points.Clear();
points.AddRange(incomingPoints);
configured = points.Count >= 2;
}
private void Configure(in SickleCombatSnapshot value, List<Vector2> path,
float width, float attractionRadius, int activeLifetime, int shapeSeed)
{
snapshot = value;
points.Clear();
points.AddRange(path);
baseWidth = MathHelper.Clamp(width, 4f, 22f);
pullRadius = MathHelper.Clamp(attractionRadius, 80f, 360f);
lifetime = Math.Clamp(activeLifetime, 60, 300);
seed = shapeSeed;
age = 0;
configured = points.Count >= 2;
serverAuthorized = Main.netMode != NetmodeID.MultiplayerClient;
Projectile.timeLeft = lifetime;
}
private void PullNearbyEnemies()
{
if (age < 4 || Projectile.timeLeft <= 15)
return;
foreach (NPC npc in Main.ActiveNPCs)
{
if (!npc.CanBeChasedBy(Projectile) || npc.boss || npc.realLife >= 0)
continue;
Vector2 closest = points[0];
float closestDistanceSquared = float.MaxValue;
for (int index = 1; index < points.Count; index++)
{
Vector2 candidate = ClosestPoint(points[index - 1], points[index], npc.Center);
float distanceSquared = Vector2.DistanceSquared(candidate, npc.Center);
if (distanceSquared < closestDistanceSquared)
{
closestDistanceSquared = distanceSquared;
closest = candidate;
}
}
float distance = (float)Math.Sqrt(closestDistanceSquared);
if (distance <= 2f || distance >= pullRadius)
continue;
float influence = 1f - distance / pullRadius;
Vector2 desired = (closest - npc.Center) / distance
* MathHelper.Lerp(1.2f, 6.4f, influence);
npc.velocity = Vector2.Lerp(npc.velocity, desired, 0.04f + influence * 0.10f);
if (Main.netMode == NetmodeID.Server && Main.GameUpdateCount % 10 == 0)
npc.netUpdate = true;
}
}
private float GetTaper(float segmentIndex)
{
float progress = segmentIndex / Math.Max(1f, points.Count - 1f);
return (float)Math.Pow(Math.Max(0f, Math.Sin(progress * MathHelper.Pi)), 0.46f);
}
private static Vector2 ClosestPoint(Vector2 start, Vector2 end, Vector2 point)
{
Vector2 segment = end - start;
float lengthSquared = Math.Max(1f, segment.LengthSquared());
float amount = MathHelper.Clamp(Vector2.Dot(point - start, segment) / lengthSquared, 0f, 1f);
return Vector2.Lerp(start, end, amount);
}
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 System;
using System.Collections.Generic;
using System.IO;
using Terraria;
using Terraria.ID;
using Terraria.ModLoader;
namespace SoulHarvest.Projectiles;
/// <summary>
/// The authoritative residual cut carved by a Void primary swing. Its geometry
/// is copied from the held weapon's actual blade-tip history, so drawing,
/// collision and attraction all use one curved world-space path.
/// </summary>
public sealed class ReaperVoidArcRiftProjectile : ModProjectile
{
private const int MaximumPoints = 24;
private readonly List<Vector2> points = [];
private SickleCombatSnapshot snapshot;
private float baseWidth;
private float pullRadius;
private int lifetime;
private int seed;
private int age;
private bool configured;
private bool serverAuthorized;
public override string Texture => "Terraria/Images/Projectile_0";
internal static int GetLifetime(int skillLevel) => skillLevel switch
{
>= 3 => 300,
2 => 210,
1 => 150,
_ => 120
};
internal static int Spawn(Terraria.DataStructures.IEntitySource source, int owner,
in SickleCombatSnapshot snapshot, Vector2[] newestFirstPoints, int validPointCount,
int phase, int actionId, float damageMultiplier = 0.50f)
{
if (Main.netMode == NetmodeID.MultiplayerClient || validPointCount < 2)
return -1;
List<Vector2> ordered = [];
int usable = Math.Min(Math.Min(validPointCount, newestFirstPoints.Length), MaximumPoints);
for (int index = usable - 1; index >= 0; index--)
{
Vector2 point = newestFirstPoints[index];
if (!float.IsFinite(point.X) || !float.IsFinite(point.Y))
continue;
if (ordered.Count == 0 || Vector2.DistanceSquared(ordered[^1], point) >= 3f * 3f)
ordered.Add(point);
}
if (ordered.Count < 2)
return -1;
Vector2 center = Vector2.Zero;
foreach (Vector2 point in ordered)
center += point;
center /= ordered.Count;
Vector2 direction = (ordered[^1] - ordered[0]).SafeNormalize(Vector2.UnitX);
int indexProjectile = Projectile.NewProjectile(source, center, direction,
ModContent.ProjectileType<ReaperVoidArcRiftProjectile>(),
Math.Max(1, (int)Math.Ceiling(snapshot.Damage
* MathHelper.Clamp(damageMultiplier, 0.05f, 2f))),
snapshot.Knockback * 0.10f, owner);
if (indexProjectile < 0 || indexProjectile >= Main.maxProjectiles)
return -1;
Projectile projectile = Main.projectile[indexProjectile];
projectile.originalDamage = snapshot.Damage;
int skillLevel = Math.Clamp(Math.Max(1, (int)snapshot.Skill2), 1, 3);
int stableSeed = unchecked(actionId * 486187739 + phase * 16777619 + owner * 97);
if (projectile.ModProjectile is ReaperVoidArcRiftProjectile rift)
{
rift.Configure(snapshot, ordered,
6f + snapshot.StageNumber * 2.2f,
120f + skillLevel * 38f, GetLifetime(skillLevel), stableSeed);
}
projectile.GetGlobalProjectile<MyGlobalProjectile>().ConfigureReaperProjectile(
projectile, snapshot, ReaperHitKind.DamageOverTime, phase,
direction.ToRotation(), actionId);
projectile.localNPCHitCooldown = 30;
ReaperProjectileHelper.SyncNewProjectile(projectile);
return indexProjectile;
}
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 = 300;
Projectile.netImportant = true;
Projectile.DamageType = DamageClass.Melee;
Projectile.usesLocalNPCImmunity = true;
Projectile.localNPCHitCooldown = 30;
}
public override bool ShouldUpdatePosition() => false;
public override bool? CanDamage()
=> configured && age >= 4 && Projectile.timeLeft > 15 ? null : false;
public override void AI()
{
if (Main.netMode == NetmodeID.Server && !serverAuthorized)
{
Projectile.Kill();
return;
}
if (!configured)
return;
age++;
if (Main.netMode != NetmodeID.MultiplayerClient)
PullNearbyEnemies();
}
public override bool? Colliding(Rectangle projHitbox, Rectangle targetHitbox)
{
float collisionPoint = 0f;
for (int index = 1; index < points.Count; index++)
{
float taper = GetTaper(index - 0.5f);
if (Collision.CheckAABBvLineCollision(targetHitbox.TopLeft(), targetHitbox.Size(),
points[index - 1], points[index], Math.Max(2f, baseWidth * taper * 0.75f),
ref collisionPoint))
{
return true;
}
}
return false;
}
public override bool PreDraw(ref Color lightColor)
{
if (!configured || Main.dedServ || points.Count < 2)
return false;
float reveal = Smooth01(age / 9f);
float fade = Smooth01(Projectile.timeLeft / 36f);
int visibleSegments = Math.Clamp((int)Math.Ceiling((points.Count - 1) * reveal), 1,
points.Count - 1);
float visualBaseWidth = snapshot.Stage >= ReaperStage.StageIII
? baseWidth * 2f
: baseWidth;
for (int index = 1; index <= visibleSegments; index++)
{
float width = Math.Max(0.55f, visualBaseWidth * GetTaper(index - 0.5f));
ReaperVoidBackdropRenderer.DrawTrailStrip(Main.spriteBatch,
points[index - 1], points[index], width, fade,
drawStars: true, branchSeed: seed, branchIndex: index);
}
return false;
}
public override void SendExtraAI(BinaryWriter writer)
{
writer.Write(configured);
if (!configured)
return;
snapshot.Write(writer);
writer.Write(baseWidth);
writer.Write(pullRadius);
writer.Write((short)lifetime);
writer.Write(seed);
writer.Write((short)Math.Clamp(age, 0, short.MaxValue));
writer.Write((byte)Math.Min(points.Count, MaximumPoints));
for (int index = 0; index < points.Count && index < MaximumPoints; index++)
writer.WriteVector2(points[index]);
}
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);
float incomingWidth = reader.ReadSingle();
float incomingPullRadius = reader.ReadSingle();
int incomingLifetime = reader.ReadInt16();
int incomingSeed = reader.ReadInt32();
int incomingAge = reader.ReadInt16();
int count = reader.ReadByte();
List<Vector2> incomingPoints = [];
for (int index = 0; index < count; index++)
incomingPoints.Add(reader.ReadVector2());
if (Main.netMode == NetmodeID.Server)
return;
snapshot = incomingSnapshot;
baseWidth = incomingWidth;
pullRadius = incomingPullRadius;
lifetime = incomingLifetime;
seed = incomingSeed;
age = incomingAge;
points.Clear();
points.AddRange(incomingPoints);
configured = points.Count >= 2;
}
private void Configure(in SickleCombatSnapshot value, List<Vector2> path,
float width, float attractionRadius, int activeLifetime, int shapeSeed)
{
snapshot = value;
points.Clear();
points.AddRange(path);
baseWidth = MathHelper.Clamp(width, 4f, 22f);
pullRadius = MathHelper.Clamp(attractionRadius, 80f, 360f);
lifetime = Math.Clamp(activeLifetime, 60, 300);
seed = shapeSeed;
age = 0;
configured = points.Count >= 2;
serverAuthorized = Main.netMode != NetmodeID.MultiplayerClient;
Projectile.timeLeft = lifetime;
}
private void PullNearbyEnemies()
{
if (age < 4 || Projectile.timeLeft <= 15)
return;
foreach (NPC npc in Main.ActiveNPCs)
{
if (!npc.CanBeChasedBy(Projectile) || npc.boss || npc.realLife >= 0)
continue;
Vector2 closest = points[0];
float closestDistanceSquared = float.MaxValue;
for (int index = 1; index < points.Count; index++)
{
Vector2 candidate = ClosestPoint(points[index - 1], points[index], npc.Center);
float distanceSquared = Vector2.DistanceSquared(candidate, npc.Center);
if (distanceSquared < closestDistanceSquared)
{
closestDistanceSquared = distanceSquared;
closest = candidate;
}
}
float distance = (float)Math.Sqrt(closestDistanceSquared);
if (distance <= 2f || distance >= pullRadius)
continue;
float influence = 1f - distance / pullRadius;
Vector2 desired = (closest - npc.Center) / distance
* MathHelper.Lerp(1.2f, 6.4f, influence);
npc.velocity = Vector2.Lerp(npc.velocity, desired, 0.04f + influence * 0.10f);
if (Main.netMode == NetmodeID.Server && Main.GameUpdateCount % 10 == 0)
npc.netUpdate = true;
}
}
private float GetTaper(float segmentIndex)
{
float progress = segmentIndex / Math.Max(1f, points.Count - 1f);
return (float)Math.Pow(Math.Max(0f, Math.Sin(progress * MathHelper.Pi)), 0.46f);
}
private static Vector2 ClosestPoint(Vector2 start, Vector2 end, Vector2 point)
{
Vector2 segment = end - start;
float lengthSquared = Math.Max(1f, segment.LengthSquared());
float amount = MathHelper.Clamp(Vector2.Dot(point - start, segment) / lengthSquared, 0f, 1f);
return Vector2.Lerp(start, end, amount);
}
private static float Smooth01(float value)
{
value = MathHelper.Clamp(value, 0f, 1f);
return value * value * (3f - 2f * value);
}
}