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; /// /// 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. /// 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 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(), 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().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() .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 asset = ModContent.Request( "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); } }