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<ShadowEntry>> _cells = new();
|
||||||
private readonly Dictionary<uint, List<uint>> _entityToCells = new(); // for deregistration
|
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>
|
/// <summary>
|
||||||
/// Register an entity into the cells it overlaps based on world position + radius.
|
/// Register an entity into the cells it overlaps based on world position + radius.
|
||||||
///
|
///
|
||||||
|
|
@ -98,6 +105,102 @@ public sealed class ShadowObjectRegistry
|
||||||
_entityToCells[entityId] = cellIds;
|
_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>
|
/// <summary>
|
||||||
/// Update an already-registered entity's world position + rotation,
|
/// Update an already-registered entity's world position + rotation,
|
||||||
/// preserving its <see cref="ShadowEntry.State"/>,
|
/// preserving its <see cref="ShadowEntry.State"/>,
|
||||||
|
|
@ -201,6 +304,7 @@ public sealed class ShadowObjectRegistry
|
||||||
list.RemoveAll(e => e.EntityId == entityId);
|
list.RemoveAll(e => e.EntityId == entityId);
|
||||||
}
|
}
|
||||||
_entityToCells.Remove(entityId);
|
_entityToCells.Remove(entityId);
|
||||||
|
_entityShapes.Remove(entityId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Remove all entities belonging to a landblock.</summary>
|
/// <summary>Remove all entities belonging to a landblock.</summary>
|
||||||
|
|
@ -386,18 +490,35 @@ public sealed class ShadowObjectRegistry
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Debug: enumerate every registered ShadowEntry (deduplicated across cells).
|
/// 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.
|
/// Intended for debug rendering only.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public IEnumerable<ShadowEntry> AllEntriesForDebug()
|
public IEnumerable<ShadowEntry> AllEntriesForDebug()
|
||||||
{
|
{
|
||||||
var seen = new HashSet<uint>();
|
var seenEntities = new HashSet<uint>();
|
||||||
foreach (var kvp in _cells)
|
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))
|
if (!_cells.TryGetValue(cellId, out var list)) continue;
|
||||||
yield return entry;
|
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.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,164 @@
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Numerics;
|
||||||
|
using AcDream.Core.Physics;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace AcDream.Core.Tests.Physics;
|
||||||
|
|
||||||
|
public class ShadowObjectRegistryMultiPartTests
|
||||||
|
{
|
||||||
|
private const uint LbId = 0xA9B40000u;
|
||||||
|
private const float OffX = 0f;
|
||||||
|
private const float OffY = 0f;
|
||||||
|
|
||||||
|
private static IReadOnlyList<ShadowShape> DoorShapes() => new[]
|
||||||
|
{
|
||||||
|
new ShadowShape(
|
||||||
|
GfxObjId: 0u,
|
||||||
|
LocalPosition: new Vector3(0f, 0f, 0.018f),
|
||||||
|
LocalRotation: Quaternion.Identity,
|
||||||
|
Scale: 1.0f,
|
||||||
|
CollisionType: ShadowCollisionType.Cylinder,
|
||||||
|
Radius: 0.100f,
|
||||||
|
CylHeight: 0.200f),
|
||||||
|
new ShadowShape(
|
||||||
|
GfxObjId: 0x010044B5u,
|
||||||
|
LocalPosition: Vector3.Zero,
|
||||||
|
LocalRotation: Quaternion.Identity,
|
||||||
|
Scale: 1.0f,
|
||||||
|
CollisionType: ShadowCollisionType.BSP,
|
||||||
|
Radius: 2.0f,
|
||||||
|
CylHeight: 0f),
|
||||||
|
new ShadowShape(
|
||||||
|
GfxObjId: 0x010044B6u,
|
||||||
|
LocalPosition: Vector3.Zero,
|
||||||
|
LocalRotation: Quaternion.Identity,
|
||||||
|
Scale: 1.0f,
|
||||||
|
CollisionType: ShadowCollisionType.BSP,
|
||||||
|
Radius: 2.0f,
|
||||||
|
CylHeight: 0f),
|
||||||
|
new ShadowShape(
|
||||||
|
GfxObjId: 0x010044B6u,
|
||||||
|
LocalPosition: Vector3.Zero,
|
||||||
|
LocalRotation: Quaternion.Identity,
|
||||||
|
Scale: 1.0f,
|
||||||
|
CollisionType: ShadowCollisionType.BSP,
|
||||||
|
Radius: 2.0f,
|
||||||
|
CylHeight: 0f)
|
||||||
|
};
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RegisterMultiPart_FourShapes_AllShareEntityId()
|
||||||
|
{
|
||||||
|
var reg = new ShadowObjectRegistry();
|
||||||
|
const uint doorEntityId = 0x000F4244u;
|
||||||
|
|
||||||
|
reg.RegisterMultiPart(
|
||||||
|
entityId: doorEntityId,
|
||||||
|
entityWorldPos: new Vector3(132.6f, 17.1f, 94.08f),
|
||||||
|
entityWorldRot: Quaternion.Identity,
|
||||||
|
shapes: DoorShapes(),
|
||||||
|
state: 0x10008u,
|
||||||
|
flags: EntityCollisionFlags.None,
|
||||||
|
worldOffsetX: OffX,
|
||||||
|
worldOffsetY: OffY,
|
||||||
|
landblockId: LbId);
|
||||||
|
|
||||||
|
int found = 0;
|
||||||
|
foreach (var entry in reg.AllEntriesForDebug())
|
||||||
|
{
|
||||||
|
if (entry.EntityId == doorEntityId) found++;
|
||||||
|
}
|
||||||
|
Assert.True(found >= 4,
|
||||||
|
$"Expected at least 4 entries for door entity (one per shape); found {found}");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RegisterMultiPart_EmptyShapeList_NoOp()
|
||||||
|
{
|
||||||
|
var reg = new ShadowObjectRegistry();
|
||||||
|
reg.RegisterMultiPart(
|
||||||
|
entityId: 0x1u,
|
||||||
|
entityWorldPos: Vector3.Zero,
|
||||||
|
entityWorldRot: Quaternion.Identity,
|
||||||
|
shapes: System.Array.Empty<ShadowShape>(),
|
||||||
|
state: 0u,
|
||||||
|
flags: EntityCollisionFlags.None,
|
||||||
|
worldOffsetX: OffX, worldOffsetY: OffY, landblockId: LbId);
|
||||||
|
|
||||||
|
Assert.Equal(0, reg.TotalRegistered);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Deregister_RemovesAllParts()
|
||||||
|
{
|
||||||
|
var reg = new ShadowObjectRegistry();
|
||||||
|
const uint doorEntityId = 0x000F4244u;
|
||||||
|
reg.RegisterMultiPart(doorEntityId, new Vector3(132.6f, 17.1f, 94.08f),
|
||||||
|
Quaternion.Identity, DoorShapes(), 0x10008u,
|
||||||
|
EntityCollisionFlags.None, OffX, OffY, LbId);
|
||||||
|
|
||||||
|
reg.Deregister(doorEntityId);
|
||||||
|
|
||||||
|
Assert.Equal(0, reg.TotalRegistered);
|
||||||
|
foreach (var entry in reg.AllEntriesForDebug())
|
||||||
|
Assert.NotEqual(doorEntityId, entry.EntityId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UpdatePhysicsState_PropagatesEtherealToAllParts()
|
||||||
|
{
|
||||||
|
var reg = new ShadowObjectRegistry();
|
||||||
|
const uint doorEntityId = 0x000F4244u;
|
||||||
|
reg.RegisterMultiPart(doorEntityId, new Vector3(132.6f, 17.1f, 94.08f),
|
||||||
|
Quaternion.Identity, DoorShapes(), 0x10008u,
|
||||||
|
EntityCollisionFlags.None, OffX, OffY, LbId);
|
||||||
|
|
||||||
|
reg.UpdatePhysicsState(doorEntityId, 0x1000Cu); // 0x10008 | 0x4
|
||||||
|
|
||||||
|
int updated = 0;
|
||||||
|
foreach (var entry in reg.AllEntriesForDebug())
|
||||||
|
{
|
||||||
|
if (entry.EntityId != doorEntityId) continue;
|
||||||
|
Assert.Equal(0x1000Cu, entry.State);
|
||||||
|
updated++;
|
||||||
|
}
|
||||||
|
Assert.True(updated >= 4, $"Expected all parts updated, only {updated} were");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RegisterMultiPart_PartsAcrossMultipleCells_AllCellsListed()
|
||||||
|
{
|
||||||
|
var reg = new ShadowObjectRegistry();
|
||||||
|
// Two shapes 30m apart in X — must span two outdoor 24m cells.
|
||||||
|
var shapes = new[]
|
||||||
|
{
|
||||||
|
new ShadowShape(0u, new Vector3( 0f, 0f, 0f), Quaternion.Identity, 1f,
|
||||||
|
ShadowCollisionType.Cylinder, 1f, 2f),
|
||||||
|
new ShadowShape(0u, new Vector3(30f, 0f, 0f), Quaternion.Identity, 1f,
|
||||||
|
ShadowCollisionType.Cylinder, 1f, 2f),
|
||||||
|
};
|
||||||
|
reg.RegisterMultiPart(0x1u, new Vector3(12f, 12f, 50f), Quaternion.Identity,
|
||||||
|
shapes, 0u, EntityCollisionFlags.None, OffX, OffY, LbId);
|
||||||
|
|
||||||
|
// Part 1 at world (12, 12) → cell (0,0) = LbId | 1
|
||||||
|
// Part 2 at world (42, 12) → cell (1,0) = LbId | 9
|
||||||
|
var entriesIn1 = reg.GetObjectsInCell(LbId | 1u);
|
||||||
|
var entriesIn9 = reg.GetObjectsInCell(LbId | 9u);
|
||||||
|
Assert.Contains(entriesIn1, e => e.EntityId == 0x1u);
|
||||||
|
Assert.Contains(entriesIn9, e => e.EntityId == 0x1u);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Register_SingleShapeCompat_Unchanged()
|
||||||
|
{
|
||||||
|
var reg = new ShadowObjectRegistry();
|
||||||
|
reg.Register(42u, 0x01000001u, new Vector3(12f, 12f, 50f),
|
||||||
|
Quaternion.Identity, 1f, OffX, OffY, LbId);
|
||||||
|
|
||||||
|
Assert.Equal(1, reg.TotalRegistered);
|
||||||
|
Assert.Single(reg.GetObjectsInCell(LbId | 1u),
|
||||||
|
e => e.EntityId == 42u);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue