using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Graphics; using System; using System.Collections.Generic; using Terraria; using Terraria.ModLoader; namespace SoulHarvest.Common; /// /// Client-only world-space presentation for synchronized Void Reaper direct hits. /// The server supplies immutable event data; this system only draws short-lived /// negative-space rifts and cannot create damage or gameplay state. /// [Autoload(Side = ModSide.Client)] internal sealed class ReaperVoidHitVisualSystem : ModSystem { private const int MaximumVisuals = 40; private const int MaximumRecentKeys = 2048; private const ulong DeduplicationFrames = 300UL; private static readonly List Visuals = []; private static readonly Dictionary RecentKeys = []; private static readonly Queue RecentOrder = []; internal static void ReportHit( int owner, ReaperStage stage, int actionId, int target, Vector2 origin, Vector2 direction, int seed) { if (Main.dedServ || Main.gameMenu || owner < 0 || owner >= Main.maxPlayers || stage is < ReaperStage.StageI or > ReaperStage.StageIII || target is < -1 || target >= Main.maxNPCs || !IsFinite(origin) || !IsFinite(direction)) { return; } direction = direction.SafeNormalize(Vector2.UnitX); if (!TryReservePresentation(owner, actionId, target)) return; if (Visuals.Count >= MaximumVisuals) Visuals.RemoveAt(0); Visuals.Add(new ReaperVoidHitVisual { Owner = owner, Stage = stage, ActionId = actionId, Target = target, Seed = seed, Origin = origin, Direction = direction, Duration = GetDuration(stage) }); } public override void OnWorldUnload() => Clear(); public override void Unload() => Clear(); public override void PostUpdateEverything() { if (Main.gameMenu) { Clear(); return; } PruneRecentKeys(Main.GameUpdateCount); for (int index = Visuals.Count - 1; index >= 0; index--) { if (++Visuals[index].Age >= Visuals[index].Duration) Visuals.RemoveAt(index); } } public override void PostDrawTiles() { if (Main.dedServ || Main.gameMenu || Visuals.Count == 0) return; SpriteBatch batch = Main.spriteBatch; batch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, DepthStencilState.None, RasterizerState.CullNone, null, Main.GameViewMatrix.TransformationMatrix); foreach (ReaperVoidHitVisual visual in Visuals) { // Keep the tear at its authoritative impact coordinate. Following only // an NPC slot number can reattach it to a newly spawned NPC after the // original target dies, and also makes fast targets cross a snap limit. Vector2 center = visual.Origin; if (IsNearScreen(center, 180f)) DrawRift(batch, visual, center); } batch.End(); } private static void DrawRift(SpriteBatch batch, ReaperVoidHitVisual visual, Vector2 center) { float progress = visual.Age / (float)Math.Max(1, visual.Duration); float reveal = Smooth01(progress / 0.18f); float fade = 1f - Smooth01((progress - 0.62f) / 0.38f); float opacity = reveal * fade; if (opacity <= 0.001f) return; Vector2 size = GetBladeSize(visual.Stage); float impactPulse = 1f + (float)Math.Exp(-Math.Pow((progress - 0.13f) / 0.055f, 2f)) * 0.10f; float length = size.X; float width = size.Y * impactPulse; float side = (visual.Seed & 1) == 0 ? 1f : -1f; Vector2 attackDirection = visual.Direction.SafeNormalize(Vector2.UnitX); Vector2 axis = attackDirection.RotatedBy(side * MathHelper.PiOver2 * 0.72f) .SafeNormalize(Vector2.UnitX); ReaperVoidBackdropRenderer.DrawHitRift(batch, center, axis, length, width, visual.Seed, reveal, opacity); } private static int GetDuration(ReaperStage stage) => stage switch { ReaperStage.StageI => 30, ReaperStage.StageII => 37, _ => 45 }; private static Vector2 GetBladeSize(ReaperStage stage) => stage switch { ReaperStage.StageI => new Vector2(126f, 12f), ReaperStage.StageII => new Vector2(158f, 16f), _ => new Vector2(194f, 20f) }; private static bool IsNearScreen(Vector2 center, float margin) { Rectangle area = new( (int)(Main.screenPosition.X - margin), (int)(Main.screenPosition.Y - margin), Main.screenWidth + (int)(margin * 2f), Main.screenHeight + (int)(margin * 2f)); return area.Contains(center.ToPoint()); } private static bool TryReservePresentation(int owner, int actionId, int target) { ulong now = Main.GameUpdateCount; PruneRecentKeys(now); HitKey key = new(owner, actionId, target); if (RecentKeys.TryGetValue(key, out ulong tick) && now >= tick && now - tick <= DeduplicationFrames) { return false; } while (RecentKeys.Count >= MaximumRecentKeys && RecentOrder.Count > 0) RemoveOldestRecentKey(); RecentKeys[key] = now; RecentOrder.Enqueue(new RecentHit(key, now)); return true; } private static void PruneRecentKeys(ulong now) { while (RecentOrder.Count > 0) { RecentHit oldest = RecentOrder.Peek(); if (now >= oldest.Tick && now - oldest.Tick <= DeduplicationFrames) break; RemoveOldestRecentKey(); } } private static void RemoveOldestRecentKey() { RecentHit oldest = RecentOrder.Dequeue(); if (RecentKeys.TryGetValue(oldest.Key, out ulong tick) && tick == oldest.Tick) RecentKeys.Remove(oldest.Key); } private static void Clear() { Visuals.Clear(); RecentKeys.Clear(); RecentOrder.Clear(); } private static float Smooth01(float value) { value = MathHelper.Clamp(value, 0f, 1f); return value * value * (3f - 2f * value); } private static bool IsFinite(Vector2 value) => float.IsFinite(value.X) && float.IsFinite(value.Y); private readonly record struct HitKey(int Owner, int ActionId, int Target); private readonly record struct RecentHit(HitKey Key, ulong Tick); }