From d3b43f6f0e79de2e9115b3774fa4fc1a3f38e717 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Sun, 28 Dec 2025 18:26:03 -0800 Subject: [PATCH] feat: Fixes spawner bugs with position finding, Adds [ShowSpawnerBorders (#2297) ### Summary - Fixes BaseSpawner and Spawner bugs. - Adds [ShowSpawnerBorders to see the spawn bounds. --- .../UOContent/Engines/Spawners/BaseSpawner.cs | 67 +++++-- .../Commands/ShowSpawnerBordersCommand.cs | 182 +++++++++++++++++ .../UOContent/Engines/Spawners/Spawner.cs | 1 + Projects/UOContent/Gumps/AdminGump.cs | 6 + .../UOContent/Items/Misc/ProjectedItem.cs | 189 ++++++++++++++++++ ...ver.Engines.Spawners.SpawnerBorder.v0.json | 4 + .../Server.Items.ProjectedItem.v0.json | 19 ++ 7 files changed, 452 insertions(+), 16 deletions(-) create mode 100644 Projects/UOContent/Engines/Spawners/Commands/ShowSpawnerBordersCommand.cs create mode 100644 Projects/UOContent/Items/Misc/ProjectedItem.cs create mode 100644 Projects/UOContent/Migrations/Server.Engines.Spawners.SpawnerBorder.v0.json create mode 100644 Projects/UOContent/Migrations/Server.Items.ProjectedItem.v0.json diff --git a/Projects/UOContent/Engines/Spawners/BaseSpawner.cs b/Projects/UOContent/Engines/Spawners/BaseSpawner.cs index aa90a1de9..8121bd585 100644 --- a/Projects/UOContent/Engines/Spawners/BaseSpawner.cs +++ b/Projects/UOContent/Engines/Spawners/BaseSpawner.cs @@ -195,19 +195,32 @@ public abstract partial class BaseSpawner : Item, ISpawner } set { - // Find the surface below the spawner (handles spawners placed in air) - var surfaceZ = Map != null && Map != Map.Internal - ? Map.GetTopSurfaceZ(Location) - : Location.Z; + int z; + int depth; + if (SpawnBounds != default) + { + z = SpawnBounds.Start.Z; + depth = SpawnBounds.Depth; + } + else if (value == 0) + { + z = Location.Z; + depth = 0; + } + else + { + z = -128; + depth = 256; + } // Create square bounds centered on spawner, Z range from surface to surface + 16 SpawnBounds = new Rectangle3D( Location.X - value, Location.Y - value, - surfaceZ, + z, value * 2 + 1, value * 2 + 1, - 17 // Z range: surface to surface + 16 + depth ); this.MarkDirty(); InvalidateProperties(); @@ -297,21 +310,34 @@ public abstract partial class BaseSpawner : Item, ISpawner json.GetProperty("minDelay", options, DefaultMinDelay, out TimeSpan minDelay); json.GetProperty("maxDelay", options, DefaultMaxDelay, out TimeSpan maxDelay); json.GetProperty("team", options, out int team); - json.GetProperty("homeRange", options, out int homeRange); + json.GetProperty("homeRange", options, -1, out int homeRange); json.GetProperty("walkingRange", options, out _walkingRange); // Handle legacy homeRange format (new spawnBounds format handled by derived classes) - if (homeRange > 0 && json.GetProperty("location", options, out Point3D location)) + if (homeRange >= 0 && json.GetProperty("location", options, out Point3D location)) { + int z; + int depth; + if (homeRange == 0) + { + z = location.Z; + depth = 0; + } + else + { + z = -128; + depth = 256; + } + // Fall back to homeRange with location for oldest format // Note: Map not available during JSON loading, so use location.Z directly SpawnBounds = new Rectangle3D( location.X - homeRange, location.Y - homeRange, - location.Z, + z, homeRange * 2 + 1, homeRange * 2 + 1, - 17 + depth ); } @@ -438,9 +464,8 @@ public abstract partial class BaseSpawner : Item, ISpawner { Defrag(); - if (spawn != null) + if (spawn != null && Spawned?.Remove(spawn, out var entry) == true) { - Spawned.Remove(spawn, out var entry); entry?.RemoveFromSpawned(spawn); } @@ -749,6 +774,16 @@ public abstract partial class BaseSpawner : Item, ISpawner } } + public override void OnMapChange() + { + base.OnMapChange(); + + if (IsHomeRangeStyleAt(Location)) + { + ShowSpawnerBordersCommand.RemoveProjection(this); + } + } + public override void OnLocationChange(Point3D oldLocation) { base.OnLocationChange(oldLocation); @@ -757,6 +792,7 @@ public abstract partial class BaseSpawner : Item, ISpawner if (IsHomeRangeStyleAt(oldLocation)) { HomeRange = SpawnBounds.Width / 2; + ShowSpawnerBordersCommand.RemoveProjection(this); } // Reset spawn position optimization state when spawner moves @@ -1359,7 +1395,7 @@ public abstract partial class BaseSpawner : Item, ISpawner if (e != null) { - Spawned.Remove(e); + Spawned?.Remove(e); entry.Spawned.RemoveAt(j); e.Delete(); } @@ -1398,14 +1434,13 @@ public abstract partial class BaseSpawner : Item, ISpawner // Handle v10 migration - convert HomeRange to SpawnBounds now that Location is available if (_pendingHomeRangeMigrations.Remove(this, out var homeRange)) { - var surfaceZ = Map?.GetTopSurfaceZ(Location) ?? Location.Z; SpawnBounds = new Rectangle3D( Location.X - homeRange, Location.Y - homeRange, - surfaceZ, + -128, homeRange * 2 + 1, homeRange * 2 + 1, - 17 + 256 ); } diff --git a/Projects/UOContent/Engines/Spawners/Commands/ShowSpawnerBordersCommand.cs b/Projects/UOContent/Engines/Spawners/Commands/ShowSpawnerBordersCommand.cs new file mode 100644 index 000000000..fe7b6693d --- /dev/null +++ b/Projects/UOContent/Engines/Spawners/Commands/ShowSpawnerBordersCommand.cs @@ -0,0 +1,182 @@ +using System.Collections.Generic; +using ModernUO.Serialization; +using Server.Commands.Generic; +using Server.Items; + +namespace Server.Engines.Spawners; + +public class ShowSpawnerBordersCommand : BaseCommand +{ + // ItemID constants for border pieces + private const int SouthEastCorner = 0xF8; // Bottom-right corner + private const int NorthSouthWall = 0xF9; // Horizontal walls + private const int EastWestWall = 0xFA; // Vertical walls + private const int NorthWestCorner = 0xFB; // Top-left corner + + private static readonly Dictionary> _projectedSpawners = []; + + public static void Configure() + { + TargetCommands.Register(new ShowSpawnerBordersCommand()); + } + + public ShowSpawnerBordersCommand() + { + AccessLevel = AccessLevel.Developer; + Supports = CommandSupport.Simple; + Commands = ["ShowSpawnerBorders"]; + ObjectTypes = ObjectTypes.Items; + Usage = "ShowSpawnerBorders"; + Description = "Toggles a projection of the targeted spawner's borders."; + } + + public override void Execute(CommandEventArgs e, object obj) + { + if (obj is not BaseSpawner spawner) + { + LogFailure("That is not a spawner."); + return; + } + + if (_projectedSpawners.Remove(spawner, out var borders)) + { + foreach (var border in borders) + { + border.Delete(); + } + + e.Mobile.SendMessage("Spawner projection removed."); + return; + } + + var bounds = spawner.SpawnBounds; + if (bounds == default) + { + LogFailure("Spawner has no spawn bounds defined."); + return; + } + + borders = CreateBorders(spawner, bounds); + if (borders.Count == 0) + { + LogFailure("Could not create borders for spawner bounds."); + return; + } + + _projectedSpawners[spawner] = borders; + e.Mobile.SendMessage($"Spawner projection created with {borders.Count} border tiles."); + } + + private static List CreateBorders(Item spawner, Rectangle3D bounds) + { + var borders = new List(); + var map = spawner.Map; + var z = spawner.Z; + + var minX = bounds.Start.X; + var minY = bounds.Start.Y; + var maxX = bounds.End.X; // Exclusive + var maxY = bounds.End.Y; // Exclusive + + // NW Corner at (minX - 1, minY - 1) + borders.Add(CreateBorder(spawner, NorthWestCorner, minX - 1, minY - 1, z, map)); + + // SE Corner at (maxX - 1, maxY - 1) + borders.Add(CreateBorder(spawner, SouthEastCorner, maxX - 1, maxY - 1, z, map)); + + // North bound: y = minY - 1, x from minX to maxX - 1 + for (var x = minX; x < maxX; x++) + { + borders.Add(CreateBorder(spawner, NorthSouthWall, x, minY - 1, z, map)); + } + + // South bound: y = maxY - 1, x from minX to maxX - 2 (excluding SE corner) + for (var x = minX; x < maxX - 1; x++) + { + borders.Add(CreateBorder(spawner, NorthSouthWall, x, maxY - 1, z, map)); + } + + // West bound: x = minX - 1, y from minY to maxY - 1 + for (var y = minY; y < maxY; y++) + { + borders.Add(CreateBorder(spawner, EastWestWall, minX - 1, y, z, map)); + } + + // East bound: x = maxX - 1, y from minY to maxY - 2 (excluding SE corner) + for (var y = minY; y < maxY - 1; y++) + { + borders.Add(CreateBorder(spawner, EastWestWall, maxX - 1, y, z, map)); + } + + return borders; + } + + private static SpawnerBorder CreateBorder(Item spawner, int itemId, int x, int y, int z, Map map) + { + var border = new SpawnerBorder(itemId) + { + Spawner = spawner + }; + border.MoveToWorld(new Point3D(x, y, z), map); + return border; + } + + public static void RemoveProjection(Item spawner) + { + if (_projectedSpawners.Remove(spawner, out var borders)) + { + foreach (var border in borders) + { + border.Delete(); + } + } + } + + public static void RemoveBorderFromProjection(Item spawner, SpawnerBorder border) + { + if (_projectedSpawners.TryGetValue(spawner, out var borders)) + { + borders.Remove(border); + if (borders.Count == 0) + { + _projectedSpawners.Remove(spawner); + } + } + } +} + +[SerializationGenerator(0)] +public partial class SpawnerBorder : ProjectedItem +{ + public override string DefaultName => "Spawn Border"; + + [CommandProperty(AccessLevel.Developer, readOnly: true)] + public Item Spawner { get; set; } + + [Constructible] + public SpawnerBorder(int effectItemId) : base(effectItemId) + { + Hue = 0x3F; + MinimumVisible = AccessLevel.Developer; + } + + [AfterDeserialization(false)] + private void AfterDeserialization() + { + Delete(); + } + + protected override bool SendEffect() + { + // Check if spawner was deleted + if (Spawner.Deleted) + { + ShowSpawnerBordersCommand.RemoveBorderFromProjection(Spawner, this); + Spawner = null; + Delete(); + return false; + } + + return base.SendEffect(); + } +} diff --git a/Projects/UOContent/Engines/Spawners/Spawner.cs b/Projects/UOContent/Engines/Spawners/Spawner.cs index d5724819d..32bde4de1 100644 --- a/Projects/UOContent/Engines/Spawners/Spawner.cs +++ b/Projects/UOContent/Engines/Spawners/Spawner.cs @@ -23,6 +23,7 @@ public partial class Spawner : BaseSpawner private bool ShouldSerializeSpawnBounds() => _spawnBounds != default; [SerializableProperty(1)] + [CommandProperty(AccessLevel.Developer)] public override Rectangle3D SpawnBounds { get => _spawnBounds; diff --git a/Projects/UOContent/Gumps/AdminGump.cs b/Projects/UOContent/Gumps/AdminGump.cs index 7dc944e82..ef8ac5d84 100644 --- a/Projects/UOContent/Gumps/AdminGump.cs +++ b/Projects/UOContent/Gumps/AdminGump.cs @@ -3985,31 +3985,37 @@ namespace Server.Gumps if (Core.SA && availableMaps.Includes(MapSelectionFlags.TerMur)) { InvokeCommand("GenerateSpawners Data/Spawns/post-uoml/termur/**.json"); + InvokeCommand("GenerateSpawners Data/Spawns/shared/termur/**.json"); } if (availableMaps.Includes(MapSelectionFlags.Malas)) { InvokeCommand($"GenerateSpawners Data/Spawns/{folder}/malas/**.json"); + InvokeCommand("GenerateSpawners Data/Spawns/shared/malas/**.json"); } if (availableMaps.Includes(MapSelectionFlags.Tokuno)) { InvokeCommand($"GenerateSpawners Data/Spawns/{folder}/tokuno/**.json"); + InvokeCommand("GenerateSpawners Data/Spawns/shared/tokuno/**.json"); } if (availableMaps.Includes(MapSelectionFlags.Ilshenar)) { InvokeCommand($"GenerateSpawners Data/Spawns/{folder}/ilshenar/**.json"); + InvokeCommand("GenerateSpawners Data/Spawns/shared/ilshenar/**.json"); } if (availableMaps.Includes(MapSelectionFlags.Trammel)) { InvokeCommand($"GenerateSpawners Data/Spawns/{folder}/trammel/**.json"); + InvokeCommand("GenerateSpawners Data/Spawns/shared/trammel/**.json"); } if (availableMaps.Includes(MapSelectionFlags.Felucca)) { InvokeCommand($"GenerateSpawners Data/Spawns/{folder}/felucca/**.json"); + InvokeCommand("GenerateSpawners Data/Spawns/shared/felucca/**.json"); } } diff --git a/Projects/UOContent/Items/Misc/ProjectedItem.cs b/Projects/UOContent/Items/Misc/ProjectedItem.cs new file mode 100644 index 000000000..e86025613 --- /dev/null +++ b/Projects/UOContent/Items/Misc/ProjectedItem.cs @@ -0,0 +1,189 @@ +using System; +using System.Collections.Generic; +using ModernUO.Serialization; +using Server.Collections; +using Server.Network; + +namespace Server.Items; + +[SerializationGenerator(0)] +public partial class ProjectedItem : Item +{ + private static readonly HashSet _active = []; + private static Timer _timer; + + [SerializedCommandProperty(AccessLevel.GameMaster)] + [SerializableField(0)] + private int _effectItemId; + + [SerializedCommandProperty(AccessLevel.Developer)] + [SerializableField(1)] + private AccessLevel _minimumVisible; + + private bool _oldVisible; + + [Constructible] + public ProjectedItem(int effectItemId) : base(1) + { + Movable = false; + Visible = false; + _effectItemId = effectItemId; + } + + public override bool HandlesOnMovement => true; + + public override bool CanSeeStaffOnly(Mobile from) => from.AccessLevel >= _minimumVisible; + + public override void ProcessDelta() + { + if (_oldVisible != Visible) + { + Timer.DelayCall( + TimeSpan.Zero, + i => + { + i.ItemID = i.Visible ? i._effectItemId : 1; + i._oldVisible = i.Visible; + i.Activate(); + }, + this + ); + } + + base.ProcessDelta(); + } + + public override void SendInfoTo(NetState ns, ReadOnlySpan world = default) + { + base.SendInfoTo(ns, world); + + var m = ns.Mobile; + if (m != null && !Visible && m.AccessLevel >= _minimumVisible && !_active.Contains(this)) + { + Activate(); + } + } + + public override void OnLocationChange(Point3D oldLocation) + { + base.OnLocationChange(oldLocation); + Activate(); + } + + public override void OnMapChange() + { + base.OnMapChange(); + Activate(); + } + + public override void OnDelete() + { + Deactivate(); + base.OnDelete(); + } + + [AfterDeserialization] + private void AfterDeserialization() + { + _oldVisible = Visible; + } + + public override void OnMovement(Mobile m, Point3D oldLocation) + { + base.OnMovement(m, oldLocation); + + if (!Visible && m.AccessLevel >= _minimumVisible && !_active.Contains(this)) + { + Activate(); + } + } + + private void Activate() + { + if (!_active.Add(this)) + { + return; + } + + if (_timer == null) + { + _timer = Timer.DelayCall(TimeSpan.Zero, TimeSpan.FromSeconds(1.0), OnTick); + } + else if (!_timer.Running) + { + _timer.Interval = TimeSpan.FromSeconds(1.0); + _timer.Start(); + } + } + + private void Deactivate() + { + if (_active.Remove(this) && _active.Count == 0) + { + _timer?.Stop(); + } + } + + private static void OnTick() + { + using var queue = PooledRefQueue.Create(); + foreach (var item in _active) + { + if (!item.SendEffect()) + { + queue.Enqueue(item); + } + } + + while (queue.Count > 0) + { + _active.Remove(queue.Dequeue() as ProjectedItem); + } + + if (_active.Count == 0) + { + _timer?.Stop(); + } + } + + protected virtual bool SendEffect() + { + if (_oldVisible) + { + return false; + } + + var found = false; + Span buffer = new byte[OutgoingEffectPackets.HuedEffectLength].InitializePacket(); + OutgoingEffectPackets.CreateHuedEffect( + buffer, + EffectType.FixedXYZ, + Serial, + Serial, + _effectItemId, + Location, + Location, + 1, + 20, + true, + false, + Hue - 1, + 3 + ); + + foreach (var ns in GetClientsInRange(GetMaxUpdateRange())) + { + found = true; + var from = ns.Mobile; + if (ns.CannotSendPackets() || from == null || from.AccessLevel < _minimumVisible) + { + continue; + } + + from.ProcessDelta(); + ns.Send(buffer); + } + + return found; + } +} diff --git a/Projects/UOContent/Migrations/Server.Engines.Spawners.SpawnerBorder.v0.json b/Projects/UOContent/Migrations/Server.Engines.Spawners.SpawnerBorder.v0.json new file mode 100644 index 000000000..1a85c0821 --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Engines.Spawners.SpawnerBorder.v0.json @@ -0,0 +1,4 @@ +{ + "version": 0, + "type": "Server.Engines.Spawners.SpawnerBorder" +} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Items.ProjectedItem.v0.json b/Projects/UOContent/Migrations/Server.Items.ProjectedItem.v0.json new file mode 100644 index 000000000..9742c8e52 --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Items.ProjectedItem.v0.json @@ -0,0 +1,19 @@ +{ + "version": 0, + "type": "Server.Items.ProjectedItem", + "properties": [ + { + "name": "EffectItemId", + "type": "int", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "MinimumVisible", + "type": "Server.AccessLevel", + "rule": "EnumMigrationRule" + } + ] +} \ No newline at end of file