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();