diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 0111505..d55d87c 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -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 diff --git a/src/AcDream.App/Input/PlayerMovementController.cs b/src/AcDream.App/Input/PlayerMovementController.cs index 0cf7b4b..32ba7ed 100644 --- a/src/AcDream.App/Input/PlayerMovementController.cs +++ b/src/AcDream.App/Input/PlayerMovementController.cs @@ -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 diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index d2d960d..5c562da 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -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). /// /// + /// + /// 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. + /// + 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;