GetNearbyObjects now searches the player's landblock plus all 8 neighbors. Previously only searched one landblock, missing objects near landblock boundaries — which includes most trees/rocks since scenery is placed across the full streaming window. Also added diagnostic logging (will strip after verification). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
184 lines
6.7 KiB
C#
184 lines
6.7 KiB
C#
using System.Collections.Generic;
|
||
using System.Numerics;
|
||
|
||
namespace AcDream.Core.Physics;
|
||
|
||
/// <summary>
|
||
/// 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.
|
||
/// </summary>
|
||
public sealed class ShadowObjectRegistry
|
||
{
|
||
private readonly Dictionary<uint, List<ShadowEntry>> _cells = new();
|
||
private readonly Dictionary<uint, List<uint>> _entityToCells = new(); // for deregistration
|
||
|
||
/// <summary>
|
||
/// Register an entity into the cells it overlaps based on world position + radius.
|
||
/// </summary>
|
||
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>();
|
||
|
||
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<ShadowEntry>();
|
||
_cells[cellId] = list;
|
||
}
|
||
list.Add(entry);
|
||
}
|
||
}
|
||
|
||
_entityToCells[entityId] = cellIds;
|
||
}
|
||
|
||
/// <summary>Remove an entity from all cells it was registered in.</summary>
|
||
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);
|
||
}
|
||
|
||
/// <summary>Remove all entities belonging to a landblock.</summary>
|
||
public void RemoveLandblock(uint landblockId)
|
||
{
|
||
uint lbPrefix = landblockId & 0xFFFF0000u;
|
||
var toRemove = new List<uint>();
|
||
|
||
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<uint>();
|
||
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);
|
||
}
|
||
|
||
/// <summary>Get all objects registered in a specific cell.</summary>
|
||
public IReadOnlyList<ShadowEntry> GetObjectsInCell(uint cellId)
|
||
{
|
||
if (_cells.TryGetValue(cellId, out var list))
|
||
return list;
|
||
return Array.Empty<ShadowEntry>();
|
||
}
|
||
|
||
/// <summary>
|
||
/// 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.
|
||
/// </summary>
|
||
public void GetNearbyObjects(Vector3 worldPos, float queryRadius,
|
||
float worldOffsetX, float worldOffsetY, uint landblockId,
|
||
List<ShadowEntry> results)
|
||
{
|
||
results.Clear();
|
||
var seen = new HashSet<uint>();
|
||
|
||
// 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;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Collision type for a shadow entry. BSP uses full polygon collision.
|
||
/// Cylinder uses a simple cylinder-sphere intersection test.
|
||
/// </summary>
|
||
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);
|