fix(app): Phase B.2 — use server position directly, fix yaw wrap + turn spam

Three more fixes from the diagnostic dump:

1. Initial position: PhysicsEngine.Resolve was mapping the player
   into an indoor EnvCell (foundry at Z=66) when they're standing
   on outdoor terrain at Z=93+. The cell-containment check was too
   aggressive for initial placement. Now uses the server-sent
   position directly — the server already gave us a valid position.

2. Yaw unbounded: mouse delta accumulated without wrapping, growing
   to 24+ radians. Now wraps to [-PI, PI] after every turn.

3. Turn command spam: MouseDeltaX > 0.5 threshold was too low for
   raw pixel deltas. Any mouse jitter triggered turnCmd flips every
   frame → stateChanged=True → MoveToState flood to the server.
   Mouse turning now only affects yaw directly; turn COMMANDS only
   come from A/D keyboard (matching retail client behavior where
   mouse-look doesn't generate a TurnRight/TurnLeft command).

265 tests still green.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-12 14:58:25 +02:00
parent 6202c5d153
commit 97c17c5bc3
3 changed files with 39 additions and 8 deletions

View file

@ -79,6 +79,10 @@ public sealed class PlayerMovementController
if (input.TurnLeft)
Yaw += TurnSpeed * dt;
Yaw -= input.MouseDeltaX * MouseTurnSensitivity;
// Wrap yaw to [-PI, PI] so it doesn't grow unbounded and cause
// NaN/overflow in sin/cos and confuse the chase camera offset.
while (Yaw > MathF.PI) Yaw -= 2f * MathF.PI;
while (Yaw < -MathF.PI) Yaw += 2f * MathF.PI;
// 2. Compute movement delta in the player's facing direction.
float speed = input.Run ? RunSpeed : WalkSpeed;
@ -130,12 +134,17 @@ public sealed class PlayerMovementController
sidestepSpeed = speed * 0.5f / WalkSpeed;
}
if (input.TurnRight || input.MouseDeltaX > 0.5f)
// Turn commands from KEYBOARD only (A/D). Mouse turning is applied
// directly to Yaw above and doesn't generate a turn command — if it
// did, mouse jitter would flip turnCmd between TurnRight/TurnLeft
// every frame, causing stateChanged=True on every frame and flooding
// the server with MoveToState spam.
if (input.TurnRight)
{
turnCmd = 0x6500000Du; // TurnRight
turnSpeed = TurnSpeed;
}
else if (input.TurnLeft || input.MouseDeltaX < -0.5f)
else if (input.TurnLeft)
{
turnCmd = 0x6500000Eu; // TurnLeft
turnSpeed = TurnSpeed;

View file

@ -203,12 +203,15 @@ public sealed class GameWindow : IDisposable
float plocalX = playerEntity.Position.X - (plbX - _liveCenterX) * 192f;
float plocalY = playerEntity.Position.Y - (plbY - _liveCenterY) * 192f;
uint pinitCellId = ((uint)plbX << 24) | ((uint)plbY << 16) | 0x0001u;
// Let the physics engine resolve the correct Z for the initial position
// so we don't start underground.
var initResult = _physicsEngine.Resolve(
playerEntity.Position, pinitCellId,
System.Numerics.Vector3.Zero, 10f);
_playerController.SetPosition(initResult.Position, initResult.CellId);
// Use the server-sent position directly — the server already
// gave us a valid position at CreateObject time. Running it
// through PhysicsEngine.Resolve was mapping the player into
// an indoor cell (e.g., the foundry at Z=66) when they're
// actually standing on outdoor terrain at Z=93+. The physics
// engine's cell-containment check is too aggressive for the
// initial placement. Once the player starts moving, Resolve
// will keep them on the correct surface.
_playerController.SetPosition(playerEntity.Position, pinitCellId & 0xFFFFu);
// Derive initial yaw from the entity's server-sent rotation
// rather than hardcoding. Extract yaw from the quaternion.
var q = playerEntity.Rotation;
@ -216,6 +219,12 @@ public sealed class GameWindow : IDisposable
2f * (q.W * q.Z + q.X * q.Y),
1f - 2f * (q.Y * q.Y + q.Z * q.Z));
_playerController.Yaw = yaw;
Console.WriteLine($"[PLAYER-INIT] entityPos=({playerEntity.Position.X:F1},{playerEntity.Position.Y:F1},{playerEntity.Position.Z:F1}) " +
$"entityRot=({q.X:F3},{q.Y:F3},{q.Z:F3},{q.W:F3}) " +
$"initCellId=0x{(pinitCellId & 0xFFFFu):X4} " +
$"yaw={yaw:F3} " +
$"physics landblocks={_physicsEngine.LandblockCount}");
_chaseCamera = new AcDream.App.Rendering.ChaseCamera
{
Aspect = _window!.Size.X / (float)_window.Size.Y,
@ -1489,6 +1498,16 @@ public sealed class GameWindow : IDisposable
var result = _playerController.Update((float)dt, input);
// DIAG: dump player state every ~60 frames to see what's happening.
if (_perfFrameCount % 60 == 0)
{
Console.WriteLine($"[PLAYER] pos=({result.Position.X:F1},{result.Position.Y:F1},{result.Position.Z:F1}) " +
$"cell=0x{result.CellId:X8} ground={result.IsOnGround} " +
$"yaw={_playerController.Yaw:F2} " +
$"fwdCmd={result.ForwardCommand?.ToString("X8") ?? "idle"} " +
$"stateChanged={result.MotionStateChanged}");
}
// Update the player entity's position + rotation so it renders at
// the physics-resolved location each frame.
if (_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var pe))

View file

@ -21,6 +21,9 @@ public sealed class PhysicsEngine
{
private readonly Dictionary<uint, LandblockPhysics> _landblocks = new();
/// <summary>Number of registered landblocks (diagnostic).</summary>
public int LandblockCount => _landblocks.Count;
private sealed record LandblockPhysics(
TerrainSurface Terrain,
IReadOnlyList<CellSurface> Cells,