using System.Collections.Generic; using System.Numerics; namespace AcDream.Core.Physics; /// /// Cell-based spatial index for object collision. Each entity registers /// into the outdoor terrain cells (24m × 24m) it overlaps. The Transition /// system queries this to find nearby objects during collision detection. /// /// Retail AC uses the same cell-based approach (no k-d tree / octree). /// Outdoor cells are 24×24m (8 cells per 192m landblock, 64 cells per lb). /// Cell ID = landblock high 16 bits | (cellX * 8 + cellY + 1) in low 16. /// public sealed class ShadowObjectRegistry { private readonly Dictionary> _cells = new(); private readonly Dictionary> _entityToCells = new(); // for deregistration /// /// Register an entity into the cells it overlaps based on world position + radius. /// public void Register(uint entityId, uint gfxObjId, Vector3 worldPos, Quaternion rotation, float radius, float worldOffsetX, float worldOffsetY, uint landblockId, ShadowCollisionType collisionType = ShadowCollisionType.BSP, float cylHeight = 0f) { Deregister(entityId); float localX = worldPos.X - worldOffsetX; float localY = worldPos.Y - worldOffsetY; int minCx = Math.Max(0, (int)((localX - radius) / 24f)); int maxCx = Math.Min(7, (int)((localX + radius) / 24f)); int minCy = Math.Max(0, (int)((localY - radius) / 24f)); int maxCy = Math.Min(7, (int)((localY + radius) / 24f)); var entry = new ShadowEntry(entityId, gfxObjId, worldPos, rotation, radius, collisionType, cylHeight); var cellIds = new List(); uint lbPrefix = landblockId & 0xFFFF0000u; for (int cx = minCx; cx <= maxCx; cx++) { for (int cy = minCy; cy <= maxCy; cy++) { uint cellId = lbPrefix | (uint)(cx * 8 + cy + 1); cellIds.Add(cellId); if (!_cells.TryGetValue(cellId, out var list)) { list = new List(); _cells[cellId] = list; } list.Add(entry); } } _entityToCells[entityId] = cellIds; } /// Remove an entity from all cells it was registered in. public void Deregister(uint entityId) { if (!_entityToCells.TryGetValue(entityId, out var cellIds)) return; foreach (var cellId in cellIds) { if (_cells.TryGetValue(cellId, out var list)) list.RemoveAll(e => e.EntityId == entityId); } _entityToCells.Remove(entityId); } /// Remove all entities belonging to a landblock. public void RemoveLandblock(uint landblockId) { uint lbPrefix = landblockId & 0xFFFF0000u; var toRemove = new List(); foreach (var kvp in _cells) { if ((kvp.Key & 0xFFFF0000u) == lbPrefix) toRemove.Add(kvp.Key); } foreach (var cellId in toRemove) _cells.Remove(cellId); // Clean up entity-to-cell map var entitiesToRemove = new List(); foreach (var kvp in _entityToCells) { kvp.Value.RemoveAll(c => (c & 0xFFFF0000u) == lbPrefix); if (kvp.Value.Count == 0) entitiesToRemove.Add(kvp.Key); } foreach (var eid in entitiesToRemove) _entityToCells.Remove(eid); } /// Get all objects registered in a specific cell. public IReadOnlyList GetObjectsInCell(uint cellId) { if (_cells.TryGetValue(cellId, out var list)) return list; return Array.Empty(); } /// /// Get all objects near a world position. Searches the given landblock plus /// all 8 adjacent landblocks to handle objects near cell/landblock boundaries. /// Within each landblock, queries only the cells the query sphere overlaps. /// public void GetNearbyObjects(Vector3 worldPos, float queryRadius, float worldOffsetX, float worldOffsetY, uint landblockId, List results) { results.Clear(); var seen = new HashSet(); // Extract landblock X/Y from the ID. int lbX = (int)((landblockId >> 24) & 0xFF); int lbY = (int)((landblockId >> 16) & 0xFF); // Search the player's landblock and all 8 neighbors. for (int dx = -1; dx <= 1; dx++) { for (int dy = -1; dy <= 1; dy++) { int nx = lbX + dx; int ny = lbY + dy; if (nx < 0 || nx > 255 || ny < 0 || ny > 255) continue; uint neighborLb = ((uint)nx << 24) | ((uint)ny << 16) | 0xFFFFu; uint nbPrefix = neighborLb & 0xFFFF0000u; // Compute local position relative to this neighbor landblock. float nbOffX = worldOffsetX + dx * 192f; float nbOffY = worldOffsetY + dy * 192f; float localX = worldPos.X - nbOffX; float localY = worldPos.Y - nbOffY; int minCx = Math.Max(0, (int)((localX - queryRadius) / 24f)); int maxCx = Math.Min(7, (int)((localX + queryRadius) / 24f)); int minCy = Math.Max(0, (int)((localY - queryRadius) / 24f)); int maxCy = Math.Min(7, (int)((localY + queryRadius) / 24f)); for (int cx = minCx; cx <= maxCx; cx++) { for (int cy = minCy; cy <= maxCy; cy++) { uint cellId = nbPrefix | (uint)(cx * 8 + cy + 1); if (!_cells.TryGetValue(cellId, out var list)) continue; foreach (var entry in list) { if (seen.Add(entry.EntityId)) results.Add(entry); } } } } } } public int TotalRegistered => _entityToCells.Count; } /// /// Collision type for a shadow entry. BSP uses full polygon collision. /// Cylinder uses a simple cylinder-sphere intersection test. /// public enum ShadowCollisionType : byte { BSP, Cylinder } public readonly record struct ShadowEntry( uint EntityId, uint GfxObjId, Vector3 Position, Quaternion Rotation, float Radius, ShadowCollisionType CollisionType = ShadowCollisionType.BSP, float CylHeight = 0f);