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 immutable world-space wound traced by the tip of a max-tempo Death swing.
/// Its red edges enclose the Death Domain backdrop; enemies crossing the exact
/// recorded curve receive the owner's character-bound Death Domain harvest.
/// </summary>
public sealed class ReaperDeathDomainRiftProjectile : ModProjectile
{
private const int Lifetime = 300;
private const int MaximumPoints = 72;
private readonly ulong[] nextHarvestTicks = new ulong[Main.maxNPCs];
private readonly List<Vector2> points = [];
private readonly HashSet<int> selectedTargets = [];
private readonly List<NPC> harvestTargets = [];
private Vector4 pathBounds;
private SickleCombatSnapshot snapshot;
private int phase;
private int actionId;
private int age;
private int volleyCounter;
private bool configured;
private bool serverAuthorized;
private float TrailWidth => snapshot.DeathRiftWidth;
#if DEBUG
internal int DebugAge => age;
internal int DebugPointCount => points.Count;
internal int DebugLifetime => Lifetime;
internal float DebugPathLength
{
get
{
float total = 0f;
for (int index = 1; index < points.Count; index++)
total += Vector2.Distance(points[index - 1], points[index]);
return total;
}
}
#endif
public override string Texture => "Terraria/Images/Projectile_0";
internal static int Spawn(Terraria.DataStructures.IEntitySource source,
int owner, in SickleCombatSnapshot snapshot,
IReadOnlyList<Vector2> orderedPoints, int phase, int actionId)
{
if (Main.netMode == NetmodeID.MultiplayerClient || orderedPoints.Count < 2)
return -1;
List<Vector2> ordered = [];
int usable = Math.Min(orderedPoints.Count, MaximumPoints);
for (int index = 0; index < usable; index++)
{
Vector2 point = orderedPoints[index];
if (!float.IsFinite(point.X) || !float.IsFinite(point.Y))
continue;
if (ordered.Count == 0
|| Vector2.DistanceSquared(ordered[^1], point) >= 5f * 5f)
{
ordered.Add(point);
}
}
if (ordered.Count < 2)
return -1;
Vector2 center = Vector2.Zero;
foreach (Vector2 point in ordered)
center += point;
center /= ordered.Count;
int projectileIndex = Projectile.NewProjectile(source, center,
Vector2.Zero, ModContent.ProjectileType<ReaperDeathDomainRiftProjectile>(),
0, 0f, owner);
if (projectileIndex < 0 || projectileIndex >= Main.maxProjectiles)
return -1;
Projectile projectile = Main.projectile[projectileIndex];
if (projectile.ModProjectile is ReaperDeathDomainRiftProjectile rift)
rift.Configure(snapshot, ordered, phase, actionId);
ReaperProjectileHelper.SyncNewProjectile(projectile);
return projectileIndex;
}
public override void SetStaticDefaults()
{
ProjectileID.Sets.DrawScreenCheckFluff[Type] = 2400;
}
public override void SetDefaults()
{
Projectile.width = 4;
Projectile.height = 4;
Projectile.friendly = false;
Projectile.hostile = false;
Projectile.tileCollide = false;
Projectile.ignoreWater = true;
Projectile.penetrate = -1;
Projectile.timeLeft = Lifetime;
Projectile.netImportant = true;
}
public override bool ShouldUpdatePosition() => false;
public override bool? CanDamage() => false;
public override void AI()
{
if (Main.netMode == NetmodeID.Server && !serverAuthorized)
{
Projectile.Kill();
return;
}
if (!configured)
return;
age++;
float opacity = GetOpacity();
if (Main.netMode != NetmodeID.Server)
{
// The lingering rift is the completed blade locus: keep the whole
// arc intact and fade it uniformly instead of deleting random
// segments during its final frames.
DeathDomainTrailVisualSystem.Record(Projectile.owner,
Projectile.identity, points, TrailWidth, opacity,
mergeOverlappingRims: true);
}
if (Main.netMode != NetmodeID.MultiplayerClient)
TryHarvestIntersectingEnemies();
}
public override bool PreDraw(ref Color lightColor)
=> false;
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(actionId);
writer.Write((short)Math.Clamp(age, 0, short.MaxValue));
writer.Write((short)Math.Clamp(volleyCounter, 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);
int incomingPhase = reader.ReadByte();
int incomingAction = reader.ReadInt32();
int incomingAge = reader.ReadInt16();
int incomingVolley = 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;
phase = incomingPhase;
actionId = incomingAction;
age = Math.Clamp(incomingAge, 0, Lifetime);
Projectile.timeLeft = Math.Max(1, Lifetime - age);
volleyCounter = incomingVolley;
points.Clear();
points.AddRange(incomingPoints);
pathBounds = CalculatePathBounds(points, TrailWidth);
configured = points.Count >= 2;
}
private void Configure(in SickleCombatSnapshot value, List<Vector2> path,
int sourcePhase, int sourceActionId)
{
snapshot = value;
points.Clear();
points.AddRange(path);
pathBounds = CalculatePathBounds(points, TrailWidth);
phase = Math.Max(0, sourcePhase);
actionId = sourceActionId;
age = 0;
volleyCounter = 0;
Projectile.timeLeft = Lifetime;
configured = points.Count >= 2;
serverAuthorized = Main.netMode != NetmodeID.MultiplayerClient;
}
private void TryHarvestIntersectingEnemies()
{
if (age < 6 || Projectile.timeLeft <= 15
|| Projectile.owner < 0 || Projectile.owner >= Main.maxPlayers)
{
return;
}
Player owner = Main.player[Projectile.owner];
if (!owner.active || owner.dead)
return;
MyPlayer modPlayer = owner.GetModPlayer<MyPlayer>();
DeathDomainProgression progression = modPlayer.DeathDomainProgression;
selectedTargets.Clear();
harvestTargets.Clear();
ulong currentTick = Main.GameUpdateCount;
foreach (NPC npc in Main.ActiveNPCs)
{
if (!ReaperTargeting.IsValidWeaponTarget(npc)
|| !IntersectsPathBounds(npc.Hitbox)
|| !IntersectsPath(npc.Hitbox))
{
continue;
}
NPC target = npc.realLife >= 0 && npc.realLife < Main.maxNPCs
&& Main.npc[npc.realLife].active ? Main.npc[npc.realLife] : npc;
if (!ReaperTargeting.IsValidWeaponTarget(target)
|| !selectedTargets.Add(target.whoAmI)
|| nextHarvestTicks[target.whoAmI] > currentTick)
{
continue;
}
nextHarvestTicks[target.whoAmI] = currentTick
+ (ulong)Math.Max(1, progression.SpawnInterval);
harvestTargets.Add(target);
}
if (harvestTargets.Count == 0)
return;
volleyCounter++;
bool requiem = modPlayer.IsDeathDomainDescended(modPlayer.GetEquippedDeathNecklace())
&& volleyCounter % 4 == 0;
foreach (NPC target in harvestTargets)
{
uint hash = unchecked((uint)(actionId * 16777619
+ target.whoAmI * 486187739 + volleyCounter * 97));
hash ^= hash >> 16;
float rotation = (hash & 0x00FFFFFFu) / 16777215f
* MathHelper.TwoPi;
modPlayer.SpawnDeathDomainHarvestFromReaperRift(target, requiem,
rotation);
}
}
private bool IntersectsPath(Rectangle target)
{
float collisionPoint = 0f;
for (int index = 1; index < points.Count; index++)
{
if (Collision.CheckAABBvLineCollision(target.TopLeft(), target.Size(),
points[index - 1], points[index], TrailWidth, ref collisionPoint))
{
return true;
}
}
return false;
}
private bool IntersectsPathBounds(Rectangle target)
=> target.Right >= pathBounds.X && target.Bottom >= pathBounds.Y
&& target.Left <= pathBounds.Z && target.Top <= pathBounds.W;
private static Vector4 CalculatePathBounds(IReadOnlyList<Vector2> path,
float width)
{
if (path.Count == 0)
return Vector4.Zero;
Vector2 minimum = path[0];
Vector2 maximum = path[0];
for (int index = 1; index < path.Count; index++)
{
minimum = Vector2.Min(minimum, path[index]);
maximum = Vector2.Max(maximum, path[index]);
}
return new Vector4(minimum.X - width, minimum.Y - width,
maximum.X + width, maximum.Y + width);
}
private float GetOpacity()
=> Smooth01(age / 8f) * Smooth01(Projectile.timeLeft / 44f);
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 immutable world-space wound traced by the tip of a max-tempo Death swing.
/// Its red edges enclose the Death Domain backdrop; enemies crossing the exact
/// recorded curve receive the owner's character-bound Death Domain harvest.
/// </summary>
public sealed class ReaperDeathDomainRiftProjectile : ModProjectile
{
private const int Lifetime = 300;
private const int MaximumPoints = 72;
private readonly ulong[] nextHarvestTicks = new ulong[Main.maxNPCs];
private readonly List<Vector2> points = [];
private readonly HashSet<int> selectedTargets = [];
private readonly List<NPC> harvestTargets = [];
private Vector4 pathBounds;
private SickleCombatSnapshot snapshot;
private int phase;
private int actionId;
private int age;
private int volleyCounter;
private bool configured;
private bool serverAuthorized;
private float TrailWidth => snapshot.DeathRiftWidth;
#if DEBUG
internal int DebugAge => age;
internal int DebugPointCount => points.Count;
internal int DebugLifetime => Lifetime;
internal float DebugPathLength
{
get
{
float total = 0f;
for (int index = 1; index < points.Count; index++)
total += Vector2.Distance(points[index - 1], points[index]);
return total;
}
}
#endif
public override string Texture => "Terraria/Images/Projectile_0";
internal static int Spawn(Terraria.DataStructures.IEntitySource source,
int owner, in SickleCombatSnapshot snapshot,
IReadOnlyList<Vector2> orderedPoints, int phase, int actionId)
{
if (Main.netMode == NetmodeID.MultiplayerClient || orderedPoints.Count < 2)
return -1;
List<Vector2> ordered = [];
int usable = Math.Min(orderedPoints.Count, MaximumPoints);
for (int index = 0; index < usable; index++)
{
Vector2 point = orderedPoints[index];
if (!float.IsFinite(point.X) || !float.IsFinite(point.Y))
continue;
if (ordered.Count == 0
|| Vector2.DistanceSquared(ordered[^1], point) >= 5f * 5f)
{
ordered.Add(point);
}
}
if (ordered.Count < 2)
return -1;
Vector2 center = Vector2.Zero;
foreach (Vector2 point in ordered)
center += point;
center /= ordered.Count;
int projectileIndex = Projectile.NewProjectile(source, center,
Vector2.Zero, ModContent.ProjectileType<ReaperDeathDomainRiftProjectile>(),
0, 0f, owner);
if (projectileIndex < 0 || projectileIndex >= Main.maxProjectiles)
return -1;
Projectile projectile = Main.projectile[projectileIndex];
if (projectile.ModProjectile is ReaperDeathDomainRiftProjectile rift)
rift.Configure(snapshot, ordered, phase, actionId);
ReaperProjectileHelper.SyncNewProjectile(projectile);
return projectileIndex;
}
public override void SetStaticDefaults()
{
ProjectileID.Sets.DrawScreenCheckFluff[Type] = 2400;
}
public override void SetDefaults()
{
Projectile.width = 4;
Projectile.height = 4;
Projectile.friendly = false;
Projectile.hostile = false;
Projectile.tileCollide = false;
Projectile.ignoreWater = true;
Projectile.penetrate = -1;
Projectile.timeLeft = Lifetime;
Projectile.netImportant = true;
}
public override bool ShouldUpdatePosition() => false;
public override bool? CanDamage() => false;
public override void AI()
{
if (Main.netMode == NetmodeID.Server && !serverAuthorized)
{
Projectile.Kill();
return;
}
if (!configured)
return;
age++;
float opacity = GetOpacity();
if (Main.netMode != NetmodeID.Server)
{
// The lingering rift is the completed blade locus: keep the whole
// arc intact and fade it uniformly instead of deleting random
// segments during its final frames.
DeathDomainTrailVisualSystem.Record(Projectile.owner,
Projectile.identity, points, TrailWidth, opacity,
mergeOverlappingRims: true);
}
if (Main.netMode != NetmodeID.MultiplayerClient)
TryHarvestIntersectingEnemies();
}
public override bool PreDraw(ref Color lightColor)
=> false;
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(actionId);
writer.Write((short)Math.Clamp(age, 0, short.MaxValue));
writer.Write((short)Math.Clamp(volleyCounter, 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);
int incomingPhase = reader.ReadByte();
int incomingAction = reader.ReadInt32();
int incomingAge = reader.ReadInt16();
int incomingVolley = 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;
phase = incomingPhase;
actionId = incomingAction;
age = Math.Clamp(incomingAge, 0, Lifetime);
Projectile.timeLeft = Math.Max(1, Lifetime - age);
volleyCounter = incomingVolley;
points.Clear();
points.AddRange(incomingPoints);
pathBounds = CalculatePathBounds(points, TrailWidth);
configured = points.Count >= 2;
}
private void Configure(in SickleCombatSnapshot value, List<Vector2> path,
int sourcePhase, int sourceActionId)
{
snapshot = value;
points.Clear();
points.AddRange(path);
pathBounds = CalculatePathBounds(points, TrailWidth);
phase = Math.Max(0, sourcePhase);
actionId = sourceActionId;
age = 0;
volleyCounter = 0;
Projectile.timeLeft = Lifetime;
configured = points.Count >= 2;
serverAuthorized = Main.netMode != NetmodeID.MultiplayerClient;
}
private void TryHarvestIntersectingEnemies()
{
if (age < 6 || Projectile.timeLeft <= 15
|| Projectile.owner < 0 || Projectile.owner >= Main.maxPlayers)
{
return;
}
Player owner = Main.player[Projectile.owner];
if (!owner.active || owner.dead)
return;
MyPlayer modPlayer = owner.GetModPlayer<MyPlayer>();
DeathDomainProgression progression = modPlayer.DeathDomainProgression;
selectedTargets.Clear();
harvestTargets.Clear();
ulong currentTick = Main.GameUpdateCount;
foreach (NPC npc in Main.ActiveNPCs)
{
if (!ReaperTargeting.IsValidWeaponTarget(npc)
|| !IntersectsPathBounds(npc.Hitbox)
|| !IntersectsPath(npc.Hitbox))
{
continue;
}
NPC target = npc.realLife >= 0 && npc.realLife < Main.maxNPCs
&& Main.npc[npc.realLife].active ? Main.npc[npc.realLife] : npc;
if (!ReaperTargeting.IsValidWeaponTarget(target)
|| !selectedTargets.Add(target.whoAmI)
|| nextHarvestTicks[target.whoAmI] > currentTick)
{
continue;
}
nextHarvestTicks[target.whoAmI] = currentTick
+ (ulong)Math.Max(1, progression.SpawnInterval);
harvestTargets.Add(target);
}
if (harvestTargets.Count == 0)
return;
volleyCounter++;
bool requiem = modPlayer.IsDeathDomainDescended(modPlayer.GetEquippedDeathNecklace())
&& volleyCounter % 4 == 0;
foreach (NPC target in harvestTargets)
{
uint hash = unchecked((uint)(actionId * 16777619
+ target.whoAmI * 486187739 + volleyCounter * 97));
hash ^= hash >> 16;
float rotation = (hash & 0x00FFFFFFu) / 16777215f
* MathHelper.TwoPi;
modPlayer.SpawnDeathDomainHarvestFromReaperRift(target, requiem,
rotation);
}
}
private bool IntersectsPath(Rectangle target)
{
float collisionPoint = 0f;
for (int index = 1; index < points.Count; index++)
{
if (Collision.CheckAABBvLineCollision(target.TopLeft(), target.Size(),
points[index - 1], points[index], TrailWidth, ref collisionPoint))
{
return true;
}
}
return false;
}
private bool IntersectsPathBounds(Rectangle target)
=> target.Right >= pathBounds.X && target.Bottom >= pathBounds.Y
&& target.Left <= pathBounds.Z && target.Top <= pathBounds.W;
private static Vector4 CalculatePathBounds(IReadOnlyList<Vector2> path,
float width)
{
if (path.Count == 0)
return Vector4.Zero;
Vector2 minimum = path[0];
Vector2 maximum = path[0];
for (int index = 1; index < path.Count; index++)
{
minimum = Vector2.Min(minimum, path[index]);
maximum = Vector2.Max(maximum, path[index]);
}
return new Vector4(minimum.X - width, minimum.Y - width,
maximum.X + width, maximum.Y + width);
}
private float GetOpacity()
=> Smooth01(age / 8f) * Smooth01(Projectile.timeLeft / 44f);
private static float Smooth01(float value)
{
value = MathHelper.Clamp(value, 0f, 1f);
return value * value * (3f - 2f * value);
}
}