From ae06f9c0ff53235882ffbde9914ec763bbe2b08d Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 12 Apr 2026 18:32:41 +0200 Subject: [PATCH] =?UTF-8?q?feat(net+app):=20Phase=20B.3=20=E2=80=94=20port?= =?UTF-8?q?al-space=20state=20machine=20for=20teleports?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../Input/PlayerMovementController.cs | 35 ++++++++ src/AcDream.App/Rendering/GameWindow.cs | 81 +++++++++++++++++++ src/AcDream.Core.Net/WorldSession.cs | 37 +++++++++ 3 files changed, 153 insertions(+) diff --git a/src/AcDream.App/Input/PlayerMovementController.cs b/src/AcDream.App/Input/PlayerMovementController.cs index 01feaf4..db3e938 100644 --- a/src/AcDream.App/Input/PlayerMovementController.cs +++ b/src/AcDream.App/Input/PlayerMovementController.cs @@ -33,6 +33,14 @@ public readonly record struct MovementResult( float? SidestepSpeed, float? TurnSpeed); +/// +/// 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. +/// +public enum PlayerState { InWorld, PortalSpace } + /// /// 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; + /// + /// 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. + /// + 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; diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 59d7dd1..5073673 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -424,6 +424,7 @@ public sealed class GameWindow : IDisposable _liveSession.EntitySpawned += OnLiveEntitySpawned; _liveSession.MotionUpdated += OnLiveMotionUpdated; _liveSession.PositionUpdated += OnLivePositionUpdated; + _liveSession.TeleportStarted += OnTeleportStarted; _liveSession.Connect(user, pass); 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 /// CreateObject hydration) and update the entity's Position/Rotation /// 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. /// private void OnLivePositionUpdated(AcDream.Core.Net.WorldSession.EntityPositionUpdate update) { @@ -950,6 +958,79 @@ public sealed class GameWindow : IDisposable entity.Position = worldPos; 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()); + } + } + } + + /// + /// 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. + /// + private void OnTeleportStarted(uint sequence) + { + if (_playerController is not null) + _playerController.State = AcDream.App.Input.PlayerState.PortalSpace; + Console.WriteLine($"live: teleport started (seq={sequence})"); } /// diff --git a/src/AcDream.Core.Net/WorldSession.cs b/src/AcDream.Core.Net/WorldSession.cs index 9172477..3c34631 100644 --- a/src/AcDream.Core.Net/WorldSession.cs +++ b/src/AcDream.Core.Net/WorldSession.cs @@ -92,6 +92,26 @@ public sealed class WorldSession : IDisposable /// public event Action? PositionUpdated; + /// + /// 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. + /// + public event Action? TeleportStarted; + + /// + /// 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). + /// + public void ResetLoginComplete() => _loginCompleteSent = false; + /// Raised every time the state machine transitions. public event Action? StateChanged; @@ -426,6 +446,23 @@ public sealed class WorldSession : IDisposable 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); + } } }