feat: Add CanSpawnMobile overload with props Z-range support. (#2293)

### Summary

- Adds CanSpawnMobile(x, y, minZ, maxZ, canSwim, cantWalk, out spawnZ) overload for finding spawn surfaces within a Z range
- Adds CanSpawnItem(x, y, minZ, maxZ, out spawnZ) for item spawning with Surface+Impassable support (tables, furniture)
- Uses bitmask optimization inspired by Item.DropToWorld's m_OpenSlots pattern for O(1) surface/blocker checks
- HomeRange spawners now use surface detection to set proper Z bounds

### Key Changes

Map.cs:
- CanSpawnMobile with Z-range finds lowest valid surface for mobiles
- CanSpawnItem with Z-range finds lowest valid surface for items (including tables)
- CanFitItem for point-check item placement on Surface+Impassable tiles
- Bitmask approach eliminates nested loops and stackalloc arrays

Spawners:
- Simplified GetSpawnPosition using new Z-range methods
- HomeRange setter detects surface below spawner for proper Z bounds
- Consistent handling for mobiles and items

### Bug Fixes

- Water tiles (Impassable | Wet) no longer block swimming mobs
- Items can now spawn on tables/furniture (Surface+Impassable)

### Test Plan

- Run dotnet test - 631 tests pass
- Manual testing: multi-story spawning, water mobs, item spawning on tables
- Verify HomeRange spawner movement shifts bounds correctly
This commit is contained in:
Kamron Batman 2025-12-27 17:01:15 -08:00 • committed by GitHub
parent ebaf104935
commit 6d51b33cf8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 1604 additions and 214 deletions

View file

@ -19,8 +19,10 @@ public abstract partial class BaseSpawner
_group = content.Group;
_minDelay = content.MinDelay;
_maxDelay = content.MaxDelay;
_count = content.Count;
_team = content.Team;
_end = content.End;
_running = content.Running;
_end = _running ? content.End : Core.Now;
// Defer SpawnBounds calculation to AfterDeserialization when Location is available
if (content.HomeRange > 0)

View file

@ -99,14 +99,19 @@ public abstract partial class BaseSpawner : Item, ISpawner
}
set
{
// Create square bounds centered on spawner with full Z range
// Find the surface below the spawner (handles spawners placed in air)
var surfaceZ = Map != null && Map != Map.Internal
? Map.GetTopSurfaceZ(Location)
: Location.Z;
// Create square bounds centered on spawner, Z range from surface to surface + 16
_spawnBounds = new Rectangle3D(
Location.X - value,
Location.Y - value,
sbyte.MinValue,
surfaceZ,
value * 2 + 1,
value * 2 + 1,
256 // Full Z range: -128 to 127
17 // Z range: surface to surface + 16
);
this.MarkDirty();
InvalidateProperties();
@ -116,41 +121,38 @@ public abstract partial class BaseSpawner : Item, ISpawner
/// <summary>
/// Returns true if SpawnBounds represents a HomeRange-style square centered on the spawner.
/// </summary>
public bool IsHomeRangeStyle
public bool IsHomeRangeStyle => IsHomeRangeStyleAt(Location);
/// <summary>
/// Returns true if SpawnBounds represents a HomeRange-style square centered on the given location.
/// </summary>
public bool IsHomeRangeStyleAt(Point3D location)
{
get
if (_spawnBounds == default)
{
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;
return true; // No bounds = default HomeRange behavior
}
// Must be square
if (_spawnBounds.Width != _spawnBounds.Height)
{
return false;
}
// Given location 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;
}
/// <summary>
/// Checks if the given location is within the spawn bounds.
/// Virtual to allow RegionSpawner to override with region-based logic.
/// </summary>
public virtual bool IsInSpawnBounds(IPoint3D location)
public virtual bool IsInSpawnBounds(Point3D location)
{
if (_spawnBounds == default)
{
return true; // No bounds = always in bounds
}
return _spawnBounds.Contains(location);
return _spawnBounds == default || _spawnBounds.Contains(location);
}
public BaseSpawner() : this(1, TimeSpan.FromMinutes(5), TimeSpan.FromMinutes(10))
@ -208,16 +210,17 @@ public abstract partial class BaseSpawner : Item, ISpawner
{
_spawnBounds = spawnBounds;
}
// Fall back to homeRange with location for oldest format
else if (homeRange > 0 && json.GetProperty("location", options, out Point3D location))
{
// 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,
sbyte.MinValue,
location.Z,
homeRange * 2 + 1,
homeRange * 2 + 1,
256
17
);
}
@ -396,39 +399,11 @@ public abstract partial class BaseSpawner : Item, ISpawner
{
base.OnLocationChange(oldLocation);
// Only shift bounds if they represent a HomeRange-style square
// (spawner was centered and bounds are square)
if (_spawnBounds == default)
// Recalculate HomeRange-style bounds when spawner moves
if (IsHomeRangeStyleAt(oldLocation))
{
return;
HomeRange = _spawnBounds.Width / 2;
}
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(
@ -833,7 +808,9 @@ public abstract partial class BaseSpawner : Item, ISpawner
Spawned.Add(m, entry);
entry.AddToSpawned(m);
var spawnLocation = m is BaseVendor ? Location : GetSpawnPosition(m, map);
// var spawnLocation = m is BaseVendor ? Location : GetSpawnPosition(m, map);
var spawnLocation = GetSpawnPosition(m, map);
m.OnBeforeSpawn(spawnLocation, map);
m.MoveToWorld(spawnLocation, map);
@ -1049,13 +1026,14 @@ 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,
sbyte.MinValue,
surfaceZ,
homeRange * 2 + 1,
homeRange * 2 + 1,
256
17
);
}

