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:
parent
ebaf104935
commit
6d51b33cf8
15 changed files with 1604 additions and 214 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue