From 08fbbef3c40166499b2a5f7b8be3a27fd6060d6d Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 3 May 2026 10:13:02 +0200 Subject: [PATCH] feat(physics): PositionManager combiner class + 6 unit tests (L.3.2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure-function ComputeOffset(dt, pos, seqVel, ori, interp, maxSpeed) → Vector3. Combines animation root motion (seqVel × dt rotated by body orientation) with InterpolationManager.AdjustOffset world-space correction. Mirrors retail CPhysicsObj::UpdateObjectInternal (acclient @ 0x00513730). Composed into RemoteMotion in subsequent task (L.3.1+L.3.2 Task 3); not yet consumed. Co-Authored-By: Claude Opus 4.7 --- src/AcDream.Core/Physics/PositionManager.cs | 55 ++++++ .../Physics/PositionManagerTests.cs | 179 ++++++++++++++++++ 2 files changed, 234 insertions(+) create mode 100644 src/AcDream.Core/Physics/PositionManager.cs create mode 100644 tests/AcDream.Core.Tests/Physics/PositionManagerTests.cs diff --git a/src/AcDream.Core/Physics/PositionManager.cs b/src/AcDream.Core/Physics/PositionManager.cs new file mode 100644 index 0000000..aa352ab --- /dev/null +++ b/src/AcDream.Core/Physics/PositionManager.cs @@ -0,0 +1,55 @@ +using System.Numerics; + +namespace AcDream.Core.Physics; + +/// +/// Per-frame combiner for remote-entity motion: animation root motion +/// + InterpolationManager catch-up correction. Pure function — no +/// side effects, no hidden state. +/// +/// Mirrors retail CPhysicsObj::UpdateObjectInternal (acclient @ 0x00513730): +/// rootOffset = CPartArray::Update(dt) // animation +/// PositionManager::adjust_offset(rootOffset) // adds correction +/// frame.origin += rootOffset +/// +/// In acdream the animation root motion is sourced from +/// AnimationSequencer.CurrentVelocity (body-local velocity from the +/// active locomotion cycle). We rotate that by the body's orientation +/// to get a world-space delta, then add the InterpolationManager's +/// world-space correction. +/// +public sealed class PositionManager +{ + /// + /// Compute the per-frame world-space delta to add to body.Position. + /// + /// Per-frame delta time, seconds. + /// Body's current world-space position. + /// + /// Body-local velocity from the active animation cycle + /// (from AnimationSequencer.CurrentVelocity); pass + /// Vector3.Zero if the entity has no sequencer or is on a + /// non-locomotion cycle. + /// + /// Body orientation; used to rotate seqVel from body-local to world. + /// The remote's InterpolationManager (for AdjustOffset call). + /// From MotionInterpreter.GetMaxSpeed() — passed to AdjustOffset for the catch-up clamp. + public Vector3 ComputeOffset( + double dt, + Vector3 currentBodyPosition, + Vector3 seqVel, + Quaternion ori, + InterpolationManager interp, + float maxSpeed) + { + // Step 1: animation root motion (body-local → world). + Vector3 rootMotionLocal = seqVel * (float)dt; + Vector3 rootMotionWorld = Vector3.Transform(rootMotionLocal, ori); + + // Step 2: interpolation correction (world-space already). + Vector3 correction = interp.AdjustOffset(dt, currentBodyPosition, maxSpeed); + + // Step 3: combined delta. + return rootMotionWorld + correction; + } +} diff --git a/tests/AcDream.Core.Tests/Physics/PositionManagerTests.cs b/tests/AcDream.Core.Tests/Physics/PositionManagerTests.cs new file mode 100644 index 0000000..7839bc6 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/PositionManagerTests.cs @@ -0,0 +1,179 @@ +using System; +using System.Numerics; +using AcDream.Core.Physics; +using Xunit; + +namespace AcDream.Core.Tests.Physics; + +// ───────────────────────────────────────────────────────────────────────────── +// PositionManagerTests — 6 tests covering ComputeOffset. +// +// Mirrors retail CPhysicsObj::UpdateObjectInternal (acclient @ 0x00513730). +// Pure-function combiner: animation root motion (seqVel × dt, rotated by +// body orientation) + InterpolationManager.AdjustOffset correction. +// ───────────────────────────────────────────────────────────────────────────── + +public sealed class PositionManagerTests +{ + // ── helpers ─────────────────────────────────────────────────────────────── + + private static PositionManager Make() => new(); + + private static InterpolationManager EmptyInterp() => new(); + + // ========================================================================= + // Test 1: stationary remote — both sources zero, no motion + // ========================================================================= + + [Fact] + public void ComputeOffset_StationaryRemote_BothSourcesZero_NoMotion() + { + var pm = Make(); + var interp = EmptyInterp(); + + Vector3 offset = pm.ComputeOffset( + dt: 0.1, + currentBodyPosition: Vector3.Zero, + seqVel: Vector3.Zero, + ori: Quaternion.Identity, + interp: interp, + maxSpeed: 4f); + + Assert.Equal(Vector3.Zero, offset); + } + + // ========================================================================= + // Test 2: animation only, identity orientation, forward velocity + // ========================================================================= + + [Fact] + public void ComputeOffset_AnimationOnly_Forward_BodyAdvances() + { + var pm = Make(); + var interp = EmptyInterp(); + + // seqVel = (0, 4, 0), dt = 0.1 → rootMotion = (0, 0.4, 0) + Vector3 offset = pm.ComputeOffset( + dt: 0.1, + currentBodyPosition: Vector3.Zero, + seqVel: new Vector3(0f, 4f, 0f), + ori: Quaternion.Identity, + interp: interp, + maxSpeed: 0f); + + Assert.Equal(0f, offset.X, precision: 4); + Assert.Equal(0.4f, offset.Y, precision: 4); + Assert.Equal(0f, offset.Z, precision: 4); + } + + // ========================================================================= + // Test 3: animation only, 180° yaw around Z — body moves south (-Y) + // ========================================================================= + + [Fact] + public void ComputeOffset_AnimationOnly_OrientedSouth_BodyMovesSouth() + { + var pm = Make(); + var interp = EmptyInterp(); + + // 180° around Z flips +Y → -Y + Quaternion ori = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, MathF.PI); + + Vector3 offset = pm.ComputeOffset( + dt: 0.1, + currentBodyPosition: Vector3.Zero, + seqVel: new Vector3(0f, 4f, 0f), + ori: ori, + interp: interp, + maxSpeed: 0f); + + Assert.Equal(0f, offset.X, precision: 4); + Assert.Equal(-0.4f, offset.Y, precision: 4); + } + + // ========================================================================= + // Test 4: interp only, no animation — body chases queue + // ========================================================================= + + [Fact] + public void ComputeOffset_InterpOnly_NoAnimation_BodyChasesQueue() + { + var pm = Make(); + var interp = new InterpolationManager(); + + // Enqueue target 1m ahead on +X; body starts at origin + interp.Enqueue(new Vector3(1f, 0f, 0f), heading: 0f, isMovingTo: false); + + // Expected catch-up: catchUpSpeed = maxSpeed × 2 = 4 × 2 = 8 m/s + // step = 8 × 0.1 = 0.8m (< dist = 1m so no overshoot clamp) + Vector3 offset = pm.ComputeOffset( + dt: 0.1, + currentBodyPosition: Vector3.Zero, + seqVel: Vector3.Zero, + ori: Quaternion.Identity, + interp: interp, + maxSpeed: 4f); + + Assert.Equal(0.8f, offset.X, precision: 3); + Assert.Equal(0f, offset.Y, precision: 3); + Assert.Equal(0f, offset.Z, precision: 3); + } + + // ========================================================================= + // Test 5: both sources active — combined delta + // ========================================================================= + + [Fact] + public void ComputeOffset_BothActive_Combined() + { + var pm = Make(); + var interp = new InterpolationManager(); + + // Enqueue target 1m ahead on +X + interp.Enqueue(new Vector3(1f, 0f, 0f), heading: 0f, isMovingTo: false); + + // rootMotion = (0, 4, 0) × 0.1 = (0, 0.4, 0) + // correction ≈ (0.8, 0, 0) + // combined ≈ (0.8, 0.4, 0) + Vector3 offset = pm.ComputeOffset( + dt: 0.1, + currentBodyPosition: Vector3.Zero, + seqVel: new Vector3(0f, 4f, 0f), + ori: Quaternion.Identity, + interp: interp, + maxSpeed: 4f); + + Assert.Equal(0.8f, offset.X, precision: 3); + Assert.Equal(0.4f, offset.Y, precision: 3); + Assert.Equal(0f, offset.Z, precision: 3); + } + + // ========================================================================= + // Test 6: local-to-world rotation — +90° yaw around Z + // ========================================================================= + + [Fact] + public void ComputeOffset_LocalToWorldRotation_Yaw90() + { + var pm = Make(); + var interp = EmptyInterp(); + + // +90° CCW around Z in right-handed coordinates: + // body-local +Y → world -X + Quaternion ori = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, MathF.PI / 2f); + + // seqVel = (0, 1, 0), dt = 1 → rootMotionLocal = (0, 1, 0) + // after Transform by ori → (-1, 0, 0) approximately + Vector3 offset = pm.ComputeOffset( + dt: 1.0, + currentBodyPosition: Vector3.Zero, + seqVel: new Vector3(0f, 1f, 0f), + ori: ori, + interp: interp, + maxSpeed: 0f); + + Assert.Equal(-1f, offset.X, precision: 4); + Assert.Equal(0f, offset.Y, precision: 4); + Assert.Equal(0f, offset.Z, precision: 4); + } +}