using SoulHarvest.Common; using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Graphics; using ReLogic.Content; using System; using System.IO; using Terraria; using Terraria.ID; using Terraria.ModLoader; namespace SoulHarvest.Projectiles; /// /// A Death-scythe afterimage anchored at a position cut by the primary attack. /// It repeatedly acquires enemies from that exact position and performs real, /// server-authoritative follow-up cuts without summoning any branch weapon. /// public sealed class ReaperDeathPhantomProjectile : ModProjectile { private const int MaximumPhantomsPerOwner = 20; private SickleCombatSnapshot snapshot; private Vector2 anchor; private Vector2 attackDirection = Vector2.UnitX; private float tempo = 1f; private float weaponAngle; private int age; private int attackClock; private int seed; 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 initialDirection, float tempo, int stableSeed) { if (Main.netMode == NetmodeID.MultiplayerClient) return -1; TrimOldest(owner); int index = Projectile.NewProjectile(source, position, Vector2.Zero, ModContent.ProjectileType(), 0, 0f, owner); if (index < 0 || index >= Main.maxProjectiles) return -1; Projectile projectile = Main.projectile[index]; if (projectile.ModProjectile is ReaperDeathPhantomProjectile phantom) phantom.Configure(snapshot, position, initialDirection, tempo, stableSeed); ReaperProjectileHelper.SyncNewProjectile(projectile); return index; } 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 = 240; 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++; attackClock++; Projectile.Center = anchor; NPC? target = FindTarget(720f); if (target is not null) { Vector2 desired = (target.Center - anchor).SafeNormalize(attackDirection); attackDirection = Vector2.Lerp(attackDirection, desired, 0.18f) .SafeNormalize(desired); } int interval = Math.Clamp((int)Math.Round(MathHelper.Lerp(30f, 12f, MathHelper.Clamp((tempo - 1f) / 1.6f, 0f, 1f))), 12, 30); float cycle = (attackClock % interval) / (float)interval; int facing = attackDirection.X >= 0f ? 1 : -1; weaponAngle = attackDirection.ToRotation() + MathHelper.Lerp(-1.65f * facing, 1.35f * facing, cycle * cycle * (3f - 2f * cycle)); if (Main.netMode != NetmodeID.MultiplayerClient && target is not null && age >= 10 && attackClock >= interval) { attackClock = 0; Vector2 direction = (target.Center - anchor).SafeNormalize(attackDirection); float length = MathHelper.Clamp(Vector2.Distance(anchor, target.Center) + 34f, 72f, 720f); ReaperStrikeProjectile.Spawn(Projectile.GetSource_FromThis(), Projectile.owner, snapshot, ReaperHitKind.PrimaryDerived, seed % 6, anchor, direction, ReaperStrikeShape.Line, length, 22f + tempo * 3f, MathHelper.Lerp(0.30f, 0.48f, (tempo - 1f) / 1.6f), actionId: seed * 509 + age); Projectile.netUpdate = true; } } public override bool PreDraw(ref Color lightColor) { if (!configured || Main.dedServ) return false; float fadeIn = MathHelper.Clamp(age / 10f, 0f, 1f); float fadeOut = MathHelper.Clamp(Projectile.timeLeft / 35f, 0f, 1f); float opacity = fadeIn * fadeOut; Asset asset = ModContent.Request( ReaperCombatRegistry.GetTexturePath(ReaperFormId.Death, ReaperStage.StageIII)); Texture2D texture = asset.Value; Vector2 anchorRatio = ReaperCombatRegistry.GetHandleAnchor( ReaperFormId.Death, ReaperStage.StageIII); Vector2 origin = new(texture.Width * anchorRatio.X, texture.Height * anchorRatio.Y); SpriteEffects effects = attackDirection.X < 0f ? SpriteEffects.FlipVertically : SpriteEffects.None; if ((effects & SpriteEffects.FlipVertically) != 0) origin.Y = texture.Height - origin.Y; float correction = ReaperCombatRegistry.GetTextureRotationCorrection( ReaperFormId.Death, ReaperStage.StageIII); Vector2 drawPosition = anchor - Main.screenPosition; Color shadow = new Color(32, 0, 18, 0) * (opacity * 0.58f); Color edge = new Color(245, 24, 80, 0) * (opacity * 0.72f); Main.EntitySpriteDraw(texture, drawPosition, null, shadow, weaponAngle + correction - 0.12f, origin, 1.08f, effects); Main.EntitySpriteDraw(texture, drawPosition, null, edge, weaponAngle + correction, origin, 0.93f, effects); Main.EntitySpriteDraw(texture, drawPosition, null, Color.White * (opacity * 0.34f), weaponAngle + correction, origin, 0.855f, effects); return false; } public override void SendExtraAI(BinaryWriter writer) { writer.Write(configured); if (!configured) return; snapshot.Write(writer); writer.WriteVector2(anchor); writer.WriteVector2(attackDirection); writer.Write(tempo); writer.Write(seed); writer.Write((short)Math.Clamp(age, 0, short.MaxValue)); writer.Write((short)Math.Clamp(attackClock, 0, short.MaxValue)); } 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); Vector2 incomingAnchor = reader.ReadVector2(); Vector2 incomingDirection = reader.ReadVector2(); float incomingTempo = reader.ReadSingle(); int incomingSeed = reader.ReadInt32(); int incomingAge = reader.ReadInt16(); int incomingClock = reader.ReadInt16(); if (Main.netMode == NetmodeID.Server) return; snapshot = incomingSnapshot; anchor = incomingAnchor; attackDirection = incomingDirection.SafeNormalize(Vector2.UnitX); tempo = MathHelper.Clamp(incomingTempo, 1f, 2.6f); seed = incomingSeed; age = incomingAge; attackClock = incomingClock; configured = true; } private void Configure(in SickleCombatSnapshot value, Vector2 position, Vector2 initialDirection, float attackTempo, int stableSeed) { snapshot = value; anchor = position; attackDirection = initialDirection.SafeNormalize(Vector2.UnitX); tempo = MathHelper.Clamp(attackTempo, 1f, 2.6f); seed = stableSeed; age = 0; attackClock = 0; configured = true; serverAuthorized = Main.netMode != NetmodeID.MultiplayerClient; Projectile.Center = anchor; Projectile.timeLeft = 240; } private NPC? FindTarget(float maximumDistance) { NPC? nearest = null; float best = maximumDistance * maximumDistance; foreach (NPC npc in Main.ActiveNPCs) { if (!ReaperTargeting.IsValidWeaponTarget(npc) || !ReaperTargeting.IsTrainingDummy(npc) && !npc.CanBeChasedBy(Projectile)) continue; float distance = Vector2.DistanceSquared(npc.Center, anchor); if (distance >= best) continue; best = distance; nearest = npc; } return nearest; } private static void TrimOldest(int owner) { int type = ModContent.ProjectileType(); Projectile? oldest = null; int count = 0; foreach (Projectile projectile in Main.ActiveProjectiles) { if (projectile.owner != owner || projectile.type != type) continue; count++; if (oldest is null || projectile.timeLeft < oldest.timeLeft) oldest = projectile; } if (count >= MaximumPhantomsPerOwner) oldest?.Kill(); } }