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 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. /// public sealed class ReaperVoidArcRiftProjectile : ModProjectile { private const int MaximumPoints = 24; private readonly List 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 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(), 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().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 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 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); } }