feat(physics): PositionManager combiner class + 6 unit tests (L.3.2)
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 <noreply@anthropic.com>
This commit is contained in:
parent
d063ac884d
commit
08fbbef3c4
2 changed files with 234 additions and 0 deletions
55
src/AcDream.Core/Physics/PositionManager.cs
Normal file
55
src/AcDream.Core/Physics/PositionManager.cs
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
using System.Numerics;
|
||||
|
||||
namespace AcDream.Core.Physics;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public sealed class PositionManager
|
||||
{
|
||||
/// <summary>
|
||||
/// Compute the per-frame world-space delta to add to body.Position.
|
||||
/// </summary>
|
||||
/// <param name="dt">Per-frame delta time, seconds.</param>
|
||||
/// <param name="currentBodyPosition">Body's current world-space position.</param>
|
||||
/// <param name="seqVel">
|
||||
/// Body-local velocity from the active animation cycle
|
||||
/// (from <c>AnimationSequencer.CurrentVelocity</c>); pass
|
||||
/// <c>Vector3.Zero</c> if the entity has no sequencer or is on a
|
||||
/// non-locomotion cycle.
|
||||
/// </param>
|
||||
/// <param name="ori">Body orientation; used to rotate seqVel from body-local to world.</param>
|
||||
/// <param name="interp">The remote's InterpolationManager (for AdjustOffset call).</param>
|
||||
/// <param name="maxSpeed">From <c>MotionInterpreter.GetMaxSpeed()</c> — passed to AdjustOffset for the catch-up clamp.</param>
|
||||
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;
|
||||
}
|
||||
}
|
||||
179
tests/AcDream.Core.Tests/Physics/PositionManagerTests.cs
Normal file
179
tests/AcDream.Core.Tests/Physics/PositionManagerTests.cs
Normal file
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue