From d5ffb0331baeef1fc244322946b43e0e1cdf468c Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 24 May 2026 15:21:35 +0200 Subject: [PATCH] feat(phys): UpdatePosition handles multi-part entities Multi-part entities cached via RegisterMultiPart's _entityShapes now recompose all part transforms on UpdatePosition (called when the server broadcasts UpdatePosition (0xF748) for a moving entity). Legacy single-shape path preserved unchanged for tests + entities that never went through RegisterMultiPart. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Physics/ShadowObjectRegistry.cs | 36 ++++++++++++++++--- .../ShadowObjectRegistryMultiPartTests.cs | 32 +++++++++++++++++ 2 files changed, 63 insertions(+), 5 deletions(-) diff --git a/src/AcDream.Core/Physics/ShadowObjectRegistry.cs b/src/AcDream.Core/Physics/ShadowObjectRegistry.cs index 0780891..002ebfe 100644 --- a/src/AcDream.Core/Physics/ShadowObjectRegistry.cs +++ b/src/AcDream.Core/Physics/ShadowObjectRegistry.cs @@ -219,8 +219,36 @@ public sealed class ShadowObjectRegistry public void UpdatePosition(uint entityId, Vector3 worldPos, Quaternion rotation, float worldOffsetX, float worldOffsetY, uint landblockId) { - // Find the existing entry (any cell holds a copy with the same - // entity-scoped state + flags + shape). + // A6.P4 door fix (2026-05-24): if the entity was registered via + // RegisterMultiPart, we have its full shape list cached. Use that + // to recompose all part transforms instead of finding one template entry. + if (_entityShapes.TryGetValue(entityId, out var shapes)) + { + // Pull the entity-scoped state + flags from the first matching entry + // (they're shared across all parts of a logical entity). + uint state = 0u; + EntityCollisionFlags flags = EntityCollisionFlags.None; + if (_entityToCells.TryGetValue(entityId, out var existingCells) + && existingCells.Count > 0 + && _cells.TryGetValue(existingCells[0], out var firstList)) + { + foreach (var e in firstList) + { + if (e.EntityId == entityId) + { + state = e.State; + flags = e.Flags; + break; + } + } + } + RegisterMultiPart(entityId, worldPos, rotation, shapes, + state, flags, worldOffsetX, worldOffsetY, landblockId); + return; + } + + // Single-shape path (legacy compat for tests + entities that never + // went through RegisterMultiPart). if (!_entityToCells.TryGetValue(entityId, out var oldCells) || oldCells.Count == 0) return; @@ -240,10 +268,8 @@ public sealed class ShadowObjectRegistry } if (template is not null) break; } - if (template is null) - return; + if (template is null) return; - // Preserve everything except position + rotation. var t = template.Value; Register(entityId, t.GfxObjId, worldPos, rotation, t.Radius, worldOffsetX, worldOffsetY, landblockId, diff --git a/tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryMultiPartTests.cs b/tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryMultiPartTests.cs index e1c03b2..1d47a19 100644 --- a/tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryMultiPartTests.cs +++ b/tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryMultiPartTests.cs @@ -161,4 +161,36 @@ public class ShadowObjectRegistryMultiPartTests Assert.Single(reg.GetObjectsInCell(LbId | 1u), e => e.EntityId == 42u); } + + [Fact] + public void UpdatePosition_MovesAllPartsWithEntity() + { + var reg = new ShadowObjectRegistry(); + const uint movingEntityId = 0xA1u; + + var shapes = new[] + { + new ShadowShape(0u, new Vector3(0f, 0f, 0f), Quaternion.Identity, 1f, + ShadowCollisionType.Cylinder, 0.5f, 1f), + new ShadowShape(0u, new Vector3(1f, 0f, 0f), Quaternion.Identity, 1f, + ShadowCollisionType.Cylinder, 0.5f, 1f), + }; + reg.RegisterMultiPart(movingEntityId, new Vector3(10f, 10f, 50f), + Quaternion.Identity, shapes, 0u, + EntityCollisionFlags.None, OffX, OffY, LbId); + + // Move entity to (50, 10, 50). Parts should be at (50, 10, 50) and (51, 10, 50). + reg.UpdatePosition(movingEntityId, + new Vector3(50f, 10f, 50f), Quaternion.Identity, + OffX, OffY, LbId); + + Vector3 expectedPart0 = new(50f, 10f, 50f); + Vector3 expectedPart1 = new(51f, 10f, 50f); + var atNew = reg.AllEntriesForDebug().Where(e => e.EntityId == movingEntityId).ToList(); + Assert.Equal(2, atNew.Count); + bool found0 = atNew.Any(e => Vector3.Distance(e.Position, expectedPart0) < 0.01f); + bool found1 = atNew.Any(e => Vector3.Distance(e.Position, expectedPart1) < 0.01f); + Assert.True(found0 && found1, + "Expected both parts at new world positions (50, 10, 50) and (51, 10, 50)"); + } }