feat(B.6 slice 2): local-player auto-walk on inbound MoveToObject
Retail-faithful per MovementManager::PerformMovement (0x00524440 case 6,
decomp 300628-300648): when ACE broadcasts MoveToObject for the local
player, the local client runs its OWN auto-walk on its OWN body —
heading correction toward the target, run-forward velocity, arrival
detected via the wire's min_distance / distance_to_object predicates.
Implementation:
PlayerMovementController:
+ IsServerAutoWalking property (read-only)
+ BeginServerAutoWalk(destWorld, minDist, objDist, moveTowards)
+ EndServerAutoWalk(reason) // idempotent, logs to [autowalk-end]
// when ACDREAM_PROBE_AUTOWALK is on
+ ApplyAutoWalkOverlay(dt, input) — called at the top of Update.
- User movement key (Forward/Backward/Strafe/Turn) cancels.
- Arrival predicate matches RemoteMoveToDriver / retail.
- Heading steered toward destination at ±20° snap-on-aligned
tolerance / π/2 rad/s rotation rate (same constants the
remote-creature path uses).
- Synthesizes input as Forward+Run; the rest of Update's
MotionInterpreter + body-velocity pipeline runs unchanged.
GameWindow.OnLiveMotionUpdated (local-player branch):
+ when update.MotionState.IsServerControlledMoveTo and MoveToPath
is populated: translate origin to world via RemoteMoveToDriver
.OriginToWorld, call _playerController.BeginServerAutoWalk.
+ when a non-MoveTo motion arrives and auto-walk is active:
EndServerAutoWalk(reason="motion-non-moveto").
+ [autowalk-begin] trace line under ACDREAM_PROBE_AUTOWALK.
The mtRun=0 case from the spec trace is handled implicitly: this
slice doesn't read MoveToRunRate at all — it relies on the existing
input.Run path which uses the player's local InqRunRate (env-var
defaulted to 200). Future slice can layer in mtRun!=0 honor if needed.
Slices 3 (animation cycle source while auto-walking) and 4 (local
pickup animation echo for #64) deferred to follow-up commits.
This commit is contained in:
parent
d82b0648b5
commit
b936ef8b0b
2 changed files with 222 additions and 0 deletions
|
|
@ -218,6 +218,31 @@ public sealed class PlayerMovementController
|
|||
private Vector3 _prevPhysicsPos;
|
||||
private Vector3 _currPhysicsPos;
|
||||
|
||||
// ── B.6 slice 2 (2026-05-14): local-player server-initiated auto-walk ──
|
||||
// When ACE sends a MoveToObject motion for the local player (out-of-range
|
||||
// Use / PickUp triggers ACE's server-side CreateMoveToChain), the wire
|
||||
// payload includes a destination, arrival predicates, and a run rate.
|
||||
// Retail's MovementManager::PerformMovement (0x00524440 case 6) runs a
|
||||
// LOCAL auto-walk in response: heading correction toward the target,
|
||||
// run-forward velocity at the wire's runRate, arrival detection via
|
||||
// MoveToManager::HandleMoveToPosition. Here we keep the active auto-walk
|
||||
// state and inject it into Update() as a synthesized Forward+Run input
|
||||
// so the existing motion-interpreter / body-velocity pipeline runs
|
||||
// unchanged. Spec: docs/superpowers/specs/2026-05-14-phase-b6-design.md.
|
||||
private bool _autoWalkActive;
|
||||
private Vector3 _autoWalkDestination;
|
||||
private float _autoWalkMinDistance;
|
||||
private float _autoWalkDistanceToObject;
|
||||
private bool _autoWalkMoveTowards;
|
||||
|
||||
/// <summary>
|
||||
/// True while a server-initiated auto-walk (MoveToObject inbound) is
|
||||
/// active on the local player. The next <see cref="Update"/> call
|
||||
/// synthesizes Forward+Run input and steers <see cref="Yaw"/> toward
|
||||
/// the destination until arrival or user-input cancellation.
|
||||
/// </summary>
|
||||
public bool IsServerAutoWalking => _autoWalkActive;
|
||||
|
||||
public PlayerMovementController(PhysicsEngine physics)
|
||||
{
|
||||
_physics = physics;
|
||||
|
|
@ -288,6 +313,154 @@ public sealed class PlayerMovementController
|
|||
_motion.apply_current_movement(cancelMoveTo: false, allowJump: false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// B.6 slice 2 (2026-05-14). Install a server-initiated auto-walk
|
||||
/// against this body. <see cref="Update"/> will synthesize
|
||||
/// <c>Forward+Run</c> input and steer <see cref="Yaw"/> toward
|
||||
/// <paramref name="destinationWorld"/> until the body reaches the
|
||||
/// arrival predicate (<c>moveTowards: dist ≤ distanceToObject</c>;
|
||||
/// <c>!moveTowards: dist ≥ minDistance</c>) or the user presses any
|
||||
/// movement key (which auto-cancels).
|
||||
///
|
||||
/// <para>
|
||||
/// Retail reference: <c>MovementManager::PerformMovement</c>
|
||||
/// (<c>0x00524440</c>) case 6 — unpacks the wire's target +
|
||||
/// origin + run rate and calls <c>CPhysicsObj::MoveToObject</c> on
|
||||
/// the local body. We do the equivalent at acdream's altitude:
|
||||
/// hold the destination + thresholds + run rate locally, let the
|
||||
/// existing per-tick motion machinery do the walking, and arrive
|
||||
/// when the horizontal distance hits the threshold.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// The run-rate parameter is the EFFECTIVE rate after the
|
||||
/// <c>mtRun=0</c> fallback chain — the caller (GameWindow) is
|
||||
/// responsible for substituting a non-zero rate when ACE sends 0.0
|
||||
/// on the wire, per the trace finding in the design spec.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public void BeginServerAutoWalk(
|
||||
Vector3 destinationWorld,
|
||||
float minDistance,
|
||||
float distanceToObject,
|
||||
bool moveTowards)
|
||||
{
|
||||
_autoWalkActive = true;
|
||||
_autoWalkDestination = destinationWorld;
|
||||
_autoWalkMinDistance = minDistance;
|
||||
_autoWalkDistanceToObject = distanceToObject;
|
||||
_autoWalkMoveTowards = moveTowards;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// B.6 slice 2 (2026-05-14). Cancel any active server-initiated
|
||||
/// auto-walk. Idempotent. <paramref name="reason"/> is logged when
|
||||
/// <see cref="PhysicsDiagnostics.ProbeAutoWalkEnabled"/> is on so
|
||||
/// the trace shows why the auto-walk ended.
|
||||
/// </summary>
|
||||
public void EndServerAutoWalk(string reason)
|
||||
{
|
||||
if (!_autoWalkActive) return;
|
||||
_autoWalkActive = false;
|
||||
if (PhysicsDiagnostics.ProbeAutoWalkEnabled)
|
||||
Console.WriteLine($"[autowalk-end] reason={reason}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// B.6 slice 2 (2026-05-14). If a server-initiated auto-walk is
|
||||
/// active, either cancel it (user pressed a movement key) or
|
||||
/// synthesize a Forward+Run input with <see cref="Yaw"/> stepped
|
||||
/// toward the destination. Returns the (possibly modified) input
|
||||
/// for the rest of <see cref="Update"/> to consume.
|
||||
///
|
||||
/// <para>
|
||||
/// Heading correction matches <see cref="RemoteMoveToDriver.Drive"/>
|
||||
/// — ±<see cref="RemoteMoveToDriver.HeadingSnapToleranceRad"/>
|
||||
/// snap-on-aligned, otherwise rotate at
|
||||
/// <see cref="RemoteMoveToDriver.TurnRateRadPerSec"/>. Arrival
|
||||
/// predicate matches retail's
|
||||
/// <c>MoveToManager::HandleMoveToPosition</c>: chase arrives at
|
||||
/// <c>distanceToObject</c>; flee arrives at <c>minDistance</c>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private MovementInput ApplyAutoWalkOverlay(float dt, MovementInput input)
|
||||
{
|
||||
if (!_autoWalkActive) return input;
|
||||
|
||||
// User-input cancellation. Any direct movement key takes over.
|
||||
// Mouse-only turning (no movement key) doesn't cancel — the
|
||||
// user might just be looking around mid-walk.
|
||||
bool userOverride = input.Forward || input.Backward
|
||||
|| input.StrafeLeft || input.StrafeRight
|
||||
|| input.TurnLeft || input.TurnRight;
|
||||
if (userOverride)
|
||||
{
|
||||
EndServerAutoWalk("user-input");
|
||||
return input;
|
||||
}
|
||||
|
||||
// Horizontal distance to target — server owns Z, our local body
|
||||
// Z snaps to UpdatePosition broadcasts when ACE sends them.
|
||||
var pos = _body.Position;
|
||||
float dx = _autoWalkDestination.X - pos.X;
|
||||
float dy = _autoWalkDestination.Y - pos.Y;
|
||||
float dist = MathF.Sqrt(dx * dx + dy * dy);
|
||||
|
||||
// Arrival predicate (RemoteMoveToDriver convention; matches retail).
|
||||
float arrivalThreshold = _autoWalkMoveTowards
|
||||
? _autoWalkDistanceToObject
|
||||
: _autoWalkMinDistance;
|
||||
bool arrived =
|
||||
(_autoWalkMoveTowards
|
||||
&& dist <= arrivalThreshold + RemoteMoveToDriver.ArrivalEpsilon)
|
||||
|| (!_autoWalkMoveTowards
|
||||
&& dist >= arrivalThreshold - RemoteMoveToDriver.ArrivalEpsilon);
|
||||
if (arrived)
|
||||
{
|
||||
EndServerAutoWalk("arrived");
|
||||
return input; // falls through to Ready (no Forward) → body stops
|
||||
}
|
||||
|
||||
// Step Yaw toward target. Convention from Update line 364:
|
||||
// _body.Orientation = Quaternion.CreateFromAxisAngle(Z, Yaw - π/2),
|
||||
// so local-forward (+Y) maps to world (cos Yaw, sin Yaw, 0).
|
||||
// Therefore Yaw that faces (dx,dy) is atan2(dy, dx).
|
||||
if (dist > 1e-4f)
|
||||
{
|
||||
float desiredYaw = MathF.Atan2(dy, dx);
|
||||
float delta = desiredYaw - Yaw;
|
||||
while (delta > MathF.PI) delta -= 2f * MathF.PI;
|
||||
while (delta < -MathF.PI) delta += 2f * MathF.PI;
|
||||
if (MathF.Abs(delta) <= RemoteMoveToDriver.HeadingSnapToleranceRad)
|
||||
{
|
||||
Yaw = desiredYaw;
|
||||
}
|
||||
else
|
||||
{
|
||||
float maxStep = RemoteMoveToDriver.TurnRateRadPerSec * dt;
|
||||
Yaw += MathF.Sign(delta) * MathF.Min(MathF.Abs(delta), maxStep);
|
||||
}
|
||||
while (Yaw > MathF.PI) Yaw -= 2f * MathF.PI;
|
||||
while (Yaw < -MathF.PI) Yaw += 2f * MathF.PI;
|
||||
}
|
||||
|
||||
// Synthesize "running forward" input. The rest of Update reads
|
||||
// Yaw + input.Forward + input.Run to drive _motion + body
|
||||
// velocity exactly as it does for user-driven W+Shift. We zero
|
||||
// any mouse delta so a stale frame doesn't fight the steering.
|
||||
return input with
|
||||
{
|
||||
Forward = true,
|
||||
Run = true,
|
||||
Backward = false,
|
||||
StrafeLeft = false,
|
||||
StrafeRight = false,
|
||||
TurnLeft = false,
|
||||
TurnRight = false,
|
||||
MouseDeltaX = 0f,
|
||||
};
|
||||
}
|
||||
|
||||
// L.2a slice 1 (2026-05-12): centralized CellId mutation so the
|
||||
// [cell-transit] probe fires from a single chokepoint. Both the
|
||||
// server-snap path (SetPosition) and the per-frame resolver path
|
||||
|
|
@ -329,6 +502,15 @@ public sealed class PlayerMovementController
|
|||
|
||||
public MovementResult Update(float dt, MovementInput input)
|
||||
{
|
||||
// B.6 slice 2 (2026-05-14): server-initiated auto-walk overlay.
|
||||
// When _autoWalkActive, steer Yaw toward _autoWalkDestination and
|
||||
// synthesize Forward+Run input so the rest of Update runs the
|
||||
// body forward as if the user were holding W. User movement-key
|
||||
// input cancels the auto-walk (retail UX). Arrival check fires
|
||||
// before synthesizing, so a one-frame arrival doesn't waste a
|
||||
// tick of velocity past the target.
|
||||
input = ApplyAutoWalkOverlay(dt, input);
|
||||
|
||||
// Portal-space guard: while teleporting, no input is processed and
|
||||
// no physics is resolved. Return a zero-movement result so the caller
|
||||
// can detect the frozen state (MotionStateChanged = false, no commands).
|
||||
|
|
|
|||
|
|
@ -3313,6 +3313,46 @@ public sealed class GameWindow : IDisposable
|
|||
Console.WriteLine(System.FormattableString.Invariant(
|
||||
$"[autowalk-mt] stance=0x{stance:X4} cmd={cmdHex} mt=0x{update.MotionState.MovementType:X2} isMoveTo={update.MotionState.IsServerControlledMoveTo} moveTowards={update.MotionState.MoveTowards} {pathStr} {spd} {mtsSpd} {mtsRun}"));
|
||||
}
|
||||
|
||||
// B.6 slice 2 (2026-05-14): drive the local player's body
|
||||
// through a server-initiated auto-walk when ACE sends
|
||||
// MoveToObject (movement type 6) — retail-faithful per
|
||||
// MovementManager::PerformMovement 0x00524440 case 6. When
|
||||
// the inbound motion is NOT a MoveTo, cancel any active
|
||||
// auto-walk (server intent changed).
|
||||
if (_playerController is not null)
|
||||
{
|
||||
if (update.MotionState.IsServerControlledMoveTo
|
||||
&& update.MotionState.MoveToPath is { } pathData)
|
||||
{
|
||||
// Translate landblock-local origin → world space.
|
||||
var destWorld = AcDream.Core.Physics.RemoteMoveToDriver
|
||||
.OriginToWorld(
|
||||
pathData.OriginCellId,
|
||||
pathData.OriginX,
|
||||
pathData.OriginY,
|
||||
pathData.OriginZ,
|
||||
_liveCenterX,
|
||||
_liveCenterY);
|
||||
_playerController.BeginServerAutoWalk(
|
||||
destWorld,
|
||||
pathData.MinDistance,
|
||||
pathData.DistanceToObject,
|
||||
update.MotionState.MoveTowards);
|
||||
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled)
|
||||
{
|
||||
Console.WriteLine(System.FormattableString.Invariant(
|
||||
$"[autowalk-begin] dest=({destWorld.X:F2},{destWorld.Y:F2},{destWorld.Z:F2}) minDist={pathData.MinDistance:F2} objDist={pathData.DistanceToObject:F2} towards={update.MotionState.MoveTowards}"));
|
||||
}
|
||||
}
|
||||
else if (_playerController.IsServerAutoWalking)
|
||||
{
|
||||
// A non-MoveTo motion arrived (e.g., Ready, or a
|
||||
// user-input echo) — server's auto-walk intent is
|
||||
// gone; release the local driver.
|
||||
_playerController.EndServerAutoWalk("motion-non-moveto");
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue