using System.Numerics;
namespace Aeshnidae.Invasions;
///
/// The show (Tom, 2026-09-27: "fireworks or any kind of visual effect"). Client effects are
/// ACE's PlayScripts - effects already in every retail client - broadcast on an object everyone
/// near can see. The fireworks are a retail object (Settings.CelebrationWcid, "Anniversary
/// Effect"), set down round the town and taken away again after CelebrationSeconds. No dat is
/// touched. World thread only.
///
public static class Effects
{
private sealed record Display(WorldObject Object, DateTime Until);
private static readonly List _displays = new();
private static readonly HashSet _warned = new(StringComparer.OrdinalIgnoreCase);
private static Settings S => Mod.Settings;
public static int Showing => _displays.Count;
/// A PlayScript by name ("PortalExit"), or null for empty/unknown (unknown is logged once).
public static PlayScript? Parse(string? name)
{
if (string.IsNullOrWhiteSpace(name))
return null;
if (Enum.TryParse(name.Trim(), ignoreCase: true, out var script) && script != PlayScript.Invalid)
return script;
if (_warned.Add(name))
ModManager.Log($"[{Mod.Name}] '{name}' is not a PlayScript name; that effect is off", ModManager.LogLevel.Warn);
return null;
}
/// Play the named effect on the object, for everyone who can see it.
public static bool Play(WorldObject? wo, string? effect)
{
var script = Parse(effect);
if (wo is null || script is null || wo.IsDestroyed)
return false;
try
{
wo.EnqueueBroadcast(new GameMessageScript(wo.Guid, script.Value));
return true;
}
catch (Exception ex)
{
ModManager.Log($"[{Mod.Name}] effect {script} on {wo.Name} failed: {ex.Message}", ModManager.LogLevel.Warn);
return false;
}
}
/// Set the fireworks off round a point (global metres). Returns how many displays went up.
public static int Celebrate(Vector2 center)
{
if (S.CelebrationWcid == 0 || S.CelebrationCount <= 0)
return 0;
var until = DateTime.UtcNow.AddSeconds(Math.Max(5, S.CelebrationSeconds));
var radius = Math.Max(6, S.CelebrationRadius);
var up = 0;
for (var i = 0; i < S.CelebrationCount; i++)
{
// Spread evenly round the centre, each at a random distance.
var angle = Math.PI * 2 * i / S.CelebrationCount + Random.Shared.NextDouble() * 0.5;
var spot = Placement.RingSpot(center, 5, radius, tries: 10, angle: angle, spread: 0.4)
?? Placement.RingSpot(center, 0, radius, tries: 10);
if (spot is null)
continue;
WorldObject? wo = null;
try
{
wo = WorldObjectFactory.CreateNewWorldObject(S.CelebrationWcid);
if (wo is null)
{
ModManager.Log($"[{Mod.Name}] celebration wcid {S.CelebrationWcid} does not exist", ModManager.LogLevel.Warn);
return up;
}
wo.Location = spot;
// The backstop if this mod is gone before it takes them away.
wo.Lifespan = (int)Math.Max(5, S.CelebrationSeconds) + 30;
if (!wo.EnterWorld())
{
wo.Destroy();
continue;
}
_displays.Add(new Display(wo, until));
up++;
}
catch (Exception ex)
{
ModManager.Log($"[{Mod.Name}] a firework display failed: {ex.Message}", ModManager.LogLevel.Warn);
try { wo?.Destroy(); } catch { /* already gone */ }
}
}
return up;
}
/// Take away displays whose time is up.
public static void Tick(DateTime now)
{
for (var i = _displays.Count - 1; i >= 0; i--)
{
var d = _displays[i];
if (now < d.Until && !d.Object.IsDestroyed)
continue;
try
{
if (!d.Object.IsDestroyed)
d.Object.Destroy();
}
catch { /* its lifespan will */ }
_displays.RemoveAt(i);
}
}
/// Mod.Dispose: take the displays away on the world thread (their Lifespan is the backstop).
public static void Shutdown()
{
if (_displays.Count == 0)
return;
var left = _displays.ToList();
_displays.Clear();
try
{
WorldManager.EnqueueAction(new ActionEventDelegate(() =>
{
foreach (var d in left)
try { if (!d.Object.IsDestroyed) d.Object.Destroy(); } catch { /* lifespan */ }
}));
}
catch { /* their Lifespan takes them */ }
}
}