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; /// /// 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. /// 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 points = []; private readonly HashSet selectedTargets = []; private readonly List 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 orderedPoints, int phase, int actionId) { if (Main.netMode == NetmodeID.MultiplayerClient || orderedPoints.Count < 2) return -1; List 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(), 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 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 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(); 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 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); } }