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 */ } } }