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:
parent
777893783a
commit
ae06f9c0ff
3 changed files with 153 additions and 0 deletions
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue