feat(phys): ShadowObjectRegistry.RegisterMultiPart
Multi-shape entity registration matching retail's CPhysicsObj model: one logical entity emits N ShadowEntry rows (one per CylSphere / Sphere / Part-BSP), all sharing the entity's EntityId. _entityShapes caches the original shape list per entity for UpdatePosition to recompose part transforms when the entity moves. Existing UpdatePhysicsState / Deregister / GetObjectsInCell / AllEntriesForDebug work unchanged — they iterate by EntityId; multiple matching entries get handled automatically. AllEntriesForDebug updated to enumerate all parts per entity (not just the first) by iterating the first cell that holds entries for each entity. Single-shape callers that previously relied on deduplicated-by-EntityId behavior are unaffected since they register exactly one entry per entity. Six new tests: AllShareEntityId, EmptyShapeList_NoOp, Deregister_RemovesAllParts, UpdatePhysicsState_PropagatesEtherealToAllParts, PartsAcrossMultipleCells_AllCellsListed, Register_SingleShapeCompat_Unchanged. All 24 existing ShadowObjectRegistry tests pass via the unchanged single-shape Register API. 11/11 CellarUpTrajectoryReplayTests pass. 7/7 ShadowShapeBuilderTests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
1454eab75a
commit
fca0a13217
2 changed files with 291 additions and 6 deletions
|
|
@ -17,6 +17,13 @@ public sealed class ShadowObjectRegistry
|
|||
private readonly Dictionary<uint, List<ShadowEntry>> _cells = new();
|
||||
private readonly Dictionary<uint, List<uint>> _entityToCells = new(); // for deregistration
|
||||
|
||||
/// <summary>
|
||||
/// A6.P4 door fix (2026-05-24): per-entity original shape list, used by
|
||||
/// <see cref="UpdatePosition"/> to recompose part world-transforms when
|
||||
/// the entity moves. Cleared by <see cref="Deregister"/>.
|
||||
/// </summary>
|
||||
private readonly Dictionary<uint, System.Collections.Generic.IReadOnlyList<ShadowShape>> _entityShapes = new();
|
||||
|
||||
/// <summary>
|
||||
/// Register an entity into the cells it overlaps based on world position + radius.
|
||||
///
|
||||
|
|
@ -98,6 +105,102 @@ public sealed class ShadowObjectRegistry
|
|||
_entityToCells[entityId] = cellIds;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A6.P4 door fix (2026-05-24): register one logical entity composed of
|
||||
/// multiple collision shapes. All emitted <see cref="ShadowEntry"/> rows
|
||||
/// share <paramref name="entityId"/>, so <see cref="UpdatePhysicsState"/>
|
||||
/// propagates an ETHEREAL flip to every part (the existing per-entityId
|
||||
/// iteration handles this naturally). The shape list is cached in
|
||||
/// <see cref="_entityShapes"/> so <see cref="UpdatePosition"/> can
|
||||
/// recompose part world-transforms when the entity moves.
|
||||
///
|
||||
/// <para>
|
||||
/// Retail anchor: <c>CPhysicsObj::FindObjCollisions</c> →
|
||||
/// <c>CPartArray::FindObjCollisions</c> at
|
||||
/// <c>acclient_2013_pseudo_c.txt:276961-286250</c>. One PhysicsObj per
|
||||
/// entity, parts iterated for collision testing.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public void RegisterMultiPart(
|
||||
uint entityId,
|
||||
Vector3 entityWorldPos,
|
||||
Quaternion entityWorldRot,
|
||||
System.Collections.Generic.IReadOnlyList<ShadowShape> shapes,
|
||||
uint state,
|
||||
EntityCollisionFlags flags,
|
||||
float worldOffsetX, float worldOffsetY, uint landblockId,
|
||||
uint cellScope = 0u)
|
||||
{
|
||||
Deregister(entityId);
|
||||
if (shapes.Count == 0) return;
|
||||
|
||||
_entityShapes[entityId] = shapes;
|
||||
var allCells = new List<uint>();
|
||||
var seenCells = new HashSet<uint>();
|
||||
uint lbPrefix = landblockId & 0xFFFF0000u;
|
||||
|
||||
foreach (var shape in shapes)
|
||||
{
|
||||
var rotatedLocal = Vector3.Transform(shape.LocalPosition, entityWorldRot);
|
||||
var partWorldPos = entityWorldPos + rotatedLocal;
|
||||
var partWorldRot = entityWorldRot * shape.LocalRotation;
|
||||
|
||||
var entry = new ShadowEntry(
|
||||
EntityId: entityId,
|
||||
GfxObjId: shape.GfxObjId,
|
||||
Position: partWorldPos,
|
||||
Rotation: partWorldRot,
|
||||
Radius: shape.Radius,
|
||||
CollisionType: shape.CollisionType,
|
||||
CylHeight: shape.CylHeight,
|
||||
Scale: shape.Scale,
|
||||
State: state,
|
||||
Flags: flags,
|
||||
LocalPosition: shape.LocalPosition,
|
||||
LocalRotation: shape.LocalRotation);
|
||||
|
||||
if (cellScope != 0u)
|
||||
{
|
||||
AddEntryToCell(entry, cellScope);
|
||||
if (seenCells.Add(cellScope)) allCells.Add(cellScope);
|
||||
continue;
|
||||
}
|
||||
|
||||
float localX = partWorldPos.X - worldOffsetX;
|
||||
float localY = partWorldPos.Y - worldOffsetY;
|
||||
float r = shape.Radius;
|
||||
|
||||
int minCx = Math.Max(0, (int)((localX - r) / 24f));
|
||||
int maxCx = Math.Min(7, (int)((localX + r) / 24f));
|
||||
int minCy = Math.Max(0, (int)((localY - r) / 24f));
|
||||
int maxCy = Math.Min(7, (int)((localY + r) / 24f));
|
||||
|
||||
for (int cx = minCx; cx <= maxCx; cx++)
|
||||
{
|
||||
for (int cy = minCy; cy <= maxCy; cy++)
|
||||
{
|
||||
uint cellId = lbPrefix | (uint)(cx * 8 + cy + 1);
|
||||
AddEntryToCell(entry, cellId);
|
||||
if (seenCells.Add(cellId)) allCells.Add(cellId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_entityToCells[entityId] = allCells;
|
||||
}
|
||||
|
||||
/// <summary>Helper: append a <see cref="ShadowEntry"/> to a cell's
|
||||
/// list, creating the list if needed.</summary>
|
||||
private void AddEntryToCell(ShadowEntry entry, uint cellId)
|
||||
{
|
||||
if (!_cells.TryGetValue(cellId, out var list))
|
||||
{
|
||||
list = new List<ShadowEntry>();
|
||||
_cells[cellId] = list;
|
||||
}
|
||||
list.Add(entry);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update an already-registered entity's world position + rotation,
|
||||
/// preserving its <see cref="ShadowEntry.State"/>,
|
||||
|
|
@ -201,6 +304,7 @@ public sealed class ShadowObjectRegistry
|
|||
list.RemoveAll(e => e.EntityId == entityId);
|
||||
}
|
||||
_entityToCells.Remove(entityId);
|
||||
_entityShapes.Remove(entityId);
|
||||
}
|
||||
|
||||
/// <summary>Remove all entities belonging to a landblock.</summary>
|
||||
|
|
@ -386,18 +490,35 @@ public sealed class ShadowObjectRegistry
|
|||
|
||||
/// <summary>
|
||||
/// Debug: enumerate every registered ShadowEntry (deduplicated across cells).
|
||||
/// For each entity, returns the first entry found in any cell it occupies.
|
||||
/// Single-shape entities return one entry per shape; multi-part entities
|
||||
/// return one entry per registered part (including duplicate shapes at the
|
||||
/// same position). Each entity is enumerated exactly once per logical part:
|
||||
/// we use the first cell the entity occupies to read its entries, avoiding
|
||||
/// re-emitting the same part for each cell it overlaps.
|
||||
/// Intended for debug rendering only.
|
||||
/// </summary>
|
||||
public IEnumerable<ShadowEntry> AllEntriesForDebug()
|
||||
{
|
||||
var seen = new HashSet<uint>();
|
||||
foreach (var kvp in _cells)
|
||||
var seenEntities = new HashSet<uint>();
|
||||
foreach (var kvp in _entityToCells)
|
||||
{
|
||||
foreach (var entry in kvp.Value)
|
||||
uint entityId = kvp.Key;
|
||||
if (!seenEntities.Add(entityId)) continue;
|
||||
|
||||
// Use the first cell that holds entries for this entity.
|
||||
foreach (uint cellId in kvp.Value)
|
||||
{
|
||||
if (seen.Add(entry.EntityId))
|
||||
yield return entry;
|
||||
if (!_cells.TryGetValue(cellId, out var list)) continue;
|
||||
bool anyFound = false;
|
||||
foreach (var entry in list)
|
||||
{
|
||||
if (entry.EntityId == entityId)
|
||||
{
|
||||
yield return entry;
|
||||
anyFound = true;
|
||||
}
|
||||
}
|
||||
if (anyFound) break; // Only use the first cell — avoids duplicating multi-cell shapes.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue