376 lines
14 KiB
C#
376 lines
14 KiB
C#
/*************************************************************************
|
|
* ModernUO *
|
|
* Copyright 2019-2026 - ModernUO Development Team *
|
|
* Email: hi@modernuo.com *
|
|
* File: SectorSpawnCache.cs *
|
|
* *
|
|
* This program is free software: you can redistribute it and/or modify *
|
|
* it under the terms of the GNU General Public License as published by *
|
|
* the Free Software Foundation, either version 3 of the License, or *
|
|
* (at your option) any later version. *
|
|
* *
|
|
* You should have received a copy of the GNU General Public License *
|
|
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
|
|
*************************************************************************/
|
|
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.Runtime.CompilerServices;
|
|
using System.Runtime.InteropServices;
|
|
using Server.Collections;
|
|
using Server.Regions;
|
|
|
|
namespace Server.Engines.Spawners;
|
|
|
|
/// <summary>
|
|
/// Global manager for sector-based spawn position caching.
|
|
/// Shared across all spawners for efficient memory usage and house invalidation.
|
|
/// Uses separate caches for land and water to minimize memory usage since most
|
|
/// sectors are either all land or all water.
|
|
/// </summary>
|
|
public static class SectorSpawnCacheManager
|
|
{
|
|
private static readonly Dictionary<(Map, int, int), BitMask256> _landCaches = [];
|
|
private static readonly Dictionary<(Map, int, int), BitMask256> _waterCaches = [];
|
|
|
|
/// <summary>
|
|
/// Marks a position as valid for spawning in the global cache.
|
|
/// </summary>
|
|
/// <param name="map">The map containing the position</param>
|
|
/// <param name="pos">The valid spawn position</param>
|
|
/// <param name="isWater">True for water mob, false for land mob</param>
|
|
public static void SetValid(Map map, Point3D pos, bool isWater)
|
|
{
|
|
var sectorX = pos.X >> Map.SectorShift;
|
|
var sectorY = pos.Y >> Map.SectorShift;
|
|
var bitIndex = (pos.X & (Map.SectorSize - 1)) + ((pos.Y & (Map.SectorSize - 1)) << Map.SectorShift);
|
|
|
|
var caches = isWater ? _waterCaches : _landCaches;
|
|
ref var cache = ref CollectionsMarshal.GetValueRefOrAddDefault(caches, (map, sectorX, sectorY), out _);
|
|
cache.SetBit(bitIndex);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Attempts to get a random valid position from cached sectors within the specified bounds.
|
|
/// </summary>
|
|
/// <param name="map">The map to search</param>
|
|
/// <param name="bounds">The spawn bounds to search within</param>
|
|
/// <param name="isWater">True for water mob, false for land mob</param>
|
|
/// <param name="pos">The selected position (X, Y only - caller must verify Z)</param>
|
|
/// <returns>True if a cached position was found</returns>
|
|
public static bool TryGetRandomPosition(Map map, Rectangle3D bounds, bool isWater, out Point2D pos)
|
|
{
|
|
ReadOnlySpan<Rectangle3D> singleBounds = [bounds];
|
|
return TryGetRandomPosition(map, singleBounds, isWater, out pos, out _);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Attempts to get a random valid position from cached sectors across multiple bounds.
|
|
/// Deduplicates overlapping sectors for uniform distribution.
|
|
/// </summary>
|
|
/// <param name="map">The map to search</param>
|
|
/// <param name="allBounds">All spawn bounds to search within</param>
|
|
/// <param name="isWater">True for water mob, false for land mob</param>
|
|
/// <param name="pos">The selected position (X, Y only - caller must verify Z)</param>
|
|
/// <param name="containingBounds">The bounds rectangle containing the selected position</param>
|
|
/// <param name="maxRetries">Maximum retries if selected position is outside bounds</param>
|
|
/// <returns>True if a cached position was found</returns>
|
|
public static bool TryGetRandomPosition(
|
|
Map map,
|
|
ReadOnlySpan<Rectangle3D> allBounds,
|
|
bool isWater,
|
|
out Point2D pos,
|
|
out Rectangle3D containingBounds,
|
|
int maxRetries = 5)
|
|
{
|
|
pos = Point2D.Zero;
|
|
containingBounds = default;
|
|
|
|
if (allBounds.Length == 0)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var caches = isWater ? _waterCaches : _landCaches;
|
|
|
|
// Collect unique sectors and their counts
|
|
using var sectorList = PooledRefList<(int sx, int sy, int count)>.Create();
|
|
var totalPositions = 0;
|
|
|
|
for (var i = 0; i < allBounds.Length; i++)
|
|
{
|
|
var bounds = allBounds[i];
|
|
var startSectorX = bounds.Start.X >> Map.SectorShift;
|
|
var startSectorY = bounds.Start.Y >> Map.SectorShift;
|
|
var endSectorX = (bounds.End.X - 1) >> Map.SectorShift;
|
|
var endSectorY = (bounds.End.Y - 1) >> Map.SectorShift;
|
|
|
|
for (var sx = startSectorX; sx <= endSectorX; sx++)
|
|
{
|
|
for (var sy = startSectorY; sy <= endSectorY; sy++)
|
|
{
|
|
// Check if we've already added this sector
|
|
var alreadyAdded = false;
|
|
for (var j = 0; j < sectorList.Count; j++)
|
|
{
|
|
var (checkSX, checkSY, _) = sectorList[j];
|
|
if (checkSX == sx && checkSY == sy)
|
|
{
|
|
alreadyAdded = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (alreadyAdded)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (caches.TryGetValue((map, sx, sy), out var cache))
|
|
{
|
|
var count = cache.PopCount();
|
|
if (count > 0)
|
|
{
|
|
sectorList.Add((sx, sy, count));
|
|
totalPositions += count;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (totalPositions == 0)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
// Retry loop for when selected position is outside all bounds
|
|
for (var attempt = 0; attempt <= maxRetries; attempt++)
|
|
{
|
|
// Pick a random position
|
|
var targetIndex = Utility.Random(totalPositions);
|
|
|
|
// Find the sector containing that index
|
|
var currentIndex = 0;
|
|
for (var i = 0; i < sectorList.Count; i++)
|
|
{
|
|
var (sx, sy, sectorCount) = sectorList[i];
|
|
if (targetIndex < currentIndex + sectorCount)
|
|
{
|
|
// Target is in this sector - look up cache again
|
|
if (!caches.TryGetValue((map, sx, sy), out var cache))
|
|
{
|
|
break; // Should not happen, try again
|
|
}
|
|
|
|
var indexInSector = targetIndex - currentIndex;
|
|
var bitPosition = cache.GetNthSetBit(indexInSector);
|
|
|
|
var localX = bitPosition & (Map.SectorSize - 1);
|
|
var localY = bitPosition >> Map.SectorShift;
|
|
|
|
pos = new Point2D((sx << Map.SectorShift) + localX, (sy << Map.SectorShift) + localY);
|
|
|
|
// Find which bounds contains this position
|
|
for (var j = 0; j < allBounds.Length; j++)
|
|
{
|
|
var bounds = allBounds[j];
|
|
if (pos.X >= bounds.Start.X && pos.X < bounds.End.X &&
|
|
pos.Y >= bounds.Start.Y && pos.Y < bounds.End.Y)
|
|
{
|
|
containingBounds = bounds;
|
|
return true;
|
|
}
|
|
}
|
|
|
|
// Position outside all bounds - retry
|
|
break;
|
|
}
|
|
|
|
currentIndex += sectorCount;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Checks if a position is blocked by a private house.
|
|
/// Public AoS houses (with unlocked doors) allow spawning.
|
|
/// </summary>
|
|
/// <param name="map">The map to check</param>
|
|
/// <param name="x">X coordinate</param>
|
|
/// <param name="y">Y coordinate</param>
|
|
/// <param name="z">Z coordinate</param>
|
|
/// <returns>True if blocked by a private house</returns>
|
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
|
public static bool IsBlockedByHouse(Map map, int x, int y, int z)
|
|
{
|
|
if (Region.Find(new Point3D(x, y, z), map) is HouseRegion houseRegion)
|
|
{
|
|
var house = houseRegion.House;
|
|
// Allow spawning in public AoS houses (unlocked doors, free entry)
|
|
return !(house.IsAosRules && house.Public);
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Invalidates all cached data for sectors within the specified bounds.
|
|
/// Called when houses are placed or demolished.
|
|
/// </summary>
|
|
/// <param name="map">The map to invalidate</param>
|
|
/// <param name="bounds">The affected area</param>
|
|
public static void InvalidateSectors(Map map, Rectangle2D bounds)
|
|
{
|
|
var startSectorX = bounds.Start.X >> Map.SectorShift;
|
|
var startSectorY = bounds.Start.Y >> Map.SectorShift;
|
|
var endSectorX = bounds.End.X >> Map.SectorShift;
|
|
var endSectorY = bounds.End.Y >> Map.SectorShift;
|
|
|
|
for (var sx = startSectorX; sx <= endSectorX; sx++)
|
|
{
|
|
for (var sy = startSectorY; sy <= endSectorY; sy++)
|
|
{
|
|
var key = (map, sx, sy);
|
|
_landCaches.Remove(key);
|
|
_waterCaches.Remove(key);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Performs incremental spiral scanning to find and cache valid spawn positions.
|
|
/// </summary>
|
|
/// <param name="map">The map to scan</param>
|
|
/// <param name="center">The center point to spiral from</param>
|
|
/// <param name="bounds">The spawn bounds to stay within</param>
|
|
/// <param name="minZ">Minimum Z for spawn checks</param>
|
|
/// <param name="maxZ">Maximum Z for spawn checks</param>
|
|
/// <param name="canSwim">Whether to find water positions</param>
|
|
/// <param name="cantWalk">Whether the mob can't walk (water-only)</param>
|
|
/// <param name="currentRing">Current ring being scanned (updated on return)</param>
|
|
/// <param name="ringPosition">Position within current ring (updated on return)</param>
|
|
/// <param name="ringsPerTick">Number of rings to scan per call</param>
|
|
/// <returns>True if scan is complete (exhausted bounds)</returns>
|
|
public static bool ContinueSpiralScan(
|
|
Map map,
|
|
Point3D center,
|
|
Rectangle3D bounds,
|
|
int minZ,
|
|
int maxZ,
|
|
bool canSwim,
|
|
bool cantWalk,
|
|
ref int currentRing,
|
|
ref int ringPosition,
|
|
int ringsPerTick = 3)
|
|
{
|
|
var maxRing = Math.Max(bounds.Width, bounds.Height) / 2 + 1;
|
|
var ringsToScan = Math.Min(currentRing + ringsPerTick, maxRing);
|
|
|
|
while (currentRing < ringsToScan)
|
|
{
|
|
// Ring 0 is just the center point
|
|
if (currentRing == 0)
|
|
{
|
|
CheckAndCachePosition(map, center.X, center.Y, minZ, maxZ, bounds, canSwim, cantWalk);
|
|
}
|
|
else
|
|
{
|
|
// Ring N has 8*N positions
|
|
var positionsInRing = currentRing * 8;
|
|
for (var p = 0; p < positionsInRing; p++)
|
|
{
|
|
var (dx, dy) = GetSpiralOffset(currentRing, p);
|
|
var x = center.X + dx;
|
|
var y = center.Y + dy;
|
|
|
|
CheckAndCachePosition(map, x, y, minZ, maxZ, bounds, canSwim, cantWalk);
|
|
}
|
|
}
|
|
|
|
currentRing++;
|
|
}
|
|
|
|
ringPosition = 0;
|
|
return currentRing >= maxRing;
|
|
}
|
|
|
|
private static void CheckAndCachePosition(
|
|
Map map,
|
|
int x, int y,
|
|
int minZ, int maxZ,
|
|
Rectangle3D bounds,
|
|
bool canSwim,
|
|
bool cantWalk)
|
|
{
|
|
// Check bounds
|
|
if (x < bounds.Start.X || x >= bounds.End.X ||
|
|
y < bounds.Start.Y || y >= bounds.End.Y)
|
|
{
|
|
return;
|
|
}
|
|
|
|
// Check if position is valid for spawning
|
|
if (map.CanSpawnMobile(x, y, minZ, maxZ, canSwim, cantWalk, out var spawnZ))
|
|
{
|
|
// Skip positions inside private houses
|
|
if (IsBlockedByHouse(map, x, y, spawnZ))
|
|
{
|
|
return;
|
|
}
|
|
|
|
var pos = new Point3D(x, y, spawnZ);
|
|
var isWater = canSwim && cantWalk;
|
|
SetValid(map, pos, isWater);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the X,Y offset for a position in a spiral ring.
|
|
/// Ring 0 = center (no offset)
|
|
/// Ring 1 = 8 positions around center
|
|
/// Ring N = 8*N positions
|
|
/// </summary>
|
|
public static (int dx, int dy) GetSpiralOffset(int ring, int position)
|
|
{
|
|
// Each ring has 4 sides, each side has ring*2 positions
|
|
var sideLength = ring * 2;
|
|
var side = position / sideLength;
|
|
var sidePos = position % sideLength;
|
|
|
|
return side switch
|
|
{
|
|
0 => (-ring + sidePos, -ring), // Top edge, left to right
|
|
1 => (ring, -ring + sidePos), // Right edge, top to bottom
|
|
2 => (ring - sidePos, ring), // Bottom edge, right to left
|
|
3 => (-ring, ring - sidePos), // Left edge, bottom to top
|
|
_ => (0, 0)
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Clears all cached data. Used for testing or server restart.
|
|
/// </summary>
|
|
public static void ClearAll()
|
|
{
|
|
_landCaches.Clear();
|
|
_waterCaches.Clear();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the number of sectors currently cached (land + water).
|
|
/// </summary>
|
|
public static int CachedSectorCount => _landCaches.Count + _waterCaches.Count;
|
|
|
|
/// <summary>
|
|
/// Gets the number of land sectors currently cached.
|
|
/// </summary>
|
|
public static int LandCacheCount => _landCaches.Count;
|
|
|
|
/// <summary>
|
|
/// Gets the number of water sectors currently cached.
|
|
/// </summary>
|
|
public static int WaterCacheCount => _waterCaches.Count;
|
|
}
|