feat(G.3a): hold teleport arrival until dungeon hydrates, then place (#133)
Replaces the unconditional OnLivePositionUpdated snap (which resolved against the resident old landblocks before the destination streamed in -> ocean) with a recenter + deferred BeginArrival; per-frame Tick places via the unchanged #111 validated-claim Resolve once SampleTerrainZ + IsSpawnCellReady report ready, or force-snaps loudly on an impossible claim / ~10s timeout. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
aca4b4645a
commit
f22121bd7d
1 changed files with 106 additions and 69 deletions
|
|
@ -4874,7 +4874,7 @@ public sealed class GameWindow : IDisposable
|
||||||
entity.Rotation = rmState.Body.Orientation;
|
entity.Rotation = rmState.Body.Orientation;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase B.3: portal-space arrival detection.
|
// Phase B.3 / G.3a (#133): portal-space arrival detection.
|
||||||
// Only runs for our own player character while in PortalSpace.
|
// Only runs for our own player character while in PortalSpace.
|
||||||
if (_playerController is not null
|
if (_playerController is not null
|
||||||
&& _playerController.State == AcDream.App.Input.PlayerState.PortalSpace
|
&& _playerController.State == AcDream.App.Input.PlayerState.PortalSpace
|
||||||
|
|
@ -4888,79 +4888,109 @@ public sealed class GameWindow : IDisposable
|
||||||
|
|
||||||
bool differentLandblock = (lbX != oldLbX || lbY != oldLbY);
|
bool differentLandblock = (lbX != oldLbX || lbY != oldLbY);
|
||||||
|
|
||||||
// #107 (2026-06-10): ANY player position update while in PortalSpace
|
Console.WriteLine(
|
||||||
// IS the teleport arrival. Retail/holtburger exit portal space on the
|
$"live: teleport arrival — old lb=({oldLbX},{oldLbY}) " +
|
||||||
// next position event unconditionally (holtburger messages.rs
|
$"new lb=({lbX},{lbY}) dist={System.Numerics.Vector3.Distance(worldPos, oldPos):F1}");
|
||||||
// PlayerTeleport handler: log + LoginComplete; the destination applies
|
|
||||||
// through the normal position flow — no distance test). The old
|
System.Numerics.Vector3 newWorldPos;
|
||||||
// `differentLandblock || farAway(>100m)` arrival gate was an
|
if (differentLandblock)
|
||||||
// invention: ACE's same-landblock short-hop position corrections
|
|
||||||
// (e.g. right after an indoor login) matched neither condition, so
|
|
||||||
// PortalSpace never exited and movement input stayed frozen for the
|
|
||||||
// whole session (the #107 "input ignored" wedge shape —
|
|
||||||
// flood-fix-gate2.log: `teleport started (seq=1)` with no arrival).
|
|
||||||
{
|
{
|
||||||
Console.WriteLine(
|
// Recenter the streaming controller on the new landblock NOW (kick
|
||||||
$"live: teleport arrival — old lb=({oldLbX},{oldLbY}) " +
|
// off the dungeon load). After recentering, the destination is
|
||||||
$"new lb=({lbX},{lbY}) dist={System.Numerics.Vector3.Distance(worldPos, oldPos):F1}");
|
// (p.PositionX, p.PositionY, p.PositionZ) relative to the new origin.
|
||||||
|
_liveCenterX = lbX;
|
||||||
System.Numerics.Vector3 newWorldPos;
|
_liveCenterY = lbY;
|
||||||
if (differentLandblock)
|
newWorldPos = new System.Numerics.Vector3(p.PositionX, p.PositionY, p.PositionZ);
|
||||||
{
|
|
||||||
// 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.
|
|
||||||
newWorldPos = new System.Numerics.Vector3(p.PositionX, p.PositionY, p.PositionZ);
|
|
||||||
// (after recentering, origin is (0,0,0) since lb == center)
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// Same landblock: worldPos is already in the current center frame.
|
|
||||||
newWorldPos = worldPos;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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.SetPosition(snappedPos);
|
|
||||||
entity.ParentCellId = resolved.CellId;
|
|
||||||
entity.Rotation = rot;
|
|
||||||
_playerController.SetPosition(snappedPos, resolved.CellId);
|
|
||||||
|
|
||||||
// 4. Recenter chase camera on the new position.
|
|
||||||
_chaseCamera?.Update(snappedPos, _playerController.Yaw);
|
|
||||||
_retailChaseCamera?.Update(snappedPos, _playerController.Yaw,
|
|
||||||
playerVelocity: System.Numerics.Vector3.Zero,
|
|
||||||
isOnGround: true,
|
|
||||||
contactPlaneNormal: System.Numerics.Vector3.UnitZ,
|
|
||||||
dt: 1f / 60f);
|
|
||||||
|
|
||||||
// 5. 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());
|
|
||||||
}
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
newWorldPos = worldPos;
|
||||||
|
}
|
||||||
|
|
||||||
|
// G.3a: do NOT snap here. The destination dungeon landblock has not
|
||||||
|
// streamed in yet; an immediate Resolve falls back to the resident
|
||||||
|
// (old) landblocks and lands the player in ocean (#133). HOLD the snap
|
||||||
|
// in portal space — TeleportArrivalController.Tick (per frame) places
|
||||||
|
// the player via PlaceTeleportArrival once the destination cell
|
||||||
|
// hydrates (TeleportArrivalReadiness == Ready), or force-places on an
|
||||||
|
// impossible claim / timeout. PortalSpace keeps input frozen meanwhile.
|
||||||
|
EnsureTeleportArrivalController();
|
||||||
|
_pendingTeleportRot = rot;
|
||||||
|
_teleportArrival!.BeginArrival(newWorldPos, p.LandblockId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// G.3a (#133): holds a teleport arrival in portal space until the destination
|
||||||
|
// dungeon landblock/cell has hydrated, then places the player via the unchanged
|
||||||
|
// validated-claim Resolve path. Lazily constructed on the first teleport (all
|
||||||
|
// runtime deps are wired by then).
|
||||||
|
private AcDream.App.World.TeleportArrivalController? _teleportArrival;
|
||||||
|
private System.Numerics.Quaternion _pendingTeleportRot = System.Numerics.Quaternion.Identity;
|
||||||
|
|
||||||
|
private void EnsureTeleportArrivalController()
|
||||||
|
{
|
||||||
|
if (_teleportArrival is not null) return;
|
||||||
|
_teleportArrival = new AcDream.App.World.TeleportArrivalController(
|
||||||
|
readiness: TeleportArrivalReadiness,
|
||||||
|
place: PlaceTeleportArrival);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reuses the #107 login readiness triplet (GameWindow.cs:1010-1024), evaluated
|
||||||
|
// against the teleport's (destPos, destCell): an impossible indoor claim short-
|
||||||
|
// circuits to immediate placement; otherwise hold until terrain is sampled and,
|
||||||
|
// for an indoor cell, the cell struct has hydrated.
|
||||||
|
private AcDream.App.World.ArrivalReadiness TeleportArrivalReadiness(
|
||||||
|
System.Numerics.Vector3 destPos, uint destCell)
|
||||||
|
{
|
||||||
|
if (IsSpawnClaimUnhydratable(destCell))
|
||||||
|
return AcDream.App.World.ArrivalReadiness.Impossible;
|
||||||
|
if (_physicsEngine.SampleTerrainZ(destPos.X, destPos.Y) is null)
|
||||||
|
return AcDream.App.World.ArrivalReadiness.NotReady;
|
||||||
|
bool indoor = (destCell & 0xFFFFu) >= 0x0100u;
|
||||||
|
if (indoor && !_physicsEngine.IsSpawnCellReady(destCell))
|
||||||
|
return AcDream.App.World.ArrivalReadiness.NotReady;
|
||||||
|
return AcDream.App.World.ArrivalReadiness.Ready;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The deferred snap (the original OnLivePositionUpdated steps 2-5), now run only
|
||||||
|
// once the destination is ready (or force-run on impossible/timeout, logged loud).
|
||||||
|
private void PlaceTeleportArrival(
|
||||||
|
System.Numerics.Vector3 destPos, uint destCell, bool forced)
|
||||||
|
{
|
||||||
|
var resolved = _physicsEngine.Resolve(
|
||||||
|
destPos, destCell, System.Numerics.Vector3.Zero, _playerController!.StepUpHeight);
|
||||||
|
var snappedPos = new System.Numerics.Vector3(
|
||||||
|
resolved.Position.X, resolved.Position.Y, resolved.Position.Z);
|
||||||
|
|
||||||
|
if (forced)
|
||||||
|
Console.WriteLine(
|
||||||
|
$"live: teleport HOLD gave up (impossible/timeout) — force-snapping " +
|
||||||
|
$"cell=0x{destCell:X8} pos={destPos} -> 0x{resolved.CellId:X8} {snappedPos}");
|
||||||
|
|
||||||
|
if (_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var pe))
|
||||||
|
{
|
||||||
|
pe.SetPosition(snappedPos);
|
||||||
|
pe.ParentCellId = resolved.CellId;
|
||||||
|
pe.Rotation = _pendingTeleportRot;
|
||||||
|
}
|
||||||
|
_playerController.SetPosition(snappedPos, resolved.CellId);
|
||||||
|
|
||||||
|
_chaseCamera?.Update(snappedPos, _playerController.Yaw);
|
||||||
|
_retailChaseCamera?.Update(snappedPos, _playerController.Yaw,
|
||||||
|
playerVelocity: System.Numerics.Vector3.Zero,
|
||||||
|
isOnGround: true,
|
||||||
|
contactPlaneNormal: System.Numerics.Vector3.UnitZ,
|
||||||
|
dt: 1f / 60f);
|
||||||
|
|
||||||
|
_playerController.State = AcDream.App.Input.PlayerState.InWorld;
|
||||||
|
Console.WriteLine($"live: teleport complete — snapped to {snappedPos} cell=0x{resolved.CellId:X8}");
|
||||||
|
|
||||||
|
// Tell the server the client finished loading the new landblock (holtburger
|
||||||
|
// client/messages.rs:434 — re-send LoginComplete after each portal transition).
|
||||||
|
_liveSession?.SendGameAction(
|
||||||
|
AcDream.Core.Net.Messages.GameActionLoginComplete.Build());
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Phase B.3: fires when the server sends a PlayerTeleport (0xF751).
|
/// Phase B.3: fires when the server sends a PlayerTeleport (0xF751).
|
||||||
/// Freeze movement input by setting the player controller to PortalSpace.
|
/// Freeze movement input by setting the player controller to PortalSpace.
|
||||||
|
|
@ -4972,6 +5002,7 @@ public sealed class GameWindow : IDisposable
|
||||||
{
|
{
|
||||||
if (_playerController is not null)
|
if (_playerController is not null)
|
||||||
_playerController.State = AcDream.App.Input.PlayerState.PortalSpace;
|
_playerController.State = AcDream.App.Input.PlayerState.PortalSpace;
|
||||||
|
EnsureTeleportArrivalController();
|
||||||
Console.WriteLine($"live: teleport started (seq={sequence})");
|
Console.WriteLine($"live: teleport started (seq={sequence})");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -6837,6 +6868,12 @@ public sealed class GameWindow : IDisposable
|
||||||
// Step 2: routed through the controller; functionally identical.
|
// Step 2: routed through the controller; functionally identical.
|
||||||
_liveSessionController?.Tick();
|
_liveSessionController?.Tick();
|
||||||
|
|
||||||
|
// G.3a (#133): advance any held teleport arrival. Runs AFTER streaming
|
||||||
|
// (which applies the destination landblock) and the live-session drain
|
||||||
|
// (which may have just called BeginArrival), so a destination that
|
||||||
|
// hydrated this frame is placed the same frame.
|
||||||
|
_teleportArrival?.Tick();
|
||||||
|
|
||||||
// Phase K.1a — tick the input dispatcher so Hold-type bindings
|
// Phase K.1a — tick the input dispatcher so Hold-type bindings
|
||||||
// re-fire while their chord is held. K.1b adds the subscribers
|
// re-fire while their chord is held. K.1b adds the subscribers
|
||||||
// that actually consume the events.
|
// that actually consume the events.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue