fix(B.6): tighter 5° alignment + defer Use until rotation completes; file #69 turn anim

User report: 'You should be face to face with the NPC before sending
use. So first is rotation, when you are facing, then using.' and
'it does not face it completely.'

Two changes:

1. Split alignment thresholds in ApplyAutoWalkOverlay:
     walkAligned  (30°) — gate for synthesised Forward+Run motion
                          during far-range approach; body walks
                          while finishing residual turn within 30°.
     aligned      (5°)  — gate for arrival-fire. Final facing
                          before the auto-walk ends and the action
                          re-sends. Matches retail's tight pre-Use
                          rotation tolerance.
   Within-arrival check still requires alignment; without alignment
   the body holds in turn-only mode regardless of distance.

2. Defer wire Use/PickUp packet for CLOSE-range targets. SendUse
   and SendPickUp now check IsCloseRangeTarget(guid): if the player
   is already within the target's use-radius, we install the
   speculative overlay, set _pendingPostArrivalAction, and RETURN
   without sending the wire packet. AutoWalkArrived fires after the
   local rotation completes (alignment within 5°); the existing
   re-send handler then fires SendUse with isRetryAfterArrival=true,
   sending the wire packet at that moment. Effect: rotate first,
   THEN Use — the NPC/door/item only sees the action after the
   character has turned to face it.

   Far-range path unchanged: send immediately, ACE auto-walks,
   arrival re-sends.

Filed #69: turn animation (leg/arm cycle while pivoting). The body
now rotates but doesn't play the TurnLeft/TurnRight cycle the user
wants to see. Separate scope — needs motion-interpreter integration.
This commit is contained in:
Erik 2026-05-15 15:15:30 +02:00
parent 5b908bcca2
commit cffb10ff18
3 changed files with 124 additions and 15 deletions

View file

@ -46,6 +46,44 @@ Copy this block when adding a new issue:
# Active issues
## #69 — Local player rotation isn't animated (no leg/arm cycle while pivoting)
**Status:** OPEN
**Severity:** LOW (visual polish — rotation works, just looks stiff)
**Filed:** 2026-05-15 (B.6 close-range turn-to-face)
**Component:** motion / animation cycle
**Description:** When the auto-walk overlay rotates the local player
(close-range Use turn-to-face, or turn-first phase of a far-range walk),
the body's Yaw rotates smoothly but no leg / arm animation plays —
the body just statue-pivots. Retail played a `TurnLeft` / `TurnRight`
motion cycle while rotating, visible to observers as the character
moving their legs / arms to turn.
**Cause:** `ApplyAutoWalkOverlay` synthesises `Forward+Run` input
during the walking phase (so the motion interpreter emits `RunForward`
cycle commands), but synthesises nothing during the turn-only phase
— so the motion interpreter emits no command and the sequencer
holds whatever cycle was last set (typically Ready / idle).
**Approach:** While turning (`!walkAligned`), synthesise
`TurnLeft = delta > 0` / `TurnRight = delta < 0` so the motion
interpreter emits the turn command. Care needed: the existing
`Update` body also steps Yaw on `TurnLeft`/`TurnRight` input — if
both apply, the body rotates twice as fast. Cleanest: set the input
flags AND skip the overlay's own Yaw step (let Update's existing
handling do the rotation).
**Acceptance:** A retail observer watching `+Acdream` turn to face
an NPC sees the turning animation play (leg shuffle / arm swing) for
the duration of the rotation.
**Estimated scope:** Small. ~30 LOC in `ApplyAutoWalkOverlay` plus
verification that retail's `TurnLeft`/`TurnRight` cycle is in the
human motion table.
---
## #68 — Remote players don't stop running animation on auto-walk arrival
**Status:** OPEN

View file

@ -482,7 +482,8 @@ public sealed class PlayerMovementController
// walk-while-turning threshold off, suppress Forward this frame
// so the body turns IN PLACE first. Once we're within the
// threshold, the synthesised Forward+Run kicks in below.
bool aligned = true;
bool aligned = true;
bool walkAligned = true;
if (dist > 1e-4f)
{
float desiredYaw = MathF.Atan2(dy, dx);
@ -501,13 +502,21 @@ public sealed class PlayerMovementController
while (Yaw > MathF.PI) Yaw -= 2f * MathF.PI;
while (Yaw < -MathF.PI) Yaw += 2f * MathF.PI;
// 30° "walk-while-turning" threshold: outside this, body
// turns in place. Inside, body walks forward while finishing
// any remaining alignment. Matches retail-feel observation;
// exact retail value is in MoveToManager but ~30° is a
// sensible heuristic for now.
// Two alignment thresholds:
// walkWhileTurning (30°): outside this, body turns in place.
// Inside, body walks forward while
// finishing residual alignment.
// fullyAligned (5°): the arrival-fire alignment. ACE
// rotates server-side via Rotate(target)
// BEFORE invoking the Use callback —
// user reported 'it does not face it
// completely', so the final-alignment
// check must be tighter than the
// walking gate.
const float WalkWhileTurningRad = 30f * MathF.PI / 180f;
aligned = MathF.Abs(delta) <= WalkWhileTurningRad;
const float FullyAlignedRad = 5f * MathF.PI / 180f;
walkAligned = MathF.Abs(delta) <= WalkWhileTurningRad;
aligned = MathF.Abs(delta) <= FullyAlignedRad;
}
// End the auto-walk once the body is BOTH within use radius
@ -528,11 +537,12 @@ public sealed class PlayerMovementController
// all the way to the object and then stop").
bool shouldRun = _autoWalkInitiallyRunning;
// Turn-first gate: if not yet aligned with the target, suppress
// forward motion so the body turns in place rather than
// walking an arc. Also suppress when already within arrival —
// we just turned to face it; no need to step forward into it.
bool moveForward = aligned && !withinArrival;
// Turn-first gate: if not yet within the 30° walking band,
// suppress forward motion so the body turns in place rather
// than walking an arc. Also suppress when already within
// arrival — we just turned to face it; no need to step forward
// into it.
bool moveForward = walkAligned && !withinArrival;
// Synthesize "moving forward" input. The rest of Update reads
// Yaw + input.Forward + input.Run to drive _motion + body

View file

@ -9121,8 +9121,25 @@ public sealed class GameWindow : IDisposable
// overlay provides the matching local rotation. Either way the
// alignment-gated arrival ensures the body finishes facing
// the target before stopping.
//
// User feedback (2026-05-15): 'first is rotation, when you are
// facing, then using.' For close-range we DEFER the wire packet
// until our local overlay arrives (turn-then-fire). The
// _pendingPostArrivalAction handler will re-fire SendUse with
// isRetryAfterArrival=true after the body finishes turning.
// For far range we still send immediately so ACE can start
// its MoveToChain.
if (!isRetryAfterArrival)
{
InstallSpeculativeTurnToTarget(guid);
_pendingPostArrivalAction = (guid, false);
if (IsCloseRangeTarget(guid))
{
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled)
Console.WriteLine($"[B.4b] use deferred (close-range, turn-first) guid=0x{guid:X8}");
return; // wait for AutoWalkArrived to fire the wire send
}
}
var seq = _liveSession.NextGameActionSequence();
var body = AcDream.Core.Net.Messages.InteractRequests.BuildUse(seq, guid);
@ -9148,11 +9165,21 @@ public sealed class GameWindow : IDisposable
_debugVm?.AddToast("Not in world");
return;
}
// B.6 (2026-05-15): same speculative turn-to-target install as
// SendUse — close-range pickup still rotates locally to face
// the item before the pickup resolves server-side.
// B.6 (2026-05-15): same speculative turn-to-target + deferral as
// SendUse — close-range pickup rotates locally to face the
// item first, then the wire packet fires when the local
// overlay reports arrival.
if (!isRetryAfterArrival)
{
InstallSpeculativeTurnToTarget(itemGuid);
_pendingPostArrivalAction = (itemGuid, true);
if (IsCloseRangeTarget(itemGuid))
{
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled)
Console.WriteLine($"[B.5] pickup deferred (close-range, turn-first) item=0x{itemGuid:X8}");
return;
}
}
// B.5 polish (2026-05-14): silently block client-side when the
// selected entity is a creature/NPC. ACE's
@ -9235,6 +9262,40 @@ public sealed class GameWindow : IDisposable
/// 0.6 m for everything else (ground items).
/// </para>
/// </summary>
/// <summary>
/// B.6 (2026-05-15). True if the local player is already inside the
/// target's use radius right now — i.e. ACE will NOT auto-walk us
/// when we send the action. Used to gate the close-range deferral
/// in SendUse / SendPickUp: when close, we hold the wire packet
/// until our local rotation overlay reports alignment, then fire.
/// </summary>
private bool IsCloseRangeTarget(uint targetGuid)
{
if (_playerController is null) return false;
if (!_entitiesByServerGuid.TryGetValue(targetGuid, out var entity))
return false;
// Mirror InstallSpeculativeTurnToTarget's per-type radius heuristic.
float useRadius = 0.6f;
if (_liveEntityInfoByGuid.TryGetValue(targetGuid, out var info)
&& (info.ItemType & AcDream.Core.Items.ItemType.Creature) != 0)
{
useRadius = 3.0f;
}
else if (_lastSpawnByGuid.TryGetValue(targetGuid, out var spawn)
&& spawn.ObjectDescriptionFlags is { } odf)
{
const uint LargeFlatMask = 0x1000u | 0x4000u | 0x40000u | 0x2000u;
if ((odf & LargeFlatMask) != 0) useRadius = 2.0f;
}
var bodyPos = _playerController.Position;
float dx = entity.Position.X - bodyPos.X;
float dy = entity.Position.Y - bodyPos.Y;
float distSq = dx * dx + dy * dy;
return distSq <= useRadius * useRadius;
}
private void InstallSpeculativeTurnToTarget(uint targetGuid)
{
if (_playerController is null) return;