using SoulHarvest.Common; using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Graphics; using System; using System.IO; using Terraria; using Terraria.Audio; using Terraria.GameContent; using Terraria.ID; using Terraria.ModLoader; namespace SoulHarvest.Projectiles; public sealed class ReaperStrikeProjectile : ModProjectile { private const int DeathCutPointCount = 65; private readonly Vector2[] deathCutPoints = new Vector2[DeathCutPointCount]; private SickleCombatSnapshot snapshot; private ReaperHitKind hitKind; private ReaperStrikeShape shape; private int phase; private int delay; private int age; private float length; private float width; private bool configured; private bool serverAuthorized; private bool serverDamageTriggered; private int authoritativeDamage; private Vector2 authoritativeCenter; private Vector2 authoritativeVelocity; public override string Texture => "Terraria/Images/Projectile_0"; public static int Spawn(Terraria.DataStructures.IEntitySource source, int owner, in SickleCombatSnapshot snapshot, ReaperHitKind hitKind, int phase, Vector2 origin, Vector2 direction, ReaperStrikeShape shape, float length, float width, float damageMultiplier, int delay = 0, int actionId = -1) { if (Main.netMode == NetmodeID.MultiplayerClient) return -1; direction = direction.SafeNormalize(Vector2.UnitX); int index = Projectile.NewProjectile(source, origin, direction, ModContent.ProjectileType(), Math.Max(1, (int)Math.Round(snapshot.Damage * damageMultiplier)), snapshot.Knockback, owner); if (index < 0 || index >= Main.maxProjectiles) return -1; Projectile projectile = Main.projectile[index]; projectile.originalDamage = snapshot.Damage; projectile.CritChance = hitKind == ReaperHitKind.Special ? snapshot.CritChance : 0; if (projectile.ModProjectile is ReaperStrikeProjectile strike) strike.Configure(snapshot, hitKind, phase, shape, length, width, delay); projectile.GetGlobalProjectile().ConfigureReaperProjectile( projectile, snapshot, hitKind, phase, direction.ToRotation(), actionId); ReaperProjectileHelper.SyncNewProjectile(projectile); return index; } 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 = 240; // This projectile owns the client-side slash telegraph. `hide` projectiles // are not submitted to Terraria's normal projectile draw cache unless a // DrawBehind hook explicitly adds them, so PreDraw never ran for any of the // server-authoritative form strikes. Projectile.hide = false; Projectile.netImportant = true; Projectile.DamageType = DamageClass.Melee; Projectile.usesLocalNPCImmunity = true; Projectile.localNPCHitCooldown = 10; } public override bool ShouldUpdatePosition() => false; // The complete visible slash window is authoritative. Local immunity is // longer than the remaining lifetime, so each actor still hits a target once // instead of leaving five decorative frames with no collision. public override bool? CanDamage() { // ReaperStrike is created by the server but retains the casting player's // owner for attribution. Terraria normally lets that owning client run // friendly-projectile collision, which makes the slash look successful // locally while the dedicated server never executes the Reaper hit hooks. // The server explicitly calls Damage below; clients are presentation-only. if (Main.netMode == NetmodeID.MultiplayerClient) return false; return configured && age >= delay && age < delay + 8 ? null : false; } public override void AI() { if (Main.netMode == NetmodeID.Server) { if (!serverAuthorized) { Projectile.Kill(); return; } Projectile.damage = authoritativeDamage; Projectile.Center = authoritativeCenter; Projectile.velocity = authoritativeVelocity; } if (!configured) return; age++; if (Main.netMode == NetmodeID.Server && !serverDamageTriggered && age >= Math.Max(1, delay)) { serverDamageTriggered = true; ReaperProjectileHelper.DamageOnDedicatedServer(Projectile); } int visualLifetime = GetVisualLifetime(); if (IsDeathUltimateWorldCut && Main.netMode != NetmodeID.Server) RecordDeathUltimateWorldCut(visualLifetime); Projectile.timeLeft = Math.Max(2, visualLifetime + 1 - age); if (age == Math.Max(1, delay) && Main.netMode != NetmodeID.Server) SpawnBurst(); if (age >= visualLifetime) Projectile.Kill(); } public override bool? Colliding(Rectangle projHitbox, Rectangle targetHitbox) { Vector2 direction = Projectile.velocity.SafeNormalize(Vector2.UnitX); return shape switch { ReaperStrikeShape.Circle => CircleIntersects(targetHitbox, Projectile.Center, length), ReaperStrikeShape.Cross => LineIntersects(targetHitbox, direction) || LineIntersects(targetHitbox, direction.RotatedBy(MathHelper.PiOver2)), _ => LineIntersects(targetHitbox, direction) }; } public override void OnHitNPC(NPC target, NPC.HitInfo hit, int damageDone) { if (Main.netMode == NetmodeID.Server || !configured || snapshot.Form != ReaperFormId.Base || hitKind != ReaperHitKind.Special) { return; } Vector2 axis = Projectile.velocity.SafeNormalize(Vector2.UnitX); Vector2 slash = axis.RotatedBy(-0.48f) .SafeNormalize(Vector2.UnitY); ReaperVfxDirector.TriggerGlobalImpact(slash, 3.2f, 4, new Color(90, 220, 230), 0f, 0, 0.08f); SoundEngine.PlaySound(SoundID.Item71 with { Volume = 0.52f, Pitch = 0.3f, PitchVariance = 0.07f }, target.Center); for (int index = 0; index < 13; index++) { Vector2 velocity = slash.RotatedBy( Main.rand.NextFloat(-0.68f, 0.68f)) * Main.rand.NextFloat(3f, 7.8f); Dust dust = Dust.NewDustPerfect( target.Center + Main.rand.NextVector2Circular(8f, 8f), DustID.AncientLight, velocity, 25, index % 4 == 0 ? new Color(225, 255, 250) : new Color(65, 220, 235), Main.rand.NextFloat(0.78f, 1.16f)); dust.noGravity = true; } Vector2 towardOwner = Projectile.owner >= 0 && Projectile.owner < Main.maxPlayers ? (Main.player[Projectile.owner].Center - target.Center) .SafeNormalize(-axis) : -axis; for (int index = 0; index < 5; index++) { Dust soul = Dust.NewDustPerfect( target.Center + Main.rand.NextVector2Circular(11f, 11f), DustID.AncientLight, towardOwner.RotatedBy(Main.rand.NextFloat(-0.34f, 0.34f)) * Main.rand.NextFloat(1.4f, 3.2f), 80, new Color(85, 225, 235), Main.rand.NextFloat(0.62f, 0.92f)); soul.noGravity = true; } } public override bool PreDraw(ref Color lightColor) { if (!configured || age < delay || age > delay + 7) { if (!IsDeathUltimateWorldCut) return false; } if (IsDeathUltimateWorldCut) { DrawDeathUltimateShatterFragments(); return false; } // Void circle strikes are the invisible collision sweep of the held // weapon. Its exact blade-tip tear is already drawn by the shared trail; // a conventional purple ring would contradict the cut-out background. if (snapshot.Form == ReaperFormId.Void && shape == ReaperStrikeShape.Circle) return false; float opacity = 1f - (age - delay) / 8f; Color outer = ReaperCombatRegistry.GetPrimaryColor(snapshot.Form) with { A = 0 }; Color core = ReaperCombatRegistry.GetSecondaryColor(snapshot.Form) with { A = 0 }; Vector2 direction = Projectile.velocity.SafeNormalize(Vector2.UnitX); if (shape == ReaperStrikeShape.Circle) { const int segments = 28; Vector2 previous = Projectile.Center + Vector2.UnitX * length; for (int index = 1; index <= segments; index++) { Vector2 next = Projectile.Center + (MathHelper.TwoPi * index / segments).ToRotationVector2() * length; DrawLine(previous, next, width * 0.42f, outer * opacity); DrawLine(previous, next, Math.Max(2f, width * 0.12f), core * opacity); previous = next; } } else { DrawSlash(direction, outer, core, opacity); if (shape == ReaperStrikeShape.Cross) DrawSlash(direction.RotatedBy(MathHelper.PiOver2), outer, core, opacity); } DrawEnergyMotes(direction, core, opacity); return false; } public override void SendExtraAI(BinaryWriter writer) { writer.Write(configured); if (!configured) return; snapshot.Write(writer); writer.Write((byte)hitKind); writer.Write((byte)shape); writer.Write((byte)Math.Clamp(phase, 0, byte.MaxValue)); writer.Write((short)Math.Clamp(delay, 0, short.MaxValue)); writer.Write(length); writer.Write(width); } public override void ReceiveExtraAI(BinaryReader reader) { bool incomingConfigured = reader.ReadBoolean(); if (!incomingConfigured) { if (Main.netMode != NetmodeID.Server) configured = false; return; } SickleCombatSnapshot incomingSnapshot = SickleCombatSnapshot.Read(reader); ReaperHitKind incomingHitKind = (ReaperHitKind)reader.ReadByte(); ReaperStrikeShape incomingShape = (ReaperStrikeShape)reader.ReadByte(); int incomingPhase = reader.ReadByte(); int incomingDelay = reader.ReadInt16(); float incomingLength = reader.ReadSingle(); float incomingWidth = reader.ReadSingle(); if (Main.netMode == NetmodeID.Server) return; configured = true; snapshot = incomingSnapshot; hitKind = incomingHitKind; shape = incomingShape; phase = incomingPhase; delay = incomingDelay; length = incomingLength; width = incomingWidth; } private void Configure(in SickleCombatSnapshot value, ReaperHitKind kind, int hitPhase, ReaperStrikeShape strikeShape, float strikeLength, float strikeWidth, int strikeDelay) { snapshot = value; hitKind = kind; phase = hitPhase; shape = strikeShape; length = Math.Max(8f, strikeLength); width = Math.Max(2f, strikeWidth); delay = Math.Max(0, strikeDelay); configured = true; serverAuthorized = Main.netMode != NetmodeID.MultiplayerClient; serverDamageTriggered = false; authoritativeDamage = Math.Max(1, Projectile.damage); authoritativeCenter = Projectile.Center; authoritativeVelocity = Projectile.velocity; Projectile.timeLeft = delay + 10; } private bool LineIntersects(Rectangle targetHitbox, Vector2 direction) { Vector2 start = shape == ReaperStrikeShape.Cross ? Projectile.Center - direction * length : Projectile.Center; Vector2 end = Projectile.Center + direction * length; float collisionPoint = 0f; return Collision.CheckAABBvLineCollision(targetHitbox.TopLeft(), targetHitbox.Size(), start, end, width, ref collisionPoint); } private static bool CircleIntersects(Rectangle target, Vector2 center, float radius) { float closestX = MathHelper.Clamp(center.X, target.Left, target.Right); float closestY = MathHelper.Clamp(center.Y, target.Top, target.Bottom); return Vector2.DistanceSquared(center, new Vector2(closestX, closestY)) <= radius * radius; } private void DrawSlash(Vector2 direction, Color outer, Color core, float opacity) { Vector2 start = shape == ReaperStrikeShape.Cross ? Projectile.Center - direction * length : Projectile.Center; Vector2 end = Projectile.Center + direction * length; DrawLine(start, end, width, outer * opacity * 0.72f); DrawLine(start, end, Math.Max(2f, width * 0.22f), core * opacity); } private bool IsDeathUltimateWorldCut => configured && snapshot.Form == ReaperFormId.Death && hitKind == ReaperHitKind.Ultimate && shape == ReaperStrikeShape.Line; private int GetVisualLifetime() { if (!IsDeathUltimateWorldCut) return delay + 8; // Every cut remains fixed in the world until the shared shatter tick, // then fractures for the rest of the controller timeline. return Math.Max(18, ReaperDeathUltimateGeometry.ShatterTick - ReaperDeathUltimateGeometry.GetCutTick(phase) + 18); } private void RecordDeathUltimateWorldCut(int lifetime) { Vector2 direction = Projectile.velocity.SafeNormalize(Vector2.UnitX); for (int index = 0; index < deathCutPoints.Length; index++) { deathCutPoints[index] = Projectile.Center + direction * length * (index / (float)(deathCutPoints.Length - 1)); } float completion = Smooth01(age / 4f); float fracture = Smooth01(MathHelper.Clamp( (age - (lifetime - 18f)) / 18f, 0f, 1f)); float terminalFade = 1f - Smooth01(MathHelper.Clamp( (fracture - 0.78f) / 0.22f, 0f, 1f)); DeathDomainTrailVisualSystem.Record(Projectile.owner, Projectile.identity, deathCutPoints, Math.Max(ReaperDeathUltimateGeometry.CutVisualWidth, width * 3.1f), completion * terminalFade * 0.98f, mergeOverlappingRims: true, fracture: fracture); } private void DrawDeathUltimateShatterFragments() { int lifetime = GetVisualLifetime(); float fracture = Smooth01(MathHelper.Clamp( (age - (lifetime - 18f)) / 18f, 0f, 1f)); if (fracture <= 0.001f) return; Vector2 axis = Projectile.velocity.SafeNormalize(Vector2.UnitX); Vector2 normal = axis.RotatedBy(MathHelper.PiOver2); for (int shard = 0; shard < 7; shard++) { uint hash = unchecked((uint)(Projectile.identity * 747796405 + shard * 2891336453 + phase * 97)); hash ^= hash >> 16; float along = 0.08f + (hash & 0xFFFFu) / 65535f * 0.84f; float side = (shard & 1) == 0 ? -1f : 1f; float tilt = 0.22f + ((hash >> 16) & 0xFFu) / 255f * 0.48f; Vector2 origin = Projectile.Center + axis * length * along; Vector2 direction = (normal * side + axis * tilt) .SafeNormalize(normal * side); float branchLength = (22f + shard % 3 * 9f) * MathHelper.Lerp(0.35f, 1f, fracture); Vector2 end = origin + direction * branchLength; DeathDomainPrimitiveTextureSystem.DrawSmoothWorldLine(origin, end, new Color(30, 0, 14) * (fracture * 0.84f), 5.5f); DeathDomainPrimitiveTextureSystem.DrawSmoothWorldLine(origin, end, new Color(255, 34, 82) * (fracture * 0.78f), 1.7f); } } private static float Smooth01(float value) { value = MathHelper.Clamp(value, 0f, 1f); return value * value * (3f - 2f * value); } private static void DrawLine(Vector2 start, Vector2 end, float drawWidth, Color color) { DeathDomainPrimitiveTextureSystem.DrawSmoothWorldLine(start, end, color, drawWidth); } private void DrawEnergyMotes(Vector2 direction, Color color, float opacity) { int motes = shape == ReaperStrikeShape.Circle ? 5 : 3; for (int index = 0; index < motes; index++) { float travel = (age * 0.13f + index / (float)motes + phase * 0.071f) % 1f; Vector2 point; if (shape == ReaperStrikeShape.Circle) { float angle = travel * MathHelper.TwoPi + phase * 0.19f; point = Projectile.Center + angle.ToRotationVector2() * length; } else { Vector2 start = shape == ReaperStrikeShape.Cross ? Projectile.Center - direction * length : Projectile.Center; Vector2 end = Projectile.Center + direction * length; point = Vector2.Lerp(start, end, travel); if (shape == ReaperStrikeShape.Cross && (index & 1) != 0) { Vector2 cross = direction.RotatedBy(MathHelper.PiOver2); point = Vector2.Lerp(Projectile.Center - cross * length, Projectile.Center + cross * length, travel); } } float radius = 1.8f + (index % 2) * 0.8f; DeathDomainPrimitiveTextureSystem.DrawSmoothDisc(Main.spriteBatch, point - Main.screenPosition, radius + 2.2f, color * (opacity * 0.25f)); DeathDomainPrimitiveTextureSystem.DrawSmoothDisc(Main.spriteBatch, point - Main.screenPosition, radius, color * (opacity * 0.82f)); } } private void SpawnBurst() { Color color = ReaperCombatRegistry.GetPrimaryColor(snapshot.Form); int dustType = ReaperCombatRegistry.GetDust(snapshot.Form); int count = shape == ReaperStrikeShape.Circle ? 24 : 14; for (int index = 0; index < count; index++) { Vector2 velocity = (MathHelper.TwoPi * index / count).ToRotationVector2() * Main.rand.NextFloat(1.2f, 4.2f); Dust dust = Dust.NewDustPerfect(Projectile.Center, dustType, velocity, 70, color, 0.9f); dust.noGravity = true; } } }