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? SidestepSpeed,
float? TurnSpeed); 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> /// <summary>
/// Per-frame player movement controller. Reads input, drives the /// Per-frame player movement controller. Reads input, drives the
/// physics engine, tracks motion state for animation + server messages. /// 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 GravityAccel { get; set; } = 20f;
public float AirControlFactor { get; set; } = 0.2f; 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 float Yaw { get; set; }
public Vector3 Position { get; private set; } public Vector3 Position { get; private set; }
public uint CellId { get; private set; } public uint CellId { get; private set; }
@ -87,6 +104,24 @@ public sealed class PlayerMovementController
public MovementResult Update(float dt, MovementInput input) 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. // 1. Apply turning from keyboard + mouse.
if (input.TurnRight) if (input.TurnRight)
Yaw -= TurnSpeed * dt; Yaw -= TurnSpeed * dt;

View file

@ -424,6 +424,7 @@ public sealed class GameWindow : IDisposable
_liveSession.EntitySpawned += OnLiveEntitySpawned; _liveSession.EntitySpawned += OnLiveEntitySpawned;
_liveSession.MotionUpdated += OnLiveMotionUpdated; _liveSession.MotionUpdated += OnLiveMotionUpdated;
_liveSession.PositionUpdated += OnLivePositionUpdated; _liveSession.PositionUpdated += OnLivePositionUpdated;
_liveSession.TeleportStarted += OnTeleportStarted;
_liveSession.Connect(user, pass); _liveSession.Connect(user, pass);
if (_liveSession.Characters is null || _liveSession.Characters.Characters.Count == 0) if (_liveSession.Characters is null || _liveSession.Characters.Characters.Count == 0)
@ -928,6 +929,13 @@ public sealed class GameWindow : IDisposable
/// landblock-local position into acdream world space (same math as /// landblock-local position into acdream world space (same math as
/// CreateObject hydration) and update the entity's Position/Rotation /// CreateObject hydration) and update the entity's Position/Rotation
/// in place so the next Draw picks up the new transform. /// in place so the next Draw picks up the new transform.
///
/// Phase B.3 extension: if the player controller is in PortalSpace and
/// this update is for our own character, detect a large position change
/// (different landblock or > 100 units distance). If detected, recenter
/// the streaming controller, resolve the new position through physics,
/// snap the player entity + controller, and return to InWorld. Also sends
/// LoginComplete so the server knows the client has loaded the destination.
/// </summary> /// </summary>
private void OnLivePositionUpdated(AcDream.Core.Net.WorldSession.EntityPositionUpdate update) private void OnLivePositionUpdated(AcDream.Core.Net.WorldSession.EntityPositionUpdate update)
{ {
@ -950,6 +958,79 @@ public sealed class GameWindow : IDisposable
entity.Position = worldPos; entity.Position = worldPos;
entity.Rotation = rot; entity.Rotation = rot;
// Phase B.3: portal-space arrival detection.
// Only runs for our own player character while in PortalSpace.
if (_playerController is not null
&& _playerController.State == AcDream.App.Input.PlayerState.PortalSpace
&& update.Guid == _playerServerGuid)
{
// Compute old landblock coords from controller position (using the
// current streaming origin as the reference center).
var oldPos = _playerController.Position;
int oldLbX = _liveCenterX + (int)System.Math.Floor(oldPos.X / 192f);
int oldLbY = _liveCenterY + (int)System.Math.Floor(oldPos.Y / 192f);
bool differentLandblock = (lbX != oldLbX || lbY != oldLbY);
bool farAway = System.Numerics.Vector3.Distance(worldPos, oldPos) > 100f;
if (differentLandblock || farAway)
{
Console.WriteLine(
$"live: teleport arrival — old lb=({oldLbX},{oldLbY}) " +
$"new lb=({lbX},{lbY}) dist={System.Numerics.Vector3.Distance(worldPos, oldPos):F1}");
// 1. Recenter the streaming controller on the new landblock.
_liveCenterX = lbX;
_liveCenterY = lbY;
// Recompute worldPos with new center (it becomes local-to-center).
// After recentering, the new position is (p.PositionX, p.PositionY, p.PositionZ)
// relative to the new origin — which maps to world-space (0,0,0) + local offset.
// The streamingController.Tick will pick up _liveCenterX/_liveCenterY automatically.
var newWorldPos = new System.Numerics.Vector3(p.PositionX, p.PositionY, p.PositionZ);
// (after recentering, origin is (0,0,0) since lb == center)
// 2. Resolve through physics for the correct ground Z.
uint newCellId = p.LandblockId;
var resolved = _physicsEngine.Resolve(
newWorldPos, newCellId,
System.Numerics.Vector3.Zero, _playerController.StepUpHeight);
var snappedPos = new System.Numerics.Vector3(
resolved.Position.X, resolved.Position.Y, resolved.Position.Z);
// 3. Snap player entity + controller.
entity.Position = snappedPos;
entity.Rotation = rot;
_playerController.SetPosition(snappedPos, resolved.CellId);
// 4. Return to InWorld.
_playerController.State = AcDream.App.Input.PlayerState.InWorld;
Console.WriteLine($"live: teleport complete — snapped to {snappedPos} cell=0x{resolved.CellId:X8}");
// 5. Send LoginComplete to tell the server the client finished loading.
// Per holtburger's PlayerTeleport handler (client/messages.rs:434-440),
// retail clients call send_login_complete() after each portal transition.
// ResetLoginComplete() clears the latch so the 0xF746 PlayerCreate path
// doesn't also send one. We send directly here instead.
_liveSession?.SendGameAction(
AcDream.Core.Net.Messages.GameActionLoginComplete.Build());
}
}
}
/// <summary>
/// Phase B.3: fires when the server sends a PlayerTeleport (0xF751).
/// Freeze movement input by setting the player controller to PortalSpace.
/// The controller's Update() will return a zero-movement result until the
/// destination UpdatePosition arrives and OnLivePositionUpdated resets the
/// state to InWorld.
/// </summary>
private void OnTeleportStarted(uint sequence)
{
if (_playerController is not null)
_playerController.State = AcDream.App.Input.PlayerState.PortalSpace;
Console.WriteLine($"live: teleport started (seq={sequence})");
} }
/// <summary> /// <summary>

