feat: Add spawn position caching and spiral scan optimization (#2295)
### Summary Adds spawn position caching and optimization for constrained spawners (e.g., those near houses, water, or blocked terrain). ### Key features: - Sector-based bitmap cache (32 bytes per 16x16 sector) stores valid spawn positions - Spiral scan progressively discovers positions from spawner center outward - Automatic mode detects constrained spawners after 5+ non-transient failures - Prevents mob spawning inside private houses (allows public AoS buildings) - Deduplicates sector lookups for multi-bounds spawners (RegionSpawner) - Cache invalidation on house placement/demolition - Moves SpawnBounds to Spawner ### New spawner properties: - SpawnPositionMode: Automatic (default), Enabled, Disabled, Abandoned - MaxSpawnAttempts: Configurable attempts before optimization engages (default: 5)
This commit is contained in:
parent
6d51b33cf8
commit
3e8d548f38
23 changed files with 1938 additions and 153 deletions
|
|
@ -39,6 +39,20 @@ public enum MapRules
|
|||
FeluccaRules = None
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Indicates why a spawn position check failed. Used by spawners to determine
|
||||
/// if optimization (caching) should be enabled.
|
||||
/// </summary>
|
||||
[Flags]
|
||||
public enum SpawnFailureReason
|
||||
{
|
||||
None = 0,
|
||||
InvalidMap = 1 << 0, // Internal map or out of bounds
|
||||
RegionBlocked = 1 << 1, // Region doesn't allow spawning
|
||||
TransientBlocker = 1 << 2, // Mobile or movable item blocking
|
||||
NonTransientBlocker = 1 << 3 // Static, multi, impassable land, or non-movable item
|
||||
}
|
||||
|
||||
public sealed partial class Map : IComparable<Map>, ISpanFormattable, ISpanParsable<Map>
|
||||
{
|
||||
public const int SectorSize = 16;
|
||||
|
|
@ -1103,17 +1117,38 @@ public sealed partial class Map : IComparable<Map>, ISpanFormattable, ISpanParsa
|
|||
/// <param name="cantWalk">Whether the spawned entity cannot walk (water-only)</param>
|
||||
/// <param name="spawnZ">The valid spawn Z if found</param>
|
||||
/// <returns>True if a valid spawn Z was found within the range</returns>
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public bool CanSpawnMobile(int x, int y, int minZ, int maxZ, bool canSwim, bool cantWalk, out int spawnZ)
|
||||
=> CanSpawnMobile(x, y, minZ, maxZ, canSwim, cantWalk, out spawnZ, out _);
|
||||
|
||||
/// <summary>
|
||||
/// Finds a valid spawn Z within the specified range by checking land, static, multi tiles, and world items.
|
||||
/// Prefers the lowest valid surface (ground/floor over tables/platforms).
|
||||
/// Also reports the reason for failure if no valid position is found.
|
||||
/// </summary>
|
||||
/// <param name="x">X coordinate</param>
|
||||
/// <param name="y">Y coordinate</param>
|
||||
/// <param name="minZ">Minimum Z (inclusive)</param>
|
||||
/// <param name="maxZ">Maximum Z (inclusive)</param>
|
||||
/// <param name="canSwim">Whether the spawned entity can swim (water surfaces valid)</param>
|
||||
/// <param name="cantWalk">Whether the spawned entity cannot walk (water-only)</param>
|
||||
/// <param name="spawnZ">The valid spawn Z if found</param>
|
||||
/// <param name="failureReason">Indicates why spawn failed (if it did)</param>
|
||||
/// <returns>True if a valid spawn Z was found within the range</returns>
|
||||
public bool CanSpawnMobile(int x, int y, int minZ, int maxZ, bool canSwim, bool cantWalk, out int spawnZ, out SpawnFailureReason failureReason)
|
||||
{
|
||||
spawnZ = 0;
|
||||
failureReason = SpawnFailureReason.None;
|
||||
|
||||
if (this == Internal || x < 0 || y < 0 || x >= Width || y >= Height)
|
||||
{
|
||||
failureReason = SpawnFailureReason.InvalidMap;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!Region.Find(new Point3D(x, y, minZ), this).AllowSpawn())
|
||||
{
|
||||
failureReason = SpawnFailureReason.RegionBlocked;
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
@ -1125,6 +1160,10 @@ public sealed partial class Map : IComparable<Map>, ISpanFormattable, ISpanParsa
|
|||
var openSlots = ulong.MaxValue;
|
||||
ulong surfaces = 0;
|
||||
|
||||
// Track what types of blockers we encounter (for failure reason)
|
||||
var hasNonTransientBlocker = false;
|
||||
var hasTransientBlocker = false;
|
||||
|
||||
// 1. Land tile
|
||||
var landTile = Tiles.GetLandTile(x, y);
|
||||
GetAverageZ(x, y, out var lowZ, out var avgZ, out _);
|
||||
|
|
@ -1140,6 +1179,7 @@ public sealed partial class Map : IComparable<Map>, ISpanFormattable, ISpanParsa
|
|||
{
|
||||
// Impassable land blocks z in range (lowZ - 16, avgZ)
|
||||
openSlots &= ~CreateBlockerMask(lowZ - 16, avgZ, minZ);
|
||||
hasNonTransientBlocker = true;
|
||||
}
|
||||
|
||||
// Surface: water for swimmers, passable land for walkers
|
||||
|
|
@ -1149,7 +1189,7 @@ public sealed partial class Map : IComparable<Map>, ISpanFormattable, ISpanParsa
|
|||
}
|
||||
}
|
||||
|
||||
// 2. Static and multi tiles
|
||||
// 2. Static and multi tiles (always non-transient)
|
||||
foreach (var tile in Tiles.GetStaticAndMultiTiles(x, y))
|
||||
{
|
||||
var id = TileData.ItemTable[tile.ID & TileData.MaxItemValue];
|
||||
|
|
@ -1163,6 +1203,7 @@ public sealed partial class Map : IComparable<Map>, ISpanFormattable, ISpanParsa
|
|||
if ((isSurface || isImpassable) && !(canSwim && isWet))
|
||||
{
|
||||
openSlots &= ~CreateBlockerMask(tile.Z - 16, tileTop, minZ);
|
||||
hasNonTransientBlocker = true;
|
||||
}
|
||||
|
||||
// Surface candidate
|
||||
|
|
@ -1193,6 +1234,16 @@ public sealed partial class Map : IComparable<Map>, ISpanFormattable, ISpanParsa
|
|||
if ((isSurface || isImpassable) && !(canSwim && isWet))
|
||||
{
|
||||
openSlots &= ~CreateBlockerMask(item.Z - 16, itemTop, minZ);
|
||||
|
||||
// Movable items are transient, non-movable are permanent
|
||||
if (item.Movable || item.CanDecay())
|
||||
{
|
||||
hasTransientBlocker = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
hasNonTransientBlocker = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Surface candidate (non-movable only)
|
||||
|
|
@ -1203,7 +1254,7 @@ public sealed partial class Map : IComparable<Map>, ISpanFormattable, ISpanParsa
|
|||
}
|
||||
}
|
||||
|
||||
// 4. Mobiles (blockers only)
|
||||
// 4. Mobiles (always transient blockers)
|
||||
foreach (var m in sector.Mobiles)
|
||||
{
|
||||
if (m.Location.m_X == x && m.Location.m_Y == y &&
|
||||
|
|
@ -1211,6 +1262,7 @@ public sealed partial class Map : IComparable<Map>, ISpanFormattable, ISpanParsa
|
|||
{
|
||||
// Mobiles block z in range (m.Z - 16, m.Z + 16)
|
||||
openSlots &= ~CreateBlockerMask(m.Z - 16, m.Z + 16, minZ);
|
||||
hasTransientBlocker = true;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1218,12 +1270,25 @@ public sealed partial class Map : IComparable<Map>, ISpanFormattable, ISpanParsa
|
|||
var validSurfaces = surfaces & openSlots;
|
||||
if (validSurfaces == 0)
|
||||
{
|
||||
// Determine failure reason based on what blockers we encountered
|
||||
// If no surfaces existed at all, that's also a non-transient issue (map geometry)
|
||||
if (hasNonTransientBlocker || surfaces == 0)
|
||||
{
|
||||
failureReason |= SpawnFailureReason.NonTransientBlocker;
|
||||
}
|
||||
|
||||
if (hasTransientBlocker)
|
||||
{
|
||||
failureReason |= SpawnFailureReason.TransientBlocker;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// TrailingZeroCount gives the position of the lowest set bit
|
||||
var lowestBit = BitOperations.TrailingZeroCount(validSurfaces);
|
||||
spawnZ = minZ + lowestBit;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue