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:
Kamron Batman 2025-12-28 02:40:21 -08:00 • committed by GitHub
parent 6d51b33cf8
commit 3e8d548f38
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 1938 additions and 153 deletions

View file

@ -1,6 +1,6 @@
/*************************************************************************
* ModernUO *
* Copyright 2019-2023 - ModernUO Development Team *
* Copyright 2019-2025 - ModernUO Development Team *
* Email: hi@modernuo.com *
* File: RegionSpawner.cs *
* *
@ -74,6 +74,48 @@ public partial class RegionSpawner : Spawner
}
}
// RegionSpawner does not support spiral scan (disjoint rectangles make it ineffective)
protected override bool SupportsSpiralScan => false;
protected override Rectangle3D GetBoundsForSpawnAttempt()
{
if (_spawnRegion == null || _spawnRegion.TotalWeight <= 0)
{
return default;
}
// Pick a weighted random rectangle from the region
var rand = Utility.Random(_spawnRegion.TotalWeight);
for (var j = 0; j < _spawnRegion.RectangleWeights.Length; j++)
{
var curWeight = _spawnRegion.RectangleWeights[j];
if (rand < curWeight)
{
return _spawnRegion.Rectangles[j];
}
rand -= curWeight;
}
return default;
}
protected override ReadOnlySpan<Rectangle3D> GetAllSpawnBounds() => _spawnRegion?.Rectangles;
public override Point3D GetSpawnPosition(ISpawnable spawned, Map map)
{
// Check for region/map mismatch before delegating to base
if (_spawnRegion == null || map == null || map == Map.Internal ||
map != _spawnRegion.Map || _spawnRegion.TotalWeight <= 0)
{
return Location;
}
return base.GetSpawnPosition(spawned, map);
}
public override void ToJson(DynamicJson json, JsonSerializerOptions options)
{
base.ToJson(json, options);
@ -90,55 +132,6 @@ public partial class RegionSpawner : Spawner
}
}
public override Point3D GetSpawnPosition(ISpawnable spawned, Map map)
{
if (_spawnRegion == null || map == null || map == Map.Internal || map != _spawnRegion.Map ||
_spawnRegion.TotalWeight <= 0)
{
return Location;
}
// Try 10 times to find a valid location.
for (var i = 0; i < 10; i++)
{
var rand = Utility.Random(_spawnRegion.TotalWeight);
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++)
{
var curWeight = _spawnRegion.RectangleWeights[j];
if (rand < curWeight)
{
var rect = _spawnRegion.Rectangles[j];
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;
}
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))
{
return new Point3D(x, y, spawnZ);
}
}
return Location;
}
[AfterDeserialization(false)]
private void AfterDeserialization()
{