using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using System;
using System.Collections.Generic;
using Terraria;
using Terraria.ModLoader;
namespace SoulHarvest.Common;
/// <summary>
/// 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.
/// </summary>
[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<ReaperVoidHitVisual> Visuals = [];
private static readonly Dictionary<HitKey, ulong> RecentKeys = [];
private static readonly Queue<RecentHit> 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);
}
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using System;
using System.Collections.Generic;
using Terraria;
using Terraria.ModLoader;
namespace SoulHarvest.Common;
/// <summary>
/// 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.
/// </summary>
[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<ReaperVoidHitVisual> Visuals = [];
private static readonly Dictionary<HitKey, ulong> RecentKeys = [];
private static readonly Queue<RecentHit> 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);
}