feat(net+app): Phase B.3 — portal-space state machine for teleports

PlayerTeleport (0xF751) is a standalone GameMessage (u16 sequence,
align-4). When received, WorldSession fires TeleportStarted(uint sequence).

GameWindow subscribes: OnTeleportStarted sets PlayerMovementController.State
= PortalSpace, freezing all WASD/physics input. OnLivePositionUpdated
detects arrival (different landblock or >100 unit jump on our character guid),
recenters the streaming origin, resolves physics for ground Z, snaps the
player entity + controller, returns State to InWorld, and sends
GameActionLoginComplete directly (matching holtburger's PlayerTeleport
handler: send_login_complete on every portal transition).

PlayerMovementController gains PlayerState enum + early-return guard: if
State == PortalSpace, Update() returns a zero-movement result immediately
so no MoveToState / AutonomousPosition messages are emitted during transit.

WorldSession gains ResetLoginComplete() for callers that need to re-arm
the latch (documented; not called by the teleport path since we send
LoginComplete directly rather than through the PlayerCreate latch).

Opcode source: holtburger/crates/holtburger-protocol/src/opcodes.rs:84
Wire layout: holtburger/crates/.../movement/messages/teleport.rs

Build: 0 errors. Tests: 283 passed, 0 failed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-12 18:32:41 +02:00
parent 777893783a
commit ae06f9c0ff
3 changed files with 153 additions and 0 deletions

View file

@ -33,6 +33,14 @@ public readonly record struct MovementResult(
float? SidestepSpeed,
float? TurnSpeed);
/// <summary>
/// Portal-space state for the player movement controller.
/// PortalSpace freezes all movement input while the server is moving the
/// player through a portal — resumed once the destination UpdatePosition
/// arrives and the player is snapped to the new location.
/// </summary>
public enum PlayerState { InWorld, PortalSpace }
/// <summary>
/// Per-frame player movement controller. Reads input, drives the
/// physics engine, tracks motion state for animation + server messages.
@ -60,6 +68,15 @@ public sealed class PlayerMovementController
public float GravityAccel { get; set; } = 20f;
public float AirControlFactor { get; set; } = 0.2f;
/// <summary>
/// Current portal-space state. Set to PortalSpace when the server sends
/// PlayerTeleport (0xF751); set back to InWorld once the destination
/// UpdatePosition arrives and the player is snapped to the new cell.
/// While in PortalSpace, Update returns immediately with a zero-movement
/// result so no WASD input or physics is processed.
/// </summary>
public PlayerState State { get; set; } = PlayerState.InWorld;
public float Yaw { get; set; }
public Vector3 Position { get; private set; }
public uint CellId { get; private set; }
@ -87,6 +104,24 @@ public sealed class PlayerMovementController
public MovementResult Update(float dt, MovementInput 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).
if (State == PlayerState.PortalSpace)
{
return new MovementResult(
Position: Position,
CellId: CellId,
IsOnGround: !IsAirborne,
MotionStateChanged: false,
ForwardCommand: null,
SidestepCommand: null,
TurnCommand: null,
ForwardSpeed: null,
SidestepSpeed: null,
TurnSpeed: null);
}
// 1. Apply turning from keyboard + mouse.
if (input.TurnRight)
Yaw -= TurnSpeed * dt;