View file

@ -98,19 +98,6 @@ public partial class RegionSpawner : Spawner
return Location;
}
bool waterMob, waterOnlyMob;
if (spawned is Mobile mob)
{
waterMob = mob.CanSwim;
waterOnlyMob = mob.CanSwim && mob.CantWalk;
}
else
{
waterMob = false;
waterOnlyMob = false;
}
// Try 10 times to find a valid location.
for (var i = 0; i < 10; i++)
{
@ -118,6 +105,8 @@ public partial class RegionSpawner : Spawner
var x = int.MinValue;
var y = int.MinValue;
var minZ = (int)sbyte.MinValue;
var maxZ = (int)sbyte.MaxValue;
for (var j = 0; j < _spawnRegion.RectangleWeights.Length; j++)
{
@ -130,38 +119,20 @@ public partial class RegionSpawner : Spawner
x = rect.Start.X + rand % rect.Width;
y = rect.Start.Y + rand / rect.Width;
// Use rectangle's Z range for multi-floor region support
minZ = rect.Start.Z;
maxZ = rect.End.Z - 1;
break;
}
rand -= curWeight;
}
var mapZ = map.GetAverageZ(x, y);
if (waterMob)
if (spawned is Mobile mob && map.CanSpawnMobile(x, y, minZ, maxZ, mob.CanSwim, mob.CantWalk, out var spawnZ)
|| spawned is Item && map.CanSpawnItem(x, y, minZ, maxZ, out spawnZ))
{
if (IsValidWater(map, x, y, Z))
{
return new Point3D(x, y, Z);
}
if (IsValidWater(map, x, y, mapZ))
{
return new Point3D(x, y, mapZ);
}
}
if (!waterOnlyMob)
{
if (map.CanSpawnMobile(x, y, Z))
{
return new Point3D(x, y, Z);
}
if (map.CanSpawnMobile(x, y, mapZ))
{
return new Point3D(x, y, mapZ);
}
return new Point3D(x, y, spawnZ);
}
}

View file

@ -34,31 +34,6 @@ public partial class Spawner : BaseSpawner
{
}
public static bool IsValidWater(Map map, int x, int y, int z)
{
if (!Region.Find(new Point3D(x, y, z), map).AllowSpawn() || !map.CanFit(x, y, z, 16, false, true, false))
{
return false;
}
var landTile = map.Tiles.GetLandTile(x, y);
if (landTile.Z == z && (TileData.LandTable[landTile.ID & TileData.MaxLandValue].Flags & TileFlag.Wet) != 0)
{
return true;
}
foreach (var staticTile in map.Tiles.GetStaticAndMultiTiles(x, y))
{
if (staticTile.Z == z && TileData.ItemTable[staticTile.ID & TileData.MaxItemValue].Wet)
{
return true;
}
}
return false;
}
/*
public override bool OnDefragSpawn(ISpawnable spawned, bool remove)
{
@ -83,68 +58,28 @@ public partial class Spawner : BaseSpawner
return Location;
}
bool waterMob, waterOnlyMob;
if (spawned is Mobile mob)
{
waterMob = mob.CanSwim;
waterOnlyMob = mob.CanSwim && mob.CantWalk;
}
else
{
waterMob = false;
waterOnlyMob = false;
}
var bounds = SpawnBounds;
var hasBounds = bounds != default;
// No bounds = HomeRange of 0, spawn at spawner location
if (bounds == default)
{
return Location;
}
// Z range from SpawnBounds (supports multi-story buildings)
var minZ = bounds.Start.Z;
var maxZ = bounds.End.Z - 1;
// Try 10 times to find a valid location.
for (var i = 0; i < 10; i++)
{
int x, y;
var x = Utility.RandomMinMax(bounds.Start.X, bounds.End.X - 1);
var y = Utility.RandomMinMax(bounds.Start.Y, bounds.End.Y - 1);
if (hasBounds)
if (spawned is Mobile mob && map.CanSpawnMobile(x, y, minZ, maxZ, mob.CanSwim, mob.CantWalk, out var spawnZ)
|| spawned is Item && map.CanSpawnItem(x, y, minZ, maxZ, out spawnZ))
{
// 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)
{
if (IsValidWater(map, x, y, Z))
{
return new Point3D(x, y, Z);
}
if (IsValidWater(map, x, y, mapZ))
{
return new Point3D(x, y, mapZ);
}
}
if (!waterOnlyMob)
{
if (map.CanSpawnMobile(x, y, Z))
{
return new Point3D(x, y, Z);
}
if (map.CanSpawnMobile(x, y, mapZ))
{
return new Point3D(x, y, mapZ);
}
return new Point3D(x, y, spawnZ);
}
}

View file

@ -86,9 +86,13 @@ public abstract partial class BaseAI
private void WalkRandomWithHome(int chanceToNotMove, int chanceToDir, int steps)
{
if (Mobile.RangeHome == 0 && Mobile.Location != Mobile.Home)
if (Mobile.RangeHome == 0)
{
DoMove(Mobile.GetDirectionTo(Mobile.Home));
if (Mobile.Location != Mobile.Home)
{
DoMove(Mobile.GetDirectionTo(Mobile.Home));
}
return;
}