From 0b34cc441718021727d49f758ce42477e81f7da2 Mon Sep 17 00:00:00 2001 From: Quick <577652+markdwags@users.noreply.github.com> Date: Fri, 26 Dec 2025 20:07:26 -0600 Subject: [PATCH] feat: Changes Spawner HomeRange to SpawnBounds (#2290) ### Summary This PR transitions the spawner system from a simple radius-based model to a flexible 3D boundary system. ### Core Changes * **Replaced `HomeRange` with `SpawnBounds`**: Spawners now use a `Rectangle3D` to define spawn areas instead of a circular integer range. * **Backward Compatibility**: * The `HomeRange` property remains as a helper that generates square `SpawnBounds` centered on the spawner. * Included a migration path (v10 to v11) that automatically converts old range data into new bounds during deserialization. * **Dynamic Bounds Shifting**: If a spawner is moved, its `SpawnBounds` will automatically shift with it, provided the bounds are currently configured as a centered square. * **New Spawn Logic**: Added `SpawnLocationIsHome` toggle. If enabled, spawned mobiles treat their exact spawn coordinates as their "Home" rather than the spawner's location. ### Implementation Details * **Interface Updates**: Updated `ISpawner` to include `WalkingRange`, `SpawnBounds`, and `IsInSpawnBounds()`. * **UI Enhancements**: The Spawner Controller Gump now displays "Custom" for complex bounds and allows copying of the new boundary properties between spawners. * **Refactored Constructors**: Streamlined `BaseSpawner`, `ProximitySpawner`, and `RegionSpawner` constructors to support the new data types. --- Projects/Server/Interfaces.cs | 9 +- .../Engines/ConPVP/Gumps/AcceptTeamGump.cs | 1 - .../Engines/ConPVP/Gumps/ConfirmSignupGump.cs | 1 - .../Spawners/BaseSpawner.Migrations.cs | 67 ++++ .../UOContent/Engines/Spawners/BaseSpawner.cs | 292 +++++++++++++----- .../Commands/ImportSpawnersCommand.cs | 6 +- .../Engines/Spawners/ProximitySpawner.cs | 36 +-- .../Engines/Spawners/RegionSpawner.cs | 26 +- .../UOContent/Engines/Spawners/Spawner.cs | 48 +-- .../Engines/Spawners/SpawnerControllerGump.cs | 25 +- .../UOContent/Engines/Spawners/SpawnerGump.cs | 51 ++- ...rver.Engines.Spawners.BaseSpawner.v11.json | 108 +++++++ .../UOContent/Mobiles/AI/BaseAI/BaseAI.cs | 6 +- .../UOContent/Mobiles/AI/BaseAI/PetOrders.cs | 18 +- Projects/UOContent/Mobiles/BaseCreature.cs | 2 +- .../UOContent/Mobiles/Guards/BaseGuard.cs | 2 +- 16 files changed, 502 insertions(+), 196 deletions(-) create mode 100644 Projects/UOContent/Engines/Spawners/BaseSpawner.Migrations.cs create mode 100644 Projects/UOContent/Migrations/Server.Engines.Spawners.BaseSpawner.v11.json diff --git a/Projects/Server/Interfaces.cs b/Projects/Server/Interfaces.cs index a75437b37..95fb64d37 100644 --- a/Projects/Server/Interfaces.cs +++ b/Projects/Server/Interfaces.cs @@ -57,8 +57,8 @@ public interface ISpawner : IEntity { Guid Guid { get; } bool UnlinkOnTaming { get; } - Point3D HomeLocation { get; } - int HomeRange { get; } + int WalkingRange { get; } + Rectangle3D SpawnBounds { get; } Region Region { get; } bool ReturnOnDeactivate { get; } bool Running { get; } @@ -66,6 +66,11 @@ public interface ISpawner : IEntity void Remove(ISpawnable spawn); Point3D GetSpawnPosition(ISpawnable spawned, Map map); void Respawn(); + + /// + /// Checks if the given location is within the spawn bounds. + /// + bool IsInSpawnBounds(IPoint3D location); } public interface ISpawnable : IEntity diff --git a/Projects/UOContent/Engines/ConPVP/Gumps/AcceptTeamGump.cs b/Projects/UOContent/Engines/ConPVP/Gumps/AcceptTeamGump.cs index f8b7af812..4d9d6d7cd 100644 --- a/Projects/UOContent/Engines/ConPVP/Gumps/AcceptTeamGump.cs +++ b/Projects/UOContent/Engines/ConPVP/Gumps/AcceptTeamGump.cs @@ -1,7 +1,6 @@ using System; using System.Collections; using System.Collections.Generic; -using System.Text; using Server.Gumps; using Server.Mobiles; using Server.Network; diff --git a/Projects/UOContent/Engines/ConPVP/Gumps/ConfirmSignupGump.cs b/Projects/UOContent/Engines/ConPVP/Gumps/ConfirmSignupGump.cs index 9e6c45622..a0b7f55c1 100644 --- a/Projects/UOContent/Engines/ConPVP/Gumps/ConfirmSignupGump.cs +++ b/Projects/UOContent/Engines/ConPVP/Gumps/ConfirmSignupGump.cs @@ -1,7 +1,6 @@ using System; using System.Collections; using System.Collections.Generic; -using System.Text; using Server.Factions; using Server.Gumps; using Server.Mobiles; diff --git a/Projects/UOContent/Engines/Spawners/BaseSpawner.Migrations.cs b/Projects/UOContent/Engines/Spawners/BaseSpawner.Migrations.cs new file mode 100644 index 000000000..3c728aafc --- /dev/null +++ b/Projects/UOContent/Engines/Spawners/BaseSpawner.Migrations.cs @@ -0,0 +1,67 @@ +using System.Collections.Generic; +using Server.Items; + +namespace Server.Engines.Spawners; + +public abstract partial class BaseSpawner +{ + // Static dictionary to track pending HomeRange migrations from v10 + // Keyed by spawner instance, stores the HomeRange value to convert to SpawnBounds + private static readonly Dictionary _pendingHomeRangeMigrations = new(); + + private void MigrateFrom(V10Content content) + { + _guid = content.Guid; + _returnOnDeactivate = content.ReturnOnDeactivate; + _entries = content.Entries; + _walkingRange = content.WalkingRange; + _wayPoint = content.WayPoint; + _group = content.Group; + _minDelay = content.MinDelay; + _maxDelay = content.MaxDelay; + _team = content.Team; + _end = content.End; + + // Defer SpawnBounds calculation to AfterDeserialization when Location is available + if (content.HomeRange > 0) + { + _pendingHomeRangeMigrations[this] = content.HomeRange; + } + + _spawnLocationIsHome = false; + } + + private void Deserialize(IGenericReader reader, int version) + { + _guid = reader.ReadGuid(); + _returnOnDeactivate = reader.ReadBool(); + + var count = reader.ReadInt(); + _entries = new List(count); + + for (var i = 0; i < count; ++i) + { + var entry = new SpawnerEntry(this); + entry.Deserialize(reader); + _entries.Add(entry); + } + + _walkingRange = reader.ReadInt(); + _wayPoint = reader.ReadEntity(); + _group = reader.ReadBool(); + _minDelay = reader.ReadTimeSpan(); + _maxDelay = reader.ReadTimeSpan(); + _count = reader.ReadInt(); + _team = reader.ReadInt(); + + // Store homeRange for migration in AfterDeserialization when Location is available + var homeRange = reader.ReadInt(); + if (homeRange > 0) + { + _pendingHomeRangeMigrations[this] = homeRange; + } + + _running = reader.ReadBool(); + _end = _running ? reader.ReadDeltaTime() : Core.Now; + } +} diff --git a/Projects/UOContent/Engines/Spawners/BaseSpawner.cs b/Projects/UOContent/Engines/Spawners/BaseSpawner.cs index 258d2956b..a8eb602d5 100644 --- a/Projects/UOContent/Engines/Spawners/BaseSpawner.cs +++ b/Projects/UOContent/Engines/Spawners/BaseSpawner.cs @@ -12,7 +12,7 @@ using static Server.Attributes; namespace Server.Engines.Spawners; -[SerializationGenerator(10, false)] +[SerializationGenerator(11, false)] public abstract partial class BaseSpawner : Item, ISpawner { [SerializedIgnoreDupe] @@ -28,9 +28,6 @@ public abstract partial class BaseSpawner : Item, ISpawner [SerializableField(2, setter: "private")] private List _entries; - [InvalidateProperties] - [SerializableField(3)] - [SerializedCommandProperty(AccessLevel.Developer)] private int _walkingRange = -1; [SerializableField(4)] @@ -60,15 +57,103 @@ public abstract partial class BaseSpawner : Item, ISpawner [InvalidateProperties] [SerializableField(10)] [SerializedCommandProperty(AccessLevel.Developer)] - private int _homeRange; + private Rectangle3D _spawnBounds; + /// + /// If true, the home location of the spawn is the location where it spawned + /// If false, the home location of the spawn is the location of the spawner + /// + [InvalidateProperties] [SerializableField(12)] [SerializedCommandProperty(AccessLevel.Developer)] + private bool _spawnLocationIsHome; + + [SerializableField(13)] + [SerializedCommandProperty(AccessLevel.Developer)] private DateTime _end; private InternalTimer _timer; - public BaseSpawner() : this(1, TimeSpan.FromMinutes(5), TimeSpan.FromMinutes(10), 0, 4) + /// + /// Gets the distance from the spawner to the nearest edge of the spawn bounds. + /// Setting this creates a square spawn area centered on the spawner location. + /// + [CommandProperty(AccessLevel.Developer)] + public virtual int HomeRange + { + get + { + if (_spawnBounds == default) + { + return 0; + } + + // Distance from spawner location to nearest edge + var distToMinX = Math.Abs(Location.X - _spawnBounds.Start.X); + var distToMaxX = Math.Abs(_spawnBounds.End.X - Location.X); + var distToMinY = Math.Abs(Location.Y - _spawnBounds.Start.Y); + var distToMaxY = Math.Abs(_spawnBounds.End.Y - Location.Y); + + // Return smallest distance to any edge + return Math.Min(Math.Min(distToMinX, distToMaxX), Math.Min(distToMinY, distToMaxY)); + } + set + { + // Create square bounds centered on spawner with full Z range + _spawnBounds = new Rectangle3D( + Location.X - value, + Location.Y - value, + sbyte.MinValue, + value * 2 + 1, + value * 2 + 1, + 256 // Full Z range: -128 to 127 + ); + this.MarkDirty(); + InvalidateProperties(); + } + } + + /// + /// Returns true if SpawnBounds represents a HomeRange-style square centered on the spawner. + /// + public bool IsHomeRangeStyle + { + get + { + if (_spawnBounds == default) + { + return true; // No bounds = default HomeRange behavior + } + + // Must be square + if (_spawnBounds.Width != _spawnBounds.Height) + { + return false; + } + + // Spawner must be at center + var centerX = _spawnBounds.Start.X + _spawnBounds.Width / 2; + var centerY = _spawnBounds.Start.Y + _spawnBounds.Height / 2; + + return centerX == Location.X && centerY == Location.Y; + } + } + + /// + /// Checks if the given location is within the spawn bounds. + /// Virtual to allow RegionSpawner to override with region-based logic. + /// + public virtual bool IsInSpawnBounds(IPoint3D location) + { + if (_spawnBounds == default) + { + return true; // No bounds = always in bounds + } + + return _spawnBounds.Contains(location); + } + + public BaseSpawner() : this(1, TimeSpan.FromMinutes(5), TimeSpan.FromMinutes(10)) { } @@ -76,34 +161,22 @@ public abstract partial class BaseSpawner : Item, ISpawner 1, TimeSpan.FromMinutes(5), TimeSpan.FromMinutes(10), - 0, - 4, - spawnedName + spawnedNames: spawnedName ) { } public BaseSpawner( - int amount, int minDelay, int maxDelay, int team, int homeRange, - params ReadOnlySpan spawnedNames - ) : this( - amount, - TimeSpan.FromMinutes(minDelay), - TimeSpan.FromMinutes(maxDelay), - team, - homeRange, - spawnedNames - ) - { - } - - public BaseSpawner( - int amount, TimeSpan minDelay, TimeSpan maxDelay, int team, int homeRange, + int amount, + TimeSpan minDelay, + TimeSpan maxDelay, + int team = 0, + Rectangle3D spawnBounds = default, params ReadOnlySpan spawnedNames ) : base(0x1f13) { _guid = Guid.NewGuid(); - InitSpawn(amount, minDelay, maxDelay, team, homeRange); + InitSpawn(amount, minDelay, maxDelay, team, spawnBounds); for (var i = 0; i < spawnedNames.Length; i++) { AddEntry(spawnedNames[i], 100, amount, false); @@ -130,7 +203,28 @@ public abstract partial class BaseSpawner : Item, ISpawner json.GetProperty("walkingRange", options, out int walkingRange); _walkingRange = walkingRange; - InitSpawn(amount, minDelay, maxDelay, team, homeRange); + // Try new format first + if (json.GetProperty("spawnBounds", options, out Rectangle3D spawnBounds)) + { + _spawnBounds = spawnBounds; + } + // Fall back to homeRange with location for oldest format + else if (homeRange > 0 && json.GetProperty("location", options, out Point3D location)) + { + _spawnBounds = new Rectangle3D( + location.X - homeRange, + location.Y - homeRange, + sbyte.MinValue, + homeRange * 2 + 1, + homeRange * 2 + 1, + 256 + ); + } + + json.GetProperty("spawnLocationIsHome", options, out bool spawnLocationIsHome); + _spawnLocationIsHome = spawnLocationIsHome; + + InitSpawn(amount, minDelay, maxDelay, team, _spawnBounds); json.GetProperty("entries", options, out List entries); @@ -147,6 +241,18 @@ public abstract partial class BaseSpawner : Item, ISpawner [IgnoreDupe] public Dictionary Spawned { get; private set; } + [CommandProperty(AccessLevel.Developer)] + [SerializableProperty(3, nameof(_walkingRange))] + public int WalkingRange + { + get => _walkingRange > 0 ? _walkingRange : HomeRange; + set + { + _walkingRange = value; + InvalidateProperties(); + } + } + [SerializableProperty(8)] [CommandProperty(AccessLevel.Developer)] public int Count @@ -208,12 +314,7 @@ public abstract partial class BaseSpawner : Item, ISpawner public virtual Point3D HomeLocation => Location; public bool UnlinkOnTaming => true; - Region ISpawner.Region => Region.Find(Location, Map); - - public void UpdateEntries(List entries) - { - Entries = entries; - } + public abstract Region Region { get; } public void Remove(ISpawnable spawn) { @@ -260,9 +361,10 @@ public abstract partial class BaseSpawner : Item, ISpawner json.SetProperty("minDelay", options, MinDelay); json.SetProperty("maxDelay", options, MaxDelay); json.SetProperty("team", options, Team); - json.SetProperty("homeRange", options, HomeRange); json.SetProperty("walkingRange", options, WalkingRange); json.SetProperty("entries", options, Entries); + json.SetProperty("spawnBounds", options, SpawnBounds); + json.SetProperty("spawnLocationIsHome", options, SpawnLocationIsHome); } public abstract Point3D GetSpawnPosition(ISpawnable spawned, Map map); @@ -290,6 +392,45 @@ public abstract partial class BaseSpawner : Item, ISpawner } } + public override void OnLocationChange(Point3D oldLocation) + { + base.OnLocationChange(oldLocation); + + // Only shift bounds if they represent a HomeRange-style square + // (spawner was centered and bounds are square) + if (_spawnBounds == default) + { + return; + } + + var isSquare = _spawnBounds.Width == _spawnBounds.Height; + if (!isSquare) + { + return; + } + + // Check if spawner was at center of bounds + var centerX = _spawnBounds.Start.X + _spawnBounds.Width / 2; + var centerY = _spawnBounds.Start.Y + _spawnBounds.Height / 2; + if (centerX != oldLocation.X || centerY != oldLocation.Y) + { + return; + } + + // Shift bounds by the location delta + var deltaX = Location.X - oldLocation.X; + var deltaY = Location.Y - oldLocation.Y; + + _spawnBounds = new Rectangle3D( + _spawnBounds.Start.X + deltaX, + _spawnBounds.Start.Y + deltaY, + _spawnBounds.Start.Z, + _spawnBounds.Width, + _spawnBounds.Height, + _spawnBounds.Depth + ); + } + public SpawnerEntry AddEntry( string creaturename, int probability = 100, @@ -309,7 +450,7 @@ public abstract partial class BaseSpawner : Item, ISpawner return entry; } - public void InitSpawn(int amount, TimeSpan minDelay, TimeSpan maxDelay, int team, int homeRange) + public void InitSpawn(int amount, TimeSpan minDelay, TimeSpan maxDelay, int team = 0, Rectangle3D spawnBounds = default) { Visible = false; Movable = false; @@ -319,7 +460,16 @@ public abstract partial class BaseSpawner : Item, ISpawner _maxDelay = maxDelay; _count = amount; _team = team; - _homeRange = homeRange; + + if (spawnBounds != default) + { + _spawnBounds = spawnBounds; + } + else + { + HomeRange = 4; + } + Entries = []; Spawned = new Dictionary(); @@ -348,7 +498,10 @@ public abstract partial class BaseSpawner : Item, ISpawner list.Add(1060742); // active list.Add(1060656, _count); // amount to make: ~1_val~ - list.Add(1061169, _homeRange); // range ~1_val~ + if (SpawnBounds != default) + { + list.Add(1061169, SpawnBounds.ToString()); // range ~1_val~ + } list.Add(1050039, $"{"walking range:"}\t{_walkingRange}"); // ~1_NUMBER~ ~2_ITEMNAME~ list.Add(1053099, $"{"group:"}\t{_group}"); // ~1_oretype~: ~2_armortype~ list.Add(1060847, $"{"team:"}\t{_team}"); // ~1_val~ ~2_val~ @@ -680,16 +833,14 @@ public abstract partial class BaseSpawner : Item, ISpawner Spawned.Add(m, entry); entry.AddToSpawned(m); - var loc = m is BaseVendor ? Location : GetSpawnPosition(m, map); + var spawnLocation = m is BaseVendor ? Location : GetSpawnPosition(m, map); - m.OnBeforeSpawn(loc, map); - m.MoveToWorld(loc, map); + m.OnBeforeSpawn(spawnLocation, map); + m.MoveToWorld(spawnLocation, map); if (m is BaseCreature c) { - var walkrange = GetWalkingRange(); - - c.RangeHome = walkrange >= 0 ? walkrange : _homeRange; + c.RangeHome = WalkingRange; c.CurrentWayPoint = WayPoint; if (_team > 0) @@ -697,8 +848,18 @@ public abstract partial class BaseSpawner : Item, ISpawner c.Team = _team; } - c.Home = Location; - c.HomeMap = Map; + // If true, the home location of the mob is the location where it spawned + // If false, the home location of the mob is the location of the spawner + if (_spawnLocationIsHome) + { + c.Home = spawnLocation; + c.HomeMap = map; + } + else + { + c.Home = Location; + c.HomeMap = Map; + } } m.Spawner = this; @@ -736,8 +897,6 @@ public abstract partial class BaseSpawner : Item, ISpawner return true; } - public virtual int GetWalkingRange() => _walkingRange; - public virtual Map GetSpawnMap() => Map; public void DoTimer() @@ -884,37 +1043,22 @@ public abstract partial class BaseSpawner : Item, ISpawner RemoveSpawns(); } - private void Deserialize(IGenericReader reader, int version) - { - _guid = reader.ReadGuid(); - _returnOnDeactivate = reader.ReadBool(); - - var count = reader.ReadInt(); - _entries = new List(count); - - for (var i = 0; i < count; ++i) - { - var entry = new SpawnerEntry(this); - entry.Deserialize(reader); - _entries.Add(entry); - } - - _walkingRange = reader.ReadInt(); - _wayPoint = reader.ReadEntity(); - _group = reader.ReadBool(); - _minDelay = reader.ReadTimeSpan(); - _maxDelay = reader.ReadTimeSpan(); - _count = reader.ReadInt(); - _team = reader.ReadInt(); - _homeRange = reader.ReadInt(); - _running = reader.ReadBool(); - - _end = _running ? reader.ReadDeltaTime() : Core.Now; - } - [AfterDeserialization] private void AfterDeserialization() { + // Handle v10 migration - convert HomeRange to SpawnBounds now that Location is available + if (_pendingHomeRangeMigrations.Remove(this, out var homeRange)) + { + _spawnBounds = new Rectangle3D( + Location.X - homeRange, + Location.Y - homeRange, + sbyte.MinValue, + homeRange * 2 + 1, + homeRange * 2 + 1, + 256 + ); + } + Spawned = new Dictionary(); foreach (var entry in Entries) diff --git a/Projects/UOContent/Engines/Spawners/Commands/ImportSpawnersCommand.cs b/Projects/UOContent/Engines/Spawners/Commands/ImportSpawnersCommand.cs index cba03feaf..8b89298c4 100644 --- a/Projects/UOContent/Engines/Spawners/Commands/ImportSpawnersCommand.cs +++ b/Projects/UOContent/Engines/Spawners/Commands/ImportSpawnersCommand.cs @@ -158,7 +158,7 @@ public static class ImportSpawnersCommand Point3D location = Point3D.Parse(Utility.GetText(node["location"], "Error")); Map map = Map.Parse(Utility.GetText(node["map"], "Error")); - Spawner spawner = new Spawner(count, minDelay, maxDelay, team, homeRange, creatureNames.ToArray()); + Spawner spawner = new Spawner(count, minDelay, maxDelay, team, spawnedNames: creatureNames.ToArray()); if (walkingRange >= 0) { spawner.WalkingRange = walkingRange; @@ -166,6 +166,7 @@ public static class ImportSpawnersCommand spawner.Name = name; spawner.MoveToWorld(location, map); + spawner.HomeRange = homeRange; if (spawner.Map == Map.Internal) { spawner.Delete(); @@ -347,7 +348,7 @@ public static class ImportSpawnersCommand var maxDelay = GetTimeSpan(maxTimeOverride, parts[12]); var homeRange = int.Parse(parts[14]); - var spawner = new Spawner(totalCount, minDelay, maxDelay, 0, homeRange) + var spawner = new Spawner(totalCount, minDelay, maxDelay) { WalkingRange = int.Parse(parts[13]), Name = $"Spawner ({spawnerIdOverride ?? parts[15]})" @@ -362,6 +363,7 @@ public static class ImportSpawnersCommand new Point3D(int.Parse(parts[7]), int.Parse(parts[8]), int.Parse(parts[9])), map ); + spawner.HomeRange = homeRange; spawner.Respawn(); allSpawners.Add(spawner.Guid, spawner); diff --git a/Projects/UOContent/Engines/Spawners/ProximitySpawner.cs b/Projects/UOContent/Engines/Spawners/ProximitySpawner.cs index ad03c2ad8..e7a13ccca 100644 --- a/Projects/UOContent/Engines/Spawners/ProximitySpawner.cs +++ b/Projects/UOContent/Engines/Spawners/ProximitySpawner.cs @@ -46,36 +46,18 @@ public partial class ProximitySpawner : Spawner { } - [Constructible(AccessLevel.Developer)] - public ProximitySpawner(int amount, int minDelay, int maxDelay, int team, int homeRange, string spawnName) - : base(amount, minDelay, maxDelay, team, homeRange, spawnName) - { - } - [Constructible(AccessLevel.Developer)] public ProximitySpawner( - int amount, int minDelay, int maxDelay, int team, int homeRange, int triggerRange, - string spawnMessage, bool instantFlag, string spawnName - ) : base(amount, minDelay, maxDelay, team, homeRange, spawnName) - { - TriggerRange = triggerRange; - SpawnMessage = TextDefinition.Parse(spawnMessage); - InstantFlag = instantFlag; - } - - [Constructible(AccessLevel.Developer)] - public ProximitySpawner( - int amount, TimeSpan minDelay, TimeSpan maxDelay, int team, int homeRange, + int amount, + TimeSpan minDelay, + TimeSpan maxDelay, + int team = 0, + Rectangle3D spawnBounds = default, + int triggerRange = 0, + TextDefinition spawnMessage = null, + bool instantFlag = false, params ReadOnlySpan spawnedNames - ) : base(amount, minDelay, maxDelay, team, homeRange, spawnedNames) - { - } - - [Constructible(AccessLevel.Developer)] - public ProximitySpawner( - int amount, TimeSpan minDelay, TimeSpan maxDelay, int team, int homeRange, int triggerRange, - TextDefinition spawnMessage, bool instantFlag, params ReadOnlySpan spawnedNames - ) : base(amount, minDelay, maxDelay, team, homeRange, spawnedNames) + ) : base(amount, minDelay, maxDelay, team, spawnBounds, spawnedNames) { TriggerRange = triggerRange; SpawnMessage = spawnMessage; diff --git a/Projects/UOContent/Engines/Spawners/RegionSpawner.cs b/Projects/UOContent/Engines/Spawners/RegionSpawner.cs index f4f889d34..6d9ceacb8 100644 --- a/Projects/UOContent/Engines/Spawners/RegionSpawner.cs +++ b/Projects/UOContent/Engines/Spawners/RegionSpawner.cs @@ -29,6 +29,8 @@ public partial class RegionSpawner : Spawner private BaseRegion _spawnRegion; + public override Region Region => _spawnRegion; + [Constructible(AccessLevel.Developer)] public RegionSpawner() { @@ -41,24 +43,12 @@ public partial class RegionSpawner : Spawner [Constructible(AccessLevel.Developer)] public RegionSpawner( - int amount, int minDelay, int maxDelay, int team, int homeRange, + int amount, + TimeSpan minDelay, + TimeSpan maxDelay, + int team = 0, params ReadOnlySpan spawnedNames - ) : this( - amount, - TimeSpan.FromMinutes(minDelay), - TimeSpan.FromMinutes(maxDelay), - team, - homeRange, - spawnedNames - ) - { - } - - [Constructible(AccessLevel.Developer)] - public RegionSpawner( - int amount, TimeSpan minDelay, TimeSpan maxDelay, int team, int homeRange, - params ReadOnlySpan spawnedNames - ) : base(amount, minDelay, maxDelay, team, homeRange, spawnedNames) + ) : base(amount, minDelay, maxDelay, team, spawnedNames: spawnedNames) { } @@ -175,7 +165,7 @@ public partial class RegionSpawner : Spawner } } - return HomeLocation; + return Location; } [AfterDeserialization(false)] diff --git a/Projects/UOContent/Engines/Spawners/Spawner.cs b/Projects/UOContent/Engines/Spawners/Spawner.cs index 49a3539ae..146639d0c 100644 --- a/Projects/UOContent/Engines/Spawners/Spawner.cs +++ b/Projects/UOContent/Engines/Spawners/Spawner.cs @@ -20,24 +20,13 @@ public partial class Spawner : BaseSpawner [Constructible(AccessLevel.Developer)] public Spawner( - int amount, int minDelay, int maxDelay, int team, int homeRange, + int amount, + TimeSpan minDelay, + TimeSpan maxDelay, + int team = 0, + Rectangle3D spawnBounds = default, params ReadOnlySpan spawnedNames - ) : this( - amount, - TimeSpan.FromMinutes(minDelay), - TimeSpan.FromMinutes(maxDelay), - team, - homeRange, - spawnedNames - ) - { - } - - [Constructible(AccessLevel.Developer)] - public Spawner( - int amount, TimeSpan minDelay, TimeSpan maxDelay, int team, int homeRange, - params ReadOnlySpan spawnedNames - ) : base(amount, minDelay, maxDelay, team, homeRange, spawnedNames) + ) : base(amount, minDelay, maxDelay, team, spawnBounds, spawnedNames) { } @@ -85,6 +74,8 @@ public partial class Spawner : BaseSpawner } */ + public override Region Region => Region.Find(Location, Map); + public override Point3D GetSpawnPosition(ISpawnable spawned, Map map) { if (map == null || map == Map.Internal) @@ -105,12 +96,29 @@ public partial class Spawner : BaseSpawner waterOnlyMob = false; } + var bounds = SpawnBounds; + var hasBounds = bounds != default; + // Try 10 times to find a valid location. for (var i = 0; i < 10; i++) { - var x = Location.X + (Utility.Random(HomeRange * 2 + 1) - HomeRange); - var y = Location.Y + (Utility.Random(HomeRange * 2 + 1) - HomeRange); + int x, y; + if (hasBounds) + { + // Use SpawnBounds for X/Y selection + x = Utility.RandomMinMax(bounds.Start.X, bounds.End.X - 1); + y = Utility.RandomMinMax(bounds.Start.Y, bounds.End.Y - 1); + } + else + { + // No bounds set - spawn at spawner location + x = Location.X; + y = Location.Y; + } + + // Note: Z-level logic uses spawner Z and map average Z. + // Multi-story building support (using bounds Z range) is planned for a future PR. var mapZ = map.GetAverageZ(x, y); if (waterMob) @@ -140,6 +148,6 @@ public partial class Spawner : BaseSpawner } } - return HomeLocation; + return Location; } } diff --git a/Projects/UOContent/Engines/Spawners/SpawnerControllerGump.cs b/Projects/UOContent/Engines/Spawners/SpawnerControllerGump.cs index 8440c5a65..f2d98ecdf 100644 --- a/Projects/UOContent/Engines/Spawners/SpawnerControllerGump.cs +++ b/Projects/UOContent/Engines/Spawners/SpawnerControllerGump.cs @@ -275,15 +275,23 @@ public class SpawnerControllerGump : GumpGrid //entry var entry = i * EntryCount; - AddImageTiled(item.Cols[2].X - 2, list.Items[i].Y + 18, item.Cols[2].Width, 1, 9357); - AddTextEntry(item.Cols[2].X, list.Items[i].Y + 2, list.Header.Cols[2].Width, 80, (int)GridHues.White, entry, spawner.Name, 20); + AddImageTiled(item.Cols[2].X - 2, list.Items[i].Y + 18, item.Cols[2].Width - 8, 1, 9357); + AddTextEntry(item.Cols[2].X, list.Items[i].Y + 2, list.Header.Cols[2].Width - 8, 80, (int)GridHues.White, entry, spawner.Name, 20); AddButton(item.Cols[2].X, list.Items[i].Y + vCenter + 9, 5837, 5838, GetButtonID(6, item.Index)); AddImageTiled(item.Cols[6].X + 8, list.Items[i].Y + 29, item.Cols[6].Width / 2, 1, 9357); AddTextEntry(item.Cols[6].X + 12, list.Items[i].Y + 13, list.Header.Cols[6].Width, 80, (int)GridHues.White, entry + 1, spawner.WalkingRange.ToString(), 2); - AddImageTiled(item.Cols[7].X + 8, list.Items[i].Y + 29, item.Cols[7].Width / 2, 1, 9357); - AddTextEntry(item.Cols[7].X + 12, list.Items[i].Y + 13, list.Header.Cols[7].Width, 80, (int)GridHues.White, entry + 2, spawner.HomeRange.ToString(), 2); + // Show HomeRange if bounds are HomeRange-style, otherwise show "Custom" + if (spawner.IsHomeRangeStyle) + { + AddImageTiled(item.Cols[7].X + 8, list.Items[i].Y + 29, item.Cols[7].Width / 2, 1, 9357); + AddTextEntry(item.Cols[7].X + 12, list.Items[i].Y + 13, list.Header.Cols[7].Width, 80, (int)GridHues.White, entry + 2, spawner.HomeRange.ToString(), 2); + } + else + { + AddLabelHtml(item.Cols[7].X - 6, list.Items[i].Y + 13, list.Header.Cols[7].Width, 30, "Custom", GridColors.Yellow); + } AddImageTiled(item.Cols[8].X + 6, list.Items[i].Y + 29, item.Cols[8].Width - 20, 1, 9357); AddTextEntry(item.Cols[8].X + 8, list.Items[i].Y + 13, list.Header.Cols[8].Width, 80, (int)GridHues.White, entry + 3, spawner.MinDelay.ToString(), 8); @@ -362,7 +370,8 @@ public class SpawnerControllerGump : GumpGrid target.MinDelay = spawner.MinDelay; target.MaxDelay = spawner.MaxDelay; target.WalkingRange = spawner.WalkingRange; - target.HomeRange = spawner.HomeRange; + target.SpawnBounds = spawner.SpawnBounds; + target.SpawnLocationIsHome = spawner.SpawnLocationIsHome; target.Group = spawner.Group; target.Count = spawner.Count; } @@ -510,9 +519,11 @@ public class SpawnerControllerGump : GumpGrid spawner.WalkingRange = walkRange; } - if (int.TryParse(info.GetTextEntry(indexEntry + 2), out var homeHange)) + // Only update HomeRange if text entry exists (HomeRange-style bounds) + // Custom bounds show a label instead, so this will be null/empty and skip + if (int.TryParse(info.GetTextEntry(indexEntry + 2), out var homeRange)) { - spawner.HomeRange = homeHange; + spawner.HomeRange = homeRange; } if (TimeSpan.TryParse(info.GetTextEntry(indexEntry + 3), out var minDelay)) diff --git a/Projects/UOContent/Engines/Spawners/SpawnerGump.cs b/Projects/UOContent/Engines/Spawners/SpawnerGump.cs index a589c6f76..22357221f 100644 --- a/Projects/UOContent/Engines/Spawners/SpawnerGump.cs +++ b/Projects/UOContent/Engines/Spawners/SpawnerGump.cs @@ -1,3 +1,4 @@ +using System; using Server.Collections; using Server.Gumps; using Server.Network; @@ -157,18 +158,16 @@ public class SpawnerGump : Gump } var totalSpawned = 0; - var totalSpawns = 0; var totalWeight = 0; foreach (SpawnerEntry spawnerEntry in _spawner.Entries) { - totalSpawns += spawnerEntry.SpawnedMaxCount; totalSpawned += spawner.CountSpawns(spawnerEntry); totalWeight += spawnerEntry.SpawnedProbability; } - AddHtml(270, 308 + offset, 35, 20, Html.Center($"{totalSpawns}", 0xF4F4F4)); - AddHtml(308, 308 + offset, 35, 20, Html.Center($"{totalWeight}",0xF4F4F4)); + AddHtml(232, 308 + offset, 35, 20, Html.Center($"{totalSpawned}", 0xF4F4F4)); + AddHtml(270, 308 + offset, 35, 20, Html.Center($"{totalWeight}", 0xF4F4F4)); AddHtml(5, 1, 161, 20, $"{spawner.Name} ({totalSpawned}/{spawner.Count})"); @@ -235,18 +234,18 @@ public class SpawnerGump : Gump var index = i * 5; var entryindex = _page * 13 + i; - var cte = info.GetTextEntry(index); - var mte = info.GetTextEntry(index + 1); - var poste = info.GetTextEntry(index + 2); - var parmte = info.GetTextEntry(index + 3); - var propte = info.GetTextEntry(index + 4); + var typeEntry = info.GetTextEntry(index); + var maxSpawns = info.GetTextEntry(index + 1); + var probability = info.GetTextEntry(index + 2); + var customParams = info.GetTextEntry(index + 3); + var customPrompts = info.GetTextEntry(index + 4); - if (cte == null) + if (typeEntry == null) { continue; } - var str = cte.Trim().ToLower(); + var str = typeEntry.Trim().ToLower(); if (str.Length > 0) { @@ -265,42 +264,32 @@ public class SpawnerGump : Gump entry = spawner.Entries[entryindex]; entry.SpawnedName = str; - if (mte != null) + if (maxSpawns != null) { - entry.SpawnedMaxCount = Utility.ToInt32(mte.Trim()); + entry.SpawnedMaxCount = Utility.ToInt32(maxSpawns.AsSpan().Trim()); } - if (poste != null) + if (probability != null) { - entry.SpawnedProbability = Utility.ToInt32(poste.Trim()); + entry.SpawnedProbability = Utility.ToInt32(probability.AsSpan().Trim()); } } else { - var maxcount = 1; - var probcount = 100; - - if (mte != null) - { - maxcount = Utility.ToInt32(mte.Trim()); - } - - if (poste != null) - { - probcount = Utility.ToInt32(poste.Trim()); - } + var maxcount = maxSpawns != null ? Utility.ToInt32(maxSpawns.AsSpan().Trim()) : 1; + var probcount = probability != null ? Utility.ToInt32(probability.AsSpan().Trim()) : 100; entry = spawner.AddEntry(str, probcount, maxcount); } - if (parmte != null) + if (customParams != null) { - entry.Parameters = parmte.Trim(); + entry.Parameters = customParams.Trim(); } - if (propte != null) + if (customPrompts != null) { - entry.Properties = propte.Trim(); + entry.Properties = customPrompts.Trim(); } } else if (entryindex < ocount && spawner.Entries[entryindex] != null) diff --git a/Projects/UOContent/Migrations/Server.Engines.Spawners.BaseSpawner.v11.json b/Projects/UOContent/Migrations/Server.Engines.Spawners.BaseSpawner.v11.json new file mode 100644 index 000000000..b259d381f --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Engines.Spawners.BaseSpawner.v11.json @@ -0,0 +1,108 @@ +{ + "version": 11, + "type": "Server.Engines.Spawners.BaseSpawner", + "properties": [ + { + "name": "Guid", + "type": "System.Guid", + "rule": "PrimitiveTypeMigrationRule" + }, + { + "name": "ReturnOnDeactivate", + "type": "bool", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "Entries", + "type": "System.Collections.Generic.List\u003CServer.Engines.Spawners.SpawnerEntry\u003E", + "rule": "ListMigrationRule", + "ruleArguments": [ + "Server.Engines.Spawners.SpawnerEntry", + "RawSerializableMigrationRule", + "DeserializationRequiresParent" + ] + }, + { + "name": "WalkingRange", + "type": "int", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "WayPoint", + "type": "Server.Items.WayPoint", + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "Group", + "type": "bool", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "MinDelay", + "type": "System.TimeSpan", + "rule": "PrimitiveTypeMigrationRule" + }, + { + "name": "MaxDelay", + "type": "System.TimeSpan", + "rule": "PrimitiveTypeMigrationRule" + }, + { + "name": "Count", + "type": "int", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "Team", + "type": "int", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "SpawnBounds", + "type": "Server.Rectangle3D", + "rule": "PrimitiveUOTypeMigrationRule", + "ruleArguments": [ + "Rect3D" + ] + }, + { + "name": "Running", + "type": "bool", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "SpawnLocationIsHome", + "type": "bool", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "End", + "type": "System.DateTime", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/BaseAI.cs b/Projects/UOContent/Mobiles/AI/BaseAI/BaseAI.cs index 1797cfca6..0820c2e46 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/BaseAI.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/BaseAI.cs @@ -879,15 +879,15 @@ public abstract partial class BaseAI _timer.Stop(); } - if (ShouldReturnToHome(Mobile.Spawner as Spawner)) + if (ShouldReturnToHome(Mobile.Spawner)) { Timer.StartTimer(ReturnToHome); } } - private bool ShouldReturnToHome(Spawner spawner) => + private bool ShouldReturnToHome(ISpawner spawner) => spawner?.ReturnOnDeactivate == true && !Mobile.Controlled && - (spawner.HomeLocation == Point3D.Zero || !Mobile.InRange(spawner.HomeLocation, spawner.HomeRange)); + !spawner.IsInSpawnBounds(Mobile.Location); private void ReturnToHome() { diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs b/Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs index 28e3634db..807fba3ca 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs @@ -13,6 +13,8 @@ * along with this program. If not, see . * ************************************************************************/ +using Server.Engines.Spawners; + namespace Server.Mobiles; public abstract partial class BaseAI @@ -326,7 +328,7 @@ public abstract partial class BaseAI return true; } - private bool IsInvalidControlTarget(Mobile target) => target?.Deleted != false || target.Map != Mobile.Map + private bool IsInvalidControlTarget(Mobile target) => target?.Deleted != false || target.Map != Mobile.Map || !target.Alive || target.IsDeadBondedPet; private void HandleInvalidControlTarget() @@ -344,7 +346,7 @@ public abstract partial class BaseAI private void FindCombatant() { var controlMaster = Mobile.ControlMaster; - + foreach (var aggr in Mobile.GetMobilesInRange(Mobile.RangePerception)) { if (!Mobile.CanSee(aggr) || aggr.IsDeadBondedPet || !aggr.Alive) @@ -354,7 +356,7 @@ public abstract partial class BaseAI bool isAttackingPet = aggr.Combatant == Mobile; bool isAttackingMaster = controlMaster != null && aggr.Combatant == controlMaster; - + if (isAttackingPet || isAttackingMaster) { if (Mobile.InLOS(aggr)) @@ -377,12 +379,12 @@ public abstract partial class BaseAI for (var i = 0; i < controlMaster.Aggressors.Count; i++) { var aggressor = controlMaster.Aggressors[i].Attacker; - + if (aggressor?.Deleted != false || !aggressor.Alive || aggressor.IsDeadBondedPet) { continue; } - + if (Mobile.InRange(aggressor, Mobile.RangePerception) && Mobile.CanSee(aggressor) && Mobile.InLOS(aggressor)) { Mobile.ControlTarget = aggressor; @@ -404,10 +406,10 @@ public abstract partial class BaseAI var spawner = Mobile.Spawner; - if (spawner != null && spawner.HomeLocation != Point3D.Zero) + if (spawner != null) { - Mobile.Home = spawner.HomeLocation; - Mobile.RangeHome = spawner.HomeRange; + Mobile.Home = spawner.GetSpawnPosition(Mobile, spawner.Map); + Mobile.RangeHome = spawner.WalkingRange; } else { diff --git a/Projects/UOContent/Mobiles/BaseCreature.cs b/Projects/UOContent/Mobiles/BaseCreature.cs index eb70e8454..b32ea2aff 100644 --- a/Projects/UOContent/Mobiles/BaseCreature.cs +++ b/Projects/UOContent/Mobiles/BaseCreature.cs @@ -3610,7 +3610,7 @@ namespace Server.Mobiles if (ReturnsToHome && IsSpawnerBound() && !InRange(Home, RangeHome)) { - if (Combatant == null && Warmode == false && Utility.RandomDouble() < .10) /* some throttling */ + if (Combatant == null && !Warmode && Utility.RandomDouble() < .10) /* some throttling */ { m_FailedReturnHome = !Move(GetDirectionTo(Home.X, Home.Y)) ? m_FailedReturnHome + 1 : 0; diff --git a/Projects/UOContent/Mobiles/Guards/BaseGuard.cs b/Projects/UOContent/Mobiles/Guards/BaseGuard.cs index 6dd80edb1..f180f9935 100644 --- a/Projects/UOContent/Mobiles/Guards/BaseGuard.cs +++ b/Projects/UOContent/Mobiles/Guards/BaseGuard.cs @@ -203,7 +203,7 @@ public class GuardIdleTimer : Timer } else { - BaseGuard.TeleportTo(_owner, _owner.Spawner.HomeLocation); + BaseGuard.TeleportTo(_owner, _owner.Spawner.Location); } Stop();