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
|
|
@ -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