View file

@ -92,6 +92,26 @@ public sealed class WorldSession : IDisposable
/// </summary> /// </summary>
public event Action<EntityPositionUpdate>? PositionUpdated; public event Action<EntityPositionUpdate>? PositionUpdated;
/// <summary>
/// Fires when the server sends a PlayerTeleport (0xF751) game message,
/// signalling that the player is entering portal space. The uint payload
/// is the teleport sequence number parsed from the message body (u16,
/// aligned to 4 bytes — per holtburger's teleport.rs wire layout).
/// Subscribers should freeze movement input until the destination
/// UpdatePosition arrives.
/// </summary>
public event Action<uint>? TeleportStarted;
/// <summary>
/// Allow re-sending LoginComplete after a portal teleport. The normal
/// _loginCompleteSent latch prevents duplicate sends on the initial spawn
/// path; this method resets it so the teleport completion path can send
/// another LoginComplete to tell the server the client has finished loading
/// the destination cell. Pattern from holtburger's PlayerTeleport handler
/// (client/messages.rs line 434-440: call send_login_complete on teleport).
/// </summary>
public void ResetLoginComplete() => _loginCompleteSent = false;
/// <summary>Raised every time the state machine transitions.</summary> /// <summary>Raised every time the state machine transitions.</summary>
public event Action<State>? StateChanged; public event Action<State>? StateChanged;
@ -426,6 +446,23 @@ public sealed class WorldSession : IDisposable
posUpdate.Value.Velocity)); posUpdate.Value.Velocity));
} }
} }
else if (op == 0xF751u) // PlayerTeleport — server is moving us through a portal
{
// Phase B.3: holtburger opcodes.rs confirms 0xF751 is the
// PlayerTeleport standalone GameMessage (NOT wrapped in 0xF7B0).
// Wire layout (teleport.rs): u16 teleport_sequence, then
// aligned to 4 bytes. Per holtburger's client handler, the
// correct response is send_login_complete() at the destination.
// Here we fire TeleportStarted so GameWindow can freeze
// movement; the LoginComplete is sent from GameWindow once
// the destination UpdatePosition is received and the player
// has been snapped to the new cell.
uint sequence = 0;
if (body.Length >= 6)
sequence = System.Buffers.Binary.BinaryPrimitives.ReadUInt16LittleEndian(
body.AsSpan(4, 2));
TeleportStarted?.Invoke(sequence);
}
} }
} }