refactor(runtime): extract local teleport and player mode
Move local teleport, player-mode, animation, shadow, and sealed-dungeon lifetimes out of GameWindow. Make player-mode entry transactional and isolate approach completions by controller lifetime and approach generation so stale callbacks cannot affect replacements. Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
parent
c557038353
commit
eeb0f6b45c
29 changed files with 3311 additions and 1073 deletions
|
|
@ -31,7 +31,9 @@ internal sealed class CombatAttackInputFrameAdapter : ICombatInputFrameControlle
|
|||
/// Owns the complete pre-object input frame: held semantic dispatch, one raw
|
||||
/// mouse-look sample, and combat attack intent.
|
||||
/// </summary>
|
||||
internal sealed class GameplayInputFrameController : IGameplayInputFramePhase
|
||||
internal sealed class GameplayInputFrameController
|
||||
: IGameplayInputFramePhase,
|
||||
AcDream.App.Streaming.ILocalPlayerTeleportInputLifetime
|
||||
{
|
||||
private readonly InputDispatcher? _dispatcher;
|
||||
private readonly DispatcherMovementInputSource _movement;
|
||||
|
|
|
|||
75
src/AcDream.App/Input/LocalPlayerAnimationController.cs
Normal file
75
src/AcDream.App/Input/LocalPlayerAnimationController.cs
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
using AcDream.App.Rendering;
|
||||
using AcDream.App.Rendering.Vfx;
|
||||
using AcDream.App.World;
|
||||
using AcDream.Core.Physics.Motion;
|
||||
using DatReaderWriter.Types;
|
||||
|
||||
namespace AcDream.App.Input;
|
||||
|
||||
/// <summary>
|
||||
/// Owns the local player's sequencer root-motion and deferred-hook seams.
|
||||
/// The movement controller borrows these methods when player mode is entered;
|
||||
/// no animation lifetime is stored in the window host.
|
||||
/// </summary>
|
||||
internal sealed class LocalPlayerAnimationController
|
||||
{
|
||||
private readonly LiveEntityRuntime _liveEntities;
|
||||
private readonly ILocalPlayerIdentitySource _identity;
|
||||
private readonly LiveEntityAnimationRuntimeView<LiveEntityAnimationState> _animations;
|
||||
private readonly LiveEntityAnimationPresenter _presenter;
|
||||
private readonly AnimationHookFrameQueue _hooks;
|
||||
|
||||
public LocalPlayerAnimationController(
|
||||
LiveEntityRuntime liveEntities,
|
||||
ILocalPlayerIdentitySource identity,
|
||||
LiveEntityAnimationRuntimeView<LiveEntityAnimationState> animations,
|
||||
LiveEntityAnimationPresenter presenter,
|
||||
AnimationHookFrameQueue hooks)
|
||||
{
|
||||
_liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities));
|
||||
_identity = identity ?? throw new ArgumentNullException(nameof(identity));
|
||||
_animations = animations ?? throw new ArgumentNullException(nameof(animations));
|
||||
_presenter = presenter ?? throw new ArgumentNullException(nameof(presenter));
|
||||
_hooks = hooks ?? throw new ArgumentNullException(nameof(hooks));
|
||||
}
|
||||
|
||||
public void AdvanceRoot(float deltaSeconds, MotionDeltaFrame output)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(output);
|
||||
output.Reset();
|
||||
|
||||
uint playerGuid = _identity.ServerGuid;
|
||||
if (!_liveEntities.MaterializedWorldEntities.TryGetValue(playerGuid, out var entity)
|
||||
|| !_animations.TryGetValue(entity.Id, out var animation)
|
||||
|| animation.Sequencer is not { } sequencer
|
||||
|| !_liveEntities.ShouldAdvanceRootRuntime(playerGuid)
|
||||
|| _liveEntities.IsHidden(playerGuid))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_liveEntities.TryGetRecord(playerGuid, out LiveEntityRecord record))
|
||||
_presenter.PrepareAnimation(record, animation);
|
||||
|
||||
Frame rootFrame = animation.RootMotionScratch;
|
||||
rootFrame.Origin = System.Numerics.Vector3.Zero;
|
||||
rootFrame.Orientation = System.Numerics.Quaternion.Identity;
|
||||
animation.PreparedSequenceFrames = animation.CaptureSequenceFrames(
|
||||
sequencer.Advance(deltaSeconds, rootFrame));
|
||||
animation.SequenceAdvancedBeforeAnimationPass = true;
|
||||
|
||||
output.Origin = rootFrame.Origin;
|
||||
output.Orientation = rootFrame.Orientation;
|
||||
}
|
||||
|
||||
public void CaptureHooks()
|
||||
{
|
||||
uint playerGuid = _identity.ServerGuid;
|
||||
if (_liveEntities.MaterializedWorldEntities.TryGetValue(playerGuid, out var entity)
|
||||
&& _animations.TryGetValue(entity.Id, out var animation)
|
||||
&& animation.Sequencer is { } sequencer)
|
||||
{
|
||||
_hooks.Capture(animation.Entity.Id, sequencer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
using AcDream.Core.Physics;
|
||||
using AcDream.App.Physics;
|
||||
|
||||
namespace AcDream.App.Input;
|
||||
|
||||
|
|
@ -29,6 +30,20 @@ internal sealed class LocalPlayerControllerSlot : ILocalPlayerControllerSource
|
|||
public PlayerMovementController? Controller { get; set; }
|
||||
}
|
||||
|
||||
internal interface ILocalPlayerPhysicsHostSource
|
||||
{
|
||||
EntityPhysicsHost? Host { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The one mutable local physics-host slot. Player-mode lifecycle owns
|
||||
/// assignment; teleport and update owners receive the read-only seam.
|
||||
/// </summary>
|
||||
internal sealed class LocalPlayerPhysicsHostSlot : ILocalPlayerPhysicsHostSource
|
||||
{
|
||||
public EntityPhysicsHost? Host { get; set; }
|
||||
}
|
||||
|
||||
internal interface ILocalPlayerModeSource
|
||||
{
|
||||
bool IsPlayerMode { get; }
|
||||
|
|
@ -50,3 +65,59 @@ internal sealed class LocalPlayerModeState : ILocalPlayerModeSource
|
|||
ChaseModeEverEntered = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Session-scoped server-authoritative movement skills. Player-description
|
||||
/// delivery and player-mode construction share this owner so rebuilding the
|
||||
/// local physics controller cannot fall back to stale defaults.
|
||||
/// </summary>
|
||||
internal sealed class LocalPlayerSkillState
|
||||
{
|
||||
public int RunSkill { get; private set; } = -1;
|
||||
public int JumpSkill { get; private set; } = -1;
|
||||
public bool IsComplete => RunSkill >= 0 && JumpSkill >= 0;
|
||||
|
||||
public void Update(
|
||||
int runSkill,
|
||||
int jumpSkill,
|
||||
PlayerMovementController? controller)
|
||||
{
|
||||
if (runSkill >= 0)
|
||||
RunSkill = runSkill;
|
||||
if (jumpSkill >= 0)
|
||||
JumpSkill = jumpSkill;
|
||||
ApplyTo(controller);
|
||||
}
|
||||
|
||||
public bool ApplyTo(PlayerMovementController? controller)
|
||||
{
|
||||
if (controller is null || !IsComplete)
|
||||
return false;
|
||||
|
||||
controller.SetCharacterSkills(RunSkill, JumpSkill);
|
||||
return true;
|
||||
}
|
||||
|
||||
public void ResetSession()
|
||||
{
|
||||
RunSkill = -1;
|
||||
JumpSkill = -1;
|
||||
}
|
||||
}
|
||||
|
||||
internal interface IViewportAspectSource
|
||||
{
|
||||
float Aspect { get; }
|
||||
}
|
||||
|
||||
/// <summary>Framebuffer aspect published by the window host.</summary>
|
||||
internal sealed class ViewportAspectState : IViewportAspectSource
|
||||
{
|
||||
public float Aspect { get; private set; } = 16f / 9f;
|
||||
|
||||
public void Update(int width, int height)
|
||||
{
|
||||
if (width > 0 && height > 0)
|
||||
Aspect = width / (float)height;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ public sealed class PlayerModeAutoEntry
|
|||
private readonly Func<bool> _isPlayerEntityPresent;
|
||||
private readonly Func<bool> _isPlayerControllerReady;
|
||||
private readonly Func<bool> _isWorldReady;
|
||||
private readonly Func<bool> _isPlayerModeActive;
|
||||
private readonly Action _enterPlayerMode;
|
||||
|
||||
private bool _armed;
|
||||
|
|
@ -76,13 +77,15 @@ public sealed class PlayerModeAutoEntry
|
|||
Func<bool> isPlayerEntityPresent,
|
||||
Func<bool> isPlayerControllerReady,
|
||||
Func<bool> isWorldReady,
|
||||
Action enterPlayerMode)
|
||||
Action enterPlayerMode,
|
||||
Func<bool>? isPlayerModeActive = null)
|
||||
{
|
||||
_isLiveInWorld = isLiveInWorld ?? throw new ArgumentNullException(nameof(isLiveInWorld));
|
||||
_isPlayerEntityPresent = isPlayerEntityPresent ?? throw new ArgumentNullException(nameof(isPlayerEntityPresent));
|
||||
_isPlayerControllerReady = isPlayerControllerReady ?? throw new ArgumentNullException(nameof(isPlayerControllerReady));
|
||||
_isWorldReady = isWorldReady ?? throw new ArgumentNullException(nameof(isWorldReady));
|
||||
_enterPlayerMode = enterPlayerMode ?? throw new ArgumentNullException(nameof(enterPlayerMode));
|
||||
_isPlayerModeActive = isPlayerModeActive ?? (() => false);
|
||||
}
|
||||
|
||||
/// <summary>True iff <see cref="TryEnter"/> would still fire if the
|
||||
|
|
@ -112,6 +115,11 @@ public sealed class PlayerModeAutoEntry
|
|||
public bool TryEnter()
|
||||
{
|
||||
if (!_armed) return false;
|
||||
if (_isPlayerModeActive())
|
||||
{
|
||||
_armed = false;
|
||||
return false;
|
||||
}
|
||||
if (!_isLiveInWorld()) return false;
|
||||
if (!_isPlayerEntityPresent()) return false;
|
||||
if (!_isPlayerControllerReady()) return false;
|
||||
|
|
|
|||
572
src/AcDream.App/Input/PlayerModeController.cs
Normal file
572
src/AcDream.App/Input/PlayerModeController.cs
Normal file
|
|
@ -0,0 +1,572 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.Interaction;
|
||||
using AcDream.App.Net;
|
||||
using AcDream.App.Physics;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.App.Streaming;
|
||||
using AcDream.App.World;
|
||||
using AcDream.Content;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.Physics.Motion;
|
||||
using AcDream.Core.World;
|
||||
|
||||
namespace AcDream.App.Input;
|
||||
|
||||
/// <summary>
|
||||
/// Sole writer for local player-mode, movement-controller, physics-host, and
|
||||
/// chase-camera lifetimes. The window host wires this owner but never mutates
|
||||
/// those slots directly.
|
||||
/// </summary>
|
||||
internal sealed class PlayerModeController : ILocalPlayerTeleportModeOperations
|
||||
{
|
||||
private readonly LocalPlayerModeState _mode;
|
||||
private readonly LocalPlayerControllerSlot _controllerSlot;
|
||||
private readonly LocalPlayerPhysicsHostSlot _hostSlot;
|
||||
private readonly ChaseCameraInputState _chase;
|
||||
private readonly CameraController _camera;
|
||||
private readonly PhysicsEngine _physics;
|
||||
private readonly LiveEntityRuntime _liveEntities;
|
||||
private readonly ILocalPlayerIdentitySource _identity;
|
||||
private readonly LiveWorldOriginState _origin;
|
||||
private readonly ILiveEntityMotionRuntimeBindings _motionBindings;
|
||||
private readonly IDatReaderWriter _dats;
|
||||
private readonly object _datLock;
|
||||
private readonly PhysicsDataCache _physicsDataCache;
|
||||
private readonly LiveEntityAnimationRuntimeView<LiveEntityAnimationState> _animations;
|
||||
private readonly LocalPlayerAnimationController _animation;
|
||||
private readonly LocalPlayerShadowSynchronizer _shadow;
|
||||
private readonly IPlayerApproachCompletionLifetimeOwner _approachCompletions;
|
||||
private readonly ILocalPlayerTeleportInputLifetime _input;
|
||||
private readonly ILiveInWorldSource _session;
|
||||
private readonly MovementTruthDiagnosticController _movementDiagnostics;
|
||||
private readonly LocalPlayerSkillState _skills;
|
||||
private readonly IViewportAspectSource _viewport;
|
||||
private PlayerModeAutoEntry? _autoEntry;
|
||||
private IPlayerApproachCompletionSink? _approachLifetime;
|
||||
|
||||
public PlayerModeController(
|
||||
LocalPlayerModeState mode,
|
||||
LocalPlayerControllerSlot controllerSlot,
|
||||
LocalPlayerPhysicsHostSlot hostSlot,
|
||||
ChaseCameraInputState chase,
|
||||
CameraController camera,
|
||||
PhysicsEngine physics,
|
||||
LiveEntityRuntime liveEntities,
|
||||
ILocalPlayerIdentitySource identity,
|
||||
LiveWorldOriginState origin,
|
||||
ILiveEntityMotionRuntimeBindings motionBindings,
|
||||
IDatReaderWriter dats,
|
||||
object datLock,
|
||||
PhysicsDataCache physicsDataCache,
|
||||
LiveEntityAnimationRuntimeView<LiveEntityAnimationState> animations,
|
||||
LocalPlayerAnimationController animation,
|
||||
LocalPlayerShadowSynchronizer shadow,
|
||||
IPlayerApproachCompletionLifetimeOwner approachCompletions,
|
||||
ILocalPlayerTeleportInputLifetime input,
|
||||
ILiveInWorldSource session,
|
||||
MovementTruthDiagnosticController movementDiagnostics,
|
||||
LocalPlayerSkillState skills,
|
||||
IViewportAspectSource viewport)
|
||||
{
|
||||
_mode = mode ?? throw new ArgumentNullException(nameof(mode));
|
||||
_controllerSlot = controllerSlot ?? throw new ArgumentNullException(nameof(controllerSlot));
|
||||
_hostSlot = hostSlot ?? throw new ArgumentNullException(nameof(hostSlot));
|
||||
_chase = chase ?? throw new ArgumentNullException(nameof(chase));
|
||||
_camera = camera ?? throw new ArgumentNullException(nameof(camera));
|
||||
_physics = physics ?? throw new ArgumentNullException(nameof(physics));
|
||||
_liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities));
|
||||
_identity = identity ?? throw new ArgumentNullException(nameof(identity));
|
||||
_origin = origin ?? throw new ArgumentNullException(nameof(origin));
|
||||
_motionBindings = motionBindings ?? throw new ArgumentNullException(nameof(motionBindings));
|
||||
_dats = dats ?? throw new ArgumentNullException(nameof(dats));
|
||||
_datLock = datLock ?? throw new ArgumentNullException(nameof(datLock));
|
||||
_physicsDataCache = physicsDataCache ?? throw new ArgumentNullException(nameof(physicsDataCache));
|
||||
_animations = animations ?? throw new ArgumentNullException(nameof(animations));
|
||||
_animation = animation ?? throw new ArgumentNullException(nameof(animation));
|
||||
_shadow = shadow ?? throw new ArgumentNullException(nameof(shadow));
|
||||
_approachCompletions = approachCompletions
|
||||
?? throw new ArgumentNullException(nameof(approachCompletions));
|
||||
_input = input ?? throw new ArgumentNullException(nameof(input));
|
||||
_session = session ?? throw new ArgumentNullException(nameof(session));
|
||||
_movementDiagnostics = movementDiagnostics
|
||||
?? throw new ArgumentNullException(nameof(movementDiagnostics));
|
||||
_skills = skills ?? throw new ArgumentNullException(nameof(skills));
|
||||
_viewport = viewport ?? throw new ArgumentNullException(nameof(viewport));
|
||||
}
|
||||
|
||||
public PlayerMovementController? Controller => _controllerSlot.Controller;
|
||||
public Matrix4x4 Projection => _camera.Active.Projection;
|
||||
|
||||
public void BindAutoEntry(PlayerModeAutoEntry autoEntry)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(autoEntry);
|
||||
if (_autoEntry is not null)
|
||||
throw new InvalidOperationException("Player-mode auto-entry is already bound.");
|
||||
_autoEntry = autoEntry;
|
||||
}
|
||||
|
||||
public void Toggle()
|
||||
{
|
||||
if (!_session.IsInWorld)
|
||||
return;
|
||||
|
||||
_autoEntry?.Cancel();
|
||||
if (_mode.IsPlayerMode)
|
||||
Exit();
|
||||
else
|
||||
_ = TryEnter("Tab");
|
||||
}
|
||||
|
||||
public void EnterFromAutoEntry()
|
||||
{
|
||||
if (TryEnter("auto-entry"))
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"live: auto-entered player mode for 0x{_identity.ServerGuid:X8}");
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryEnterPortalSpace()
|
||||
{
|
||||
// Portal activation supersedes the login-only guard. Otherwise the
|
||||
// later auto-entry tick can rebuild this just-created controller and
|
||||
// replace PortalSpace with the default InWorld state.
|
||||
_autoEntry?.Cancel();
|
||||
if (Controller is null && !TryEnter("teleport"))
|
||||
return false;
|
||||
|
||||
if (Controller is not { } controller)
|
||||
return false;
|
||||
|
||||
controller.State = PlayerState.PortalSpace;
|
||||
return true;
|
||||
}
|
||||
|
||||
public void EnterWorld()
|
||||
{
|
||||
if (Controller is { } controller)
|
||||
controller.State = PlayerState.InWorld;
|
||||
}
|
||||
|
||||
public void Exit()
|
||||
{
|
||||
var failures = new List<Exception>();
|
||||
try { _input.EndMouseLook(); }
|
||||
catch (Exception error) { failures.Add(error); }
|
||||
try { _camera.ExitChaseMode(); }
|
||||
catch (Exception error) { failures.Add(error); }
|
||||
try { RetireApproachLifetime(); }
|
||||
catch (Exception error) { failures.Add(error); }
|
||||
_mode.IsPlayerMode = false;
|
||||
_controllerSlot.Controller = null;
|
||||
_hostSlot.Host = null;
|
||||
_chase.Legacy = null;
|
||||
_chase.Retail = null;
|
||||
|
||||
if (failures.Count != 0)
|
||||
throw new AggregateException("Player-mode exit was incomplete.", failures);
|
||||
}
|
||||
|
||||
public void ToggleFlyOrChase()
|
||||
{
|
||||
_autoEntry?.Cancel();
|
||||
if (_camera.IsFlyMode
|
||||
&& _mode.IsPlayerMode
|
||||
&& _chase.Legacy is { } legacy)
|
||||
{
|
||||
_chase.Retail ??= new RetailChaseCamera
|
||||
{
|
||||
Aspect = legacy.Aspect,
|
||||
CollisionProbe = new PhysicsCameraCollisionProbe(_physics),
|
||||
};
|
||||
_camera.EnterChaseMode(legacy, _chase.Retail);
|
||||
return;
|
||||
}
|
||||
|
||||
_camera.ToggleFly();
|
||||
}
|
||||
|
||||
public void ResetSession()
|
||||
{
|
||||
_autoEntry?.Cancel();
|
||||
var failures = new List<Exception>();
|
||||
try { _camera.ExitChaseMode(); }
|
||||
catch (Exception error) { failures.Add(error); }
|
||||
try { RetireApproachLifetime(); }
|
||||
catch (Exception error) { failures.Add(error); }
|
||||
_mode.ResetSession();
|
||||
_controllerSlot.Controller = null;
|
||||
_hostSlot.Host = null;
|
||||
_chase.Legacy = null;
|
||||
_chase.Retail = null;
|
||||
try { _movementDiagnostics.ResetSession(); }
|
||||
catch (Exception error) { failures.Add(error); }
|
||||
try { _shadow.ResetSession(); }
|
||||
catch (Exception error) { failures.Add(error); }
|
||||
|
||||
if (failures.Count != 0)
|
||||
throw new AggregateException("Player-mode session reset was incomplete.", failures);
|
||||
}
|
||||
|
||||
private bool TryEnter(string loggingTag)
|
||||
{
|
||||
uint playerGuid = _identity.ServerGuid;
|
||||
if (!_liveEntities.MaterializedWorldEntities.TryGetValue(
|
||||
playerGuid,
|
||||
out WorldEntity? playerEntity))
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"live: {loggingTag} — player entity 0x{playerGuid:X8} not found yet");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!_liveEntities.TryGetRecord(playerGuid, out LiveEntityRecord playerRecord))
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"live: {loggingTag} — player record 0x{playerGuid:X8} not found yet");
|
||||
return false;
|
||||
}
|
||||
|
||||
BuildControllerAndCamera(
|
||||
loggingTag,
|
||||
playerGuid,
|
||||
playerEntity,
|
||||
playerRecord);
|
||||
return true;
|
||||
}
|
||||
|
||||
private void BuildControllerAndCamera(
|
||||
string loggingTag,
|
||||
uint playerGuid,
|
||||
WorldEntity playerEntity,
|
||||
LiveEntityRecord playerRecord)
|
||||
{
|
||||
IPlayerApproachCompletionSink approachLifetime =
|
||||
_approachCompletions.BeginControllerLifetime();
|
||||
bool lifetimeCommitted = false;
|
||||
bool cameraAttempted = false;
|
||||
bool shadowAttempted = false;
|
||||
CameraController.CameraState priorCamera = _camera.CaptureState();
|
||||
LocalPlayerShadowState.Snapshot? priorShadow = _shadow.Capture();
|
||||
try
|
||||
{
|
||||
var controller = new PlayerMovementController(
|
||||
_physics,
|
||||
playerRecord.ObjectClock);
|
||||
controller.ApplyPhysicsState(playerRecord.FinalPhysicsState);
|
||||
|
||||
// Retail MovementManager::MakeMoveToManager @ 0x00524000 creates one
|
||||
// MoveToManager facade over the local CPhysicsObj seams.
|
||||
PlayerMovementController capturedController = controller;
|
||||
EntityPhysicsHost playerHost = null!;
|
||||
controller.Movement.MoveToFactory = () =>
|
||||
{
|
||||
var moveTo = new MoveToManager(
|
||||
capturedController.Motion,
|
||||
stopCompletely: () =>
|
||||
capturedController.StopCompletelyAtPhysicsObjectBoundary(),
|
||||
getPosition: () => new Position(
|
||||
capturedController.CellId,
|
||||
capturedController.Position,
|
||||
capturedController.BodyOrientation),
|
||||
getHeading: () => MoveToMath.HeadingFromYaw(capturedController.Yaw),
|
||||
setHeading: (heading, _) => capturedController.Yaw =
|
||||
MoveToMath.YawFromHeading(heading),
|
||||
getOwnRadius: () => _motionBindings.GetSetupCylinder(
|
||||
playerGuid,
|
||||
playerEntity).Radius,
|
||||
getOwnHeight: () => _motionBindings.GetSetupCylinder(
|
||||
playerGuid,
|
||||
playerEntity).Height,
|
||||
contact: () => capturedController.BodyInContact,
|
||||
isInterpolating: () => false,
|
||||
getVelocity: () => capturedController.BodyVelocity,
|
||||
getSelfId: () => playerGuid,
|
||||
setTarget: (context, target, radius, quantum) =>
|
||||
playerHost.SetTarget(context, target, radius, quantum),
|
||||
clearTarget: playerHost.ClearTarget,
|
||||
getTargetQuantum: () => playerHost.TargetManager.GetTargetQuantum(),
|
||||
setTargetQuantum: playerHost.TargetManager.SetTargetQuantum,
|
||||
curTime: () => capturedController.SimTimeSeconds);
|
||||
|
||||
moveTo.MoveToComplete = error =>
|
||||
{
|
||||
if (PhysicsDiagnostics.ProbeAutoWalkEnabled)
|
||||
Console.WriteLine($"[autowalk-end] reason=complete err={error}");
|
||||
if (error == WeenieError.None)
|
||||
approachLifetime.PublishNaturalCompletion();
|
||||
else
|
||||
approachLifetime.PublishCancellation(error);
|
||||
};
|
||||
moveTo.MoveToCancelled = error =>
|
||||
approachLifetime.PublishCancellation(error);
|
||||
moveTo.StickTo = (target, radius, height) =>
|
||||
playerHost.PositionManager.StickTo(target, radius, height);
|
||||
moveTo.Unstick = () => playerHost.PositionManager.UnStick();
|
||||
return moveTo;
|
||||
};
|
||||
|
||||
MovementManager exactMovement = controller.Movement;
|
||||
var configuredHost = new EntityPhysicsHost(
|
||||
playerGuid,
|
||||
getPosition: () => new Position(
|
||||
playerRecord.FullCellId,
|
||||
playerRecord.WorldEntity?.Position ?? capturedController.Position,
|
||||
capturedController.BodyOrientation),
|
||||
getVelocity: () => capturedController.BodyVelocity,
|
||||
getRadius: () => _motionBindings.GetSetupCylinder(
|
||||
playerGuid,
|
||||
playerEntity).Radius,
|
||||
inContact: () => capturedController.BodyInContact,
|
||||
minterpMaxSpeed: () => capturedController.Motion.GetMaxSpeed(),
|
||||
curTime: () => capturedController.SimTimeSeconds,
|
||||
physicsTimerTime: () => capturedController.SimTimeSeconds,
|
||||
getObjectA: _motionBindings.ResolvePhysicsHost,
|
||||
handleUpdateTarget: info =>
|
||||
{
|
||||
if (PhysicsDiagnostics.ProbeAutoWalkEnabled)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[autowalk-target] object=0x{info.ObjectId:X8} "
|
||||
+ $"status={info.Status} context={info.ContextId} "
|
||||
+ $"target=({info.TargetPosition.Frame.Origin.X:F2},"
|
||||
+ $"{info.TargetPosition.Frame.Origin.Y:F2},"
|
||||
+ $"{info.TargetPosition.Frame.Origin.Z:F2})");
|
||||
}
|
||||
exactMovement.HandleUpdateTarget(info);
|
||||
},
|
||||
interruptCurrentMovement: () => exactMovement.CancelMoveTo(
|
||||
WeenieError.ActionCancelled));
|
||||
playerHost = EntityPhysicsHost.SelectStableHostWithoutRebind(
|
||||
_liveEntities,
|
||||
playerRecord,
|
||||
configuredHost);
|
||||
|
||||
exactMovement.MakeMoveToManager();
|
||||
controller.Motion.UnstickFromObject = () =>
|
||||
playerHost.PositionManager.UnStick();
|
||||
controller.PositionManager = playerHost.PositionManager;
|
||||
controller.Motion.InterruptCurrentMovement = () =>
|
||||
{
|
||||
if (PhysicsDiagnostics.ProbeAutoWalkEnabled
|
||||
&& exactMovement.IsMovingTo())
|
||||
{
|
||||
Console.WriteLine("[autowalk-end] reason=interrupt");
|
||||
}
|
||||
exactMovement.CancelMoveTo(WeenieError.ActionCancelled);
|
||||
};
|
||||
|
||||
if (_skills.ApplyTo(controller))
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"live: {loggingTag} — applied server skills "
|
||||
+ $"run={_skills.RunSkill} jump={_skills.JumpSkill}");
|
||||
}
|
||||
|
||||
ApplyStepHeights(controller, playerEntity);
|
||||
uint initialCellId = ResolveInitialCell(playerGuid, playerEntity);
|
||||
|
||||
if (_animations.TryGetValue(playerEntity.Id, out LiveEntityAnimationState? animation)
|
||||
&& animation.Sequencer is { } sequencer)
|
||||
{
|
||||
controller.AttachCycleVelocityAccessor(() => sequencer.CurrentVelocity);
|
||||
controller.ObjectScale = animation.Scale;
|
||||
controller.AttachAnimationRootMotionSource(
|
||||
_animation.AdvanceRoot,
|
||||
_animation.CaptureHooks);
|
||||
controller.Motion.RemoveLinkAnimations =
|
||||
sequencer.Manager.HandleEnterWorld;
|
||||
controller.Motion.InitializeMotionTables =
|
||||
sequencer.Manager.InitializeState;
|
||||
controller.Motion.CheckForCompletedMotions =
|
||||
sequencer.Manager.CheckForCompletedMotions;
|
||||
controller.Motion.DefaultSink =
|
||||
new MotionTableDispatchSink(sequencer);
|
||||
}
|
||||
|
||||
ResolveResult initial = _physics.Resolve(
|
||||
playerEntity.Position,
|
||||
initialCellId,
|
||||
Vector3.Zero,
|
||||
100f);
|
||||
var (placementRadius, placementHeight) =
|
||||
_motionBindings.GetSetupCylinder(playerGuid, playerEntity);
|
||||
if (placementRadius < 0.05f)
|
||||
{
|
||||
placementRadius = 0.48f;
|
||||
placementHeight = 1.835f;
|
||||
}
|
||||
|
||||
ResolveResult placement = _physics.ResolvePlacement(
|
||||
initial.Position,
|
||||
initial.CellId,
|
||||
placementRadius,
|
||||
placementHeight,
|
||||
controller.StepUpHeight,
|
||||
controller.StepDownHeight,
|
||||
ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide,
|
||||
playerEntity.Id);
|
||||
if (placement.Ok)
|
||||
initial = placement;
|
||||
|
||||
controller.PreparePositionForCommit(
|
||||
initial.Position,
|
||||
initial.CellId,
|
||||
CellLocalForSeed(initial.Position, initial.CellId));
|
||||
controller.SetBodyOrientation(playerEntity.Rotation);
|
||||
|
||||
var legacyCamera = new ChaseCamera { Aspect = _viewport.Aspect };
|
||||
var retailCamera = new RetailChaseCamera
|
||||
{
|
||||
Aspect = _viewport.Aspect,
|
||||
CollisionProbe = new PhysicsCameraCollisionProbe(_physics),
|
||||
};
|
||||
cameraAttempted = true;
|
||||
_camera.EnterChaseMode(legacyCamera, retailCamera);
|
||||
|
||||
EntityPhysicsHost stableAfterCamera =
|
||||
EntityPhysicsHost.SelectStableHostWithoutRebind(
|
||||
_liveEntities,
|
||||
playerRecord,
|
||||
configuredHost);
|
||||
if (!ReferenceEquals(stableAfterCamera, playerHost))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"The local physics host changed during chase-camera activation.");
|
||||
}
|
||||
|
||||
shadowAttempted = true;
|
||||
_shadow.SyncPose(
|
||||
playerEntity,
|
||||
initial.Position,
|
||||
playerEntity.Rotation,
|
||||
initial.CellId,
|
||||
force: true);
|
||||
|
||||
// Publish the incarnation-stable CPhysicsObj delegates only after all
|
||||
// DAT, placement, shadow, and camera preparation has succeeded. A
|
||||
// late preparation failure therefore cannot expose an abandoned
|
||||
// controller through LiveEntityRecord.PhysicsHost.
|
||||
EntityPhysicsHost publishedHost = EntityPhysicsHost.InstallOrRebind(
|
||||
_liveEntities,
|
||||
playerRecord,
|
||||
configuredHost);
|
||||
if (!ReferenceEquals(publishedHost, playerHost))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"The local physics host changed between preparation and commit.");
|
||||
}
|
||||
|
||||
playerEntity.SetPosition(initial.Position);
|
||||
playerEntity.ParentCellId = initial.CellId;
|
||||
controller.CommitPreparedPosition();
|
||||
_hostSlot.Host = publishedHost;
|
||||
_controllerSlot.Controller = controller;
|
||||
_chase.Legacy = legacyCamera;
|
||||
_chase.Retail = retailCamera;
|
||||
_mode.IsPlayerMode = true;
|
||||
_mode.ChaseModeEverEntered = true;
|
||||
_approachLifetime = approachLifetime;
|
||||
lifetimeCommitted = true;
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
var failures = new List<Exception> { error };
|
||||
if (shadowAttempted)
|
||||
{
|
||||
try { _shadow.Restore(playerEntity, priorShadow); }
|
||||
catch (Exception cleanupError) { failures.Add(cleanupError); }
|
||||
}
|
||||
if (cameraAttempted)
|
||||
{
|
||||
try { _camera.RestoreState(priorCamera); }
|
||||
catch (Exception cleanupError) { failures.Add(cleanupError); }
|
||||
}
|
||||
|
||||
_mode.IsPlayerMode = false;
|
||||
_controllerSlot.Controller = null;
|
||||
_hostSlot.Host = null;
|
||||
_chase.Legacy = null;
|
||||
_chase.Retail = null;
|
||||
|
||||
if (failures.Count != 1)
|
||||
throw new AggregateException(
|
||||
"Player-mode entry failed and rollback was incomplete.",
|
||||
failures);
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (!lifetimeCommitted)
|
||||
_approachCompletions.RetireControllerLifetime(approachLifetime);
|
||||
}
|
||||
}
|
||||
|
||||
private void RetireApproachLifetime()
|
||||
{
|
||||
if (_approachLifetime is not { } lifetime)
|
||||
return;
|
||||
_approachLifetime = null;
|
||||
_approachCompletions.RetireControllerLifetime(lifetime);
|
||||
}
|
||||
|
||||
private void ApplyStepHeights(
|
||||
PlayerMovementController controller,
|
||||
WorldEntity playerEntity)
|
||||
{
|
||||
if ((playerEntity.SourceGfxObjOrSetupId & 0xFF000000u) == 0x02000000u)
|
||||
{
|
||||
DatReaderWriter.DBObjs.Setup? setup;
|
||||
lock (_datLock)
|
||||
setup = _dats.Get<DatReaderWriter.DBObjs.Setup>(
|
||||
playerEntity.SourceGfxObjOrSetupId);
|
||||
if (setup is not null)
|
||||
_physicsDataCache.CacheSetup(playerEntity.SourceGfxObjOrSetupId, setup);
|
||||
controller.StepUpHeight = setup is { StepUpHeight: > 0f }
|
||||
? setup.StepUpHeight
|
||||
: 0.4f;
|
||||
controller.StepDownHeight = setup is { StepDownHeight: > 0f }
|
||||
? setup.StepDownHeight
|
||||
: 0.4f;
|
||||
Console.WriteLine(
|
||||
$"physics: player step heights — StepUp={controller.StepUpHeight:F3} m "
|
||||
+ $"(Setup.StepUpHeight={(setup?.StepUpHeight ?? 0f):F3}), "
|
||||
+ $"StepDown={controller.StepDownHeight:F3} m "
|
||||
+ $"(Setup.StepDownHeight={(setup?.StepDownHeight ?? 0f):F3})");
|
||||
return;
|
||||
}
|
||||
|
||||
controller.StepUpHeight = 0.4f;
|
||||
controller.StepDownHeight = 0.4f;
|
||||
Console.WriteLine(
|
||||
"physics: player step heights — defaulting to 0.4 m (no setup dat)");
|
||||
}
|
||||
|
||||
private uint ResolveInitialCell(uint playerGuid, WorldEntity playerEntity)
|
||||
{
|
||||
if (_liveEntities.Snapshots.TryGetValue(playerGuid, out var spawn)
|
||||
&& spawn.Position is { LandblockId: not 0u } position)
|
||||
{
|
||||
return position.LandblockId;
|
||||
}
|
||||
|
||||
int landblockX = _origin.CenterX
|
||||
+ (int)MathF.Floor(playerEntity.Position.X / 192f);
|
||||
int landblockY = _origin.CenterY
|
||||
+ (int)MathF.Floor(playerEntity.Position.Y / 192f);
|
||||
return ((uint)landblockX << 24)
|
||||
| ((uint)landblockY << 16)
|
||||
| 0x0001u;
|
||||
}
|
||||
|
||||
private Vector3 CellLocalForSeed(Vector3 worldPosition, uint cellId)
|
||||
{
|
||||
int landblockX = (int)((cellId >> 24) & 0xFFu);
|
||||
int landblockY = (int)((cellId >> 16) & 0xFFu);
|
||||
var origin = new Vector3(
|
||||
(landblockX - _origin.CenterX) * 192f,
|
||||
(landblockY - _origin.CenterY) * 192f,
|
||||
0f);
|
||||
return worldPosition - origin;
|
||||
}
|
||||
}
|
||||
|
|
@ -1092,7 +1092,10 @@ public sealed class PlayerMovementController
|
|||
// route through here. When PhysicsDiagnostics.ProbeCellEnabled is
|
||||
// off this collapses to a single bool-compare + assignment — zero
|
||||
// logging cost.
|
||||
private void UpdateCellId(uint newCellId, string reason)
|
||||
private void UpdateCellId(
|
||||
uint newCellId,
|
||||
string reason,
|
||||
bool publishRenderRoot = true)
|
||||
{
|
||||
if (newCellId != CellId && PhysicsDiagnostics.ProbeCellEnabled)
|
||||
{
|
||||
|
|
@ -1109,7 +1112,8 @@ public sealed class PlayerMovementController
|
|||
// jump-looping near the cottage doorway clobbered the render root every tick → the render
|
||||
// rooted at the NPC's tiny connector cell → only its ~8-tri shell drew, rest = GL clear
|
||||
// color = the cottage doorway "blue-hole" flap (diagnosed 2026-06-03 via [flap-cam]/[shell]).
|
||||
_physics.UpdatePlayerCurrCell(newCellId);
|
||||
if (publishRenderRoot)
|
||||
_physics.UpdatePlayerCurrCell(newCellId);
|
||||
}
|
||||
|
||||
public void SetPosition(Vector3 pos, uint cellId)
|
||||
|
|
@ -1127,11 +1131,47 @@ public sealed class PlayerMovementController
|
|||
/// directly via <c>SnapToCell</c> rather than delta-syncing through the setter.
|
||||
/// </summary>
|
||||
public void SetPosition(Vector3 pos, uint cellId, Vector3 cellLocal)
|
||||
=> SetPositionCore(
|
||||
pos,
|
||||
cellId,
|
||||
cellLocal,
|
||||
publishSharedState: true);
|
||||
|
||||
/// <summary>
|
||||
/// Builds a new local controller's canonical body pose without publishing
|
||||
/// the renderer's global current cell or mutating the incarnation-stable
|
||||
/// PositionManager. Player-mode entry commits those shared edges only
|
||||
/// after camera, shadow, and host preparation succeeds.
|
||||
/// </summary>
|
||||
internal void PreparePositionForCommit(
|
||||
Vector3 pos,
|
||||
uint cellId,
|
||||
Vector3 cellLocal) =>
|
||||
SetPositionCore(
|
||||
pos,
|
||||
cellId,
|
||||
cellLocal,
|
||||
publishSharedState: false);
|
||||
|
||||
internal void CommitPreparedPosition()
|
||||
{
|
||||
_physics.UpdatePlayerCurrCell(CellId);
|
||||
PositionManager?.UnStick();
|
||||
}
|
||||
|
||||
private void SetPositionCore(
|
||||
Vector3 pos,
|
||||
uint cellId,
|
||||
Vector3 cellLocal,
|
||||
bool publishSharedState)
|
||||
{
|
||||
_body.SnapToCell(cellId, pos, cellLocal);
|
||||
_prevPhysicsPos = pos;
|
||||
_currPhysicsPos = pos;
|
||||
UpdateCellId(_body.CellPosition.ObjCellId, "teleport");
|
||||
UpdateCellId(
|
||||
_body.CellPosition.ObjCellId,
|
||||
"teleport",
|
||||
publishSharedState);
|
||||
|
||||
// Treat as grounded after a server-side position snap.
|
||||
_body.TransientState = TransientStateFlags.Contact
|
||||
|
|
@ -1164,7 +1204,8 @@ public sealed class PlayerMovementController
|
|||
// tears down any active stick. (StopInterpolating/UnConstrain have no
|
||||
// armed acdream counterparts — no local-player InterpolationManager,
|
||||
// constraint leash unarmed per #167.)
|
||||
PositionManager?.UnStick();
|
||||
if (publishSharedState)
|
||||
PositionManager?.UnStick();
|
||||
// Reset the edge tracker: the stop wiped the motion state, so keys
|
||||
// still physically held must re-fire as press edges on the next
|
||||
// Update (matches the pre-W6 level-triggered behavior of walking
|
||||
|
|
|
|||
141
src/AcDream.App/Interaction/PlayerApproachCompletionState.cs
Normal file
141
src/AcDream.App/Interaction/PlayerApproachCompletionState.cs
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
using AcDream.Core.Physics;
|
||||
|
||||
namespace AcDream.App.Interaction;
|
||||
|
||||
internal interface IPlayerApproachCompletionSink
|
||||
{
|
||||
void PublishNaturalCompletion();
|
||||
void PublishCancellation(WeenieError error);
|
||||
}
|
||||
|
||||
internal interface IPlayerApproachCompletionLifetimeOwner
|
||||
{
|
||||
IPlayerApproachCompletionSink BeginControllerLifetime();
|
||||
void RetireControllerLifetime(IPlayerApproachCompletionSink lifetime);
|
||||
}
|
||||
|
||||
internal interface IPlayerApproachTokenSource
|
||||
{
|
||||
bool TryBeginApproach(out PlayerApproachToken token);
|
||||
}
|
||||
|
||||
internal readonly record struct PlayerApproachToken(
|
||||
ulong ControllerLifetime,
|
||||
ulong ApproachGeneration);
|
||||
|
||||
internal readonly record struct PlayerApproachCompletion(
|
||||
PlayerApproachToken Token,
|
||||
bool IsNatural,
|
||||
WeenieError Error);
|
||||
|
||||
/// <summary>
|
||||
/// Same-thread mailbox between local MoveTo completion and the selection/
|
||||
/// item interaction phase. It deliberately owns no query, item, transport,
|
||||
/// window, or callback reference.
|
||||
/// </summary>
|
||||
internal sealed class PlayerApproachCompletionState
|
||||
: IPlayerApproachCompletionLifetimeOwner,
|
||||
IPlayerApproachTokenSource
|
||||
{
|
||||
private readonly Queue<PlayerApproachCompletion> _pending = new();
|
||||
private ControllerLifetime? _active;
|
||||
private ulong _nextLifetime;
|
||||
|
||||
public IPlayerApproachCompletionSink BeginControllerLifetime()
|
||||
{
|
||||
if (_active is not null)
|
||||
throw new InvalidOperationException(
|
||||
"A player approach-completion lifetime is already active.");
|
||||
|
||||
var lifetime = new ControllerLifetime(this, ++_nextLifetime);
|
||||
_active = lifetime;
|
||||
return lifetime;
|
||||
}
|
||||
|
||||
public void RetireControllerLifetime(IPlayerApproachCompletionSink lifetime)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(lifetime);
|
||||
if (!ReferenceEquals(_active, lifetime)
|
||||
|| lifetime is not ControllerLifetime retiring)
|
||||
return;
|
||||
|
||||
PurgeLifetime(retiring.LifetimeId);
|
||||
if (retiring.TryGetCurrentToken(out PlayerApproachToken token))
|
||||
{
|
||||
_pending.Enqueue(new PlayerApproachCompletion(
|
||||
token,
|
||||
IsNatural: false,
|
||||
WeenieError.ActionCancelled));
|
||||
}
|
||||
_active = null;
|
||||
}
|
||||
|
||||
public bool TryBeginApproach(out PlayerApproachToken token)
|
||||
{
|
||||
if (_active is null)
|
||||
{
|
||||
token = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
token = _active.BeginApproach();
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool TryTake(out PlayerApproachCompletion completion) =>
|
||||
_pending.TryDequeue(out completion);
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
_active = null;
|
||||
_pending.Clear();
|
||||
}
|
||||
|
||||
private void Publish(
|
||||
ControllerLifetime lifetime,
|
||||
bool isNatural,
|
||||
WeenieError error)
|
||||
{
|
||||
if (!ReferenceEquals(_active, lifetime)
|
||||
|| !lifetime.TryGetCurrentToken(out PlayerApproachToken token))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_pending.Enqueue(new PlayerApproachCompletion(token, isNatural, error));
|
||||
}
|
||||
|
||||
private void PurgeLifetime(ulong lifetimeId)
|
||||
{
|
||||
int retained = _pending.Count;
|
||||
for (int i = 0; i < retained; i++)
|
||||
{
|
||||
PlayerApproachCompletion completion = _pending.Dequeue();
|
||||
if (completion.Token.ControllerLifetime != lifetimeId)
|
||||
_pending.Enqueue(completion);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ControllerLifetime(
|
||||
PlayerApproachCompletionState owner,
|
||||
ulong lifetimeId) : IPlayerApproachCompletionSink
|
||||
{
|
||||
private ulong _approachGeneration;
|
||||
public ulong LifetimeId => lifetimeId;
|
||||
|
||||
public PlayerApproachToken BeginApproach() =>
|
||||
new(lifetimeId, ++_approachGeneration);
|
||||
|
||||
public bool TryGetCurrentToken(out PlayerApproachToken token)
|
||||
{
|
||||
token = new PlayerApproachToken(lifetimeId, _approachGeneration);
|
||||
return _approachGeneration != 0;
|
||||
}
|
||||
|
||||
public void PublishNaturalCompletion() =>
|
||||
owner.Publish(this, isNatural: true, WeenieError.None);
|
||||
|
||||
public void PublishCancellation(WeenieError error) =>
|
||||
owner.Publish(this, isNatural: false, error);
|
||||
}
|
||||
}
|
||||
|
|
@ -12,7 +12,9 @@ internal interface IPlayerInteractionMovementSink
|
|||
/// owner arm completion state before an already-facing TurnTo can finish
|
||||
/// synchronously, without letting the preceding cancellation clear it.
|
||||
/// </summary>
|
||||
bool BeginApproach(InteractionApproach approach, Action? armAfterCancel = null);
|
||||
bool BeginApproach(
|
||||
InteractionApproach approach,
|
||||
Action<PlayerApproachToken>? armAfterCancel = null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -20,13 +22,18 @@ internal interface IPlayerInteractionMovementSink
|
|||
/// the same MovementManager used by authoritative movement packets.
|
||||
/// </summary>
|
||||
internal sealed class PlayerInteractionMovementSink(
|
||||
Func<PlayerMovementController?> player)
|
||||
Func<PlayerMovementController?> player,
|
||||
IPlayerApproachTokenSource approachTokens)
|
||||
: IPlayerInteractionMovementSink
|
||||
{
|
||||
private readonly Func<PlayerMovementController?> _player = player
|
||||
?? throw new ArgumentNullException(nameof(player));
|
||||
private readonly IPlayerApproachTokenSource _approachTokens = approachTokens
|
||||
?? throw new ArgumentNullException(nameof(approachTokens));
|
||||
|
||||
public bool BeginApproach(InteractionApproach approach, Action? armAfterCancel = null)
|
||||
public bool BeginApproach(
|
||||
InteractionApproach approach,
|
||||
Action<PlayerApproachToken>? armAfterCancel = null)
|
||||
{
|
||||
PlayerMovementController? controller = _player();
|
||||
if (controller?.MoveTo is null)
|
||||
|
|
@ -57,7 +64,9 @@ internal sealed class PlayerInteractionMovementSink(
|
|||
// intent is armed so cancellation of the preceding move cannot clear
|
||||
// the new request; the internal second call is then a retail no-op.
|
||||
controller.Movement.CancelMoveTo(WeenieError.ActionCancelled);
|
||||
armAfterCancel?.Invoke();
|
||||
if (!_approachTokens.TryBeginApproach(out PlayerApproachToken token))
|
||||
return false;
|
||||
armAfterCancel?.Invoke(token);
|
||||
|
||||
// P1's wire MoveToObject store marks the command non-autonomous.
|
||||
// The speculative local install must do the same or the next raw-input
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ internal sealed class SelectionInteractionController
|
|||
private readonly ISelectionInteractionTransport _transport;
|
||||
private readonly IPlayerInteractionMovementSink _movement;
|
||||
private readonly OutboundInteractionQueue _outbound = new();
|
||||
private readonly PlayerApproachCompletionState _approachCompletions;
|
||||
private readonly Action<string>? _toast;
|
||||
private PendingPostArrivalAction? _pendingPostArrival;
|
||||
|
||||
|
|
@ -36,7 +37,8 @@ internal sealed class SelectionInteractionController
|
|||
bool IsPickup,
|
||||
uint DestinationContainerId,
|
||||
int Placement,
|
||||
ulong PendingPlacementToken);
|
||||
ulong PendingPlacementToken,
|
||||
PlayerApproachToken ApproachToken);
|
||||
|
||||
public SelectionInteractionController(
|
||||
SelectionState selection,
|
||||
|
|
@ -44,7 +46,8 @@ internal sealed class SelectionInteractionController
|
|||
ItemInteractionController items,
|
||||
ISelectionInteractionTransport transport,
|
||||
IPlayerInteractionMovementSink movement,
|
||||
Action<string>? toast = null)
|
||||
Action<string>? toast = null,
|
||||
PlayerApproachCompletionState? approachCompletions = null)
|
||||
{
|
||||
_selection = selection ?? throw new ArgumentNullException(nameof(selection));
|
||||
_query = query ?? throw new ArgumentNullException(nameof(query));
|
||||
|
|
@ -52,6 +55,8 @@ internal sealed class SelectionInteractionController
|
|||
_transport = transport ?? throw new ArgumentNullException(nameof(transport));
|
||||
_movement = movement ?? throw new ArgumentNullException(nameof(movement));
|
||||
_toast = toast;
|
||||
_approachCompletions = approachCompletions
|
||||
?? new PlayerApproachCompletionState();
|
||||
}
|
||||
|
||||
public bool HandleInputAction(InputAction action)
|
||||
|
|
@ -213,11 +218,12 @@ internal sealed class SelectionInteractionController
|
|||
IsPickup: false,
|
||||
DestinationContainerId: 0u,
|
||||
Placement: 0,
|
||||
PendingPlacementToken: 0u);
|
||||
PendingPlacementToken: 0u,
|
||||
ApproachToken: default);
|
||||
bool started = _movement.BeginApproach(
|
||||
approach,
|
||||
() => _pendingPostArrival = pending);
|
||||
if (!started && _pendingPostArrival == pending)
|
||||
token => _pendingPostArrival = pending with { ApproachToken = token });
|
||||
if (!started && _pendingPostArrival?.ServerGuid == pending.ServerGuid)
|
||||
CancelPendingApproach();
|
||||
return;
|
||||
}
|
||||
|
|
@ -265,11 +271,12 @@ internal sealed class SelectionInteractionController
|
|||
IsPickup: true,
|
||||
destinationContainerId,
|
||||
placement,
|
||||
pendingPlacementToken);
|
||||
pendingPlacementToken,
|
||||
ApproachToken: default);
|
||||
bool started = _movement.BeginApproach(
|
||||
approach,
|
||||
() => _pendingPostArrival = pending);
|
||||
if (!started && _pendingPostArrival == pending)
|
||||
token => _pendingPostArrival = pending with { ApproachToken = token });
|
||||
if (!started && _pendingPostArrival?.ServerGuid == pending.ServerGuid)
|
||||
CancelPendingApproach();
|
||||
else if (!started)
|
||||
CancelPickupPresentation(itemGuid, pendingPlacementToken);
|
||||
|
|
@ -307,6 +314,9 @@ internal sealed class SelectionInteractionController
|
|||
|
||||
/// <summary>Fires only after natural MoveToComplete(None), never cancellation.</summary>
|
||||
public void OnNaturalMoveToComplete()
|
||||
=> HandleNaturalMoveToComplete();
|
||||
|
||||
private void HandleNaturalMoveToComplete()
|
||||
{
|
||||
if (_pendingPostArrival is not { } pending)
|
||||
return;
|
||||
|
|
@ -351,7 +361,19 @@ internal sealed class SelectionInteractionController
|
|||
}
|
||||
}
|
||||
|
||||
public void DrainOutbound() => _outbound.Drain();
|
||||
public void DrainOutbound()
|
||||
{
|
||||
while (_approachCompletions.TryTake(out PlayerApproachCompletion completion))
|
||||
{
|
||||
if (_pendingPostArrival?.ApproachToken != completion.Token)
|
||||
continue;
|
||||
if (completion.IsNatural)
|
||||
HandleNaturalMoveToComplete();
|
||||
else
|
||||
CancelPendingApproach();
|
||||
}
|
||||
_outbound.Drain();
|
||||
}
|
||||
|
||||
public void OnMoveToCancelled(WeenieError _) => CancelPendingApproach();
|
||||
|
||||
|
|
@ -395,6 +417,8 @@ internal sealed class SelectionInteractionController
|
|||
catch (Exception error) { failures.Add(error); }
|
||||
try { _outbound.Clear(); }
|
||||
catch (Exception error) { failures.Add(error); }
|
||||
try { _approachCompletions.Clear(); }
|
||||
catch (Exception error) { failures.Add(error); }
|
||||
_pendingPostArrival = null;
|
||||
|
||||
if (failures.Count != 0)
|
||||
|
|
|
|||
|
|
@ -193,6 +193,36 @@ public sealed class EntityPhysicsHost : IPhysicsObjHost
|
|||
return existing;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates an exact incarnation and returns the manager-stable host that
|
||||
/// a later <see cref="InstallOrRebind"/> will publish, without changing any
|
||||
/// live delegates. Player-mode construction uses this preparation edge so
|
||||
/// DAT/physics/camera failures cannot expose a half-rebound host.
|
||||
/// </summary>
|
||||
internal static EntityPhysicsHost SelectStableHostWithoutRebind(
|
||||
LiveEntityRuntime runtime,
|
||||
LiveEntityRecord expectedRecord,
|
||||
EntityPhysicsHost configuration)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(runtime);
|
||||
ArgumentNullException.ThrowIfNull(expectedRecord);
|
||||
ArgumentNullException.ThrowIfNull(configuration);
|
||||
if (!runtime.TryGetRecord(expectedRecord.ServerGuid, out LiveEntityRecord current)
|
||||
|| !ReferenceEquals(current, expectedRecord))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Live entity 0x{expectedRecord.ServerGuid:X8} changed incarnation during physics-host preparation.");
|
||||
}
|
||||
|
||||
return current.PhysicsHost switch
|
||||
{
|
||||
null => configuration,
|
||||
EntityPhysicsHost existing => existing,
|
||||
_ => throw new InvalidOperationException(
|
||||
$"Live entity 0x{expectedRecord.ServerGuid:X8} owns an incompatible physics-host implementation."),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the position-only CPhysicsObj facade used before an animated or
|
||||
/// player movement owner exists. The delegates capture the exact live
|
||||
|
|
|
|||
|
|
@ -48,8 +48,8 @@ internal sealed class LiveEntityNetworkUpdateController
|
|||
private readonly IAnimationLoader _animLoader;
|
||||
private readonly CombatTargetController? _combatTargetController;
|
||||
private readonly LiveWorldOriginState _origin;
|
||||
private readonly AcDream.App.Streaming.TeleportTransitCoordinator<
|
||||
WorldSession.EntityPositionUpdate> _teleportTransit;
|
||||
private readonly AcDream.App.Streaming.ILocalPlayerTeleportNetworkSink
|
||||
_localPlayerTeleport;
|
||||
private readonly Func<PlayerMovementController?> _playerControllerSource;
|
||||
private readonly LocalPlayerOutboundController _localPlayerOutbound;
|
||||
private readonly Func<EntityPhysicsHost?> _playerHostSource;
|
||||
|
|
@ -57,7 +57,6 @@ internal sealed class LiveEntityNetworkUpdateController
|
|||
private readonly IPhysicsScriptTimeSource _gameTime;
|
||||
private readonly Func<WorldSession?> _session;
|
||||
private readonly LiveEntityInboundAuthorityGate _authorityGate;
|
||||
private readonly Action<WorldSession.EntityPositionUpdate> _aimTeleportDestination;
|
||||
private readonly IMovementTruthDiagnosticSink _movementTruthDiagnostics;
|
||||
|
||||
private PlayerMovementController? _playerController => _playerControllerSource();
|
||||
|
|
@ -96,7 +95,7 @@ internal sealed class LiveEntityNetworkUpdateController
|
|||
IAnimationLoader animLoader,
|
||||
CombatTargetController? combatTargetController,
|
||||
LiveWorldOriginState origin,
|
||||
AcDream.App.Streaming.TeleportTransitCoordinator<WorldSession.EntityPositionUpdate> teleportTransit,
|
||||
AcDream.App.Streaming.ILocalPlayerTeleportNetworkSink localPlayerTeleport,
|
||||
Func<PlayerMovementController?> playerControllerSource,
|
||||
LocalPlayerOutboundController localPlayerOutbound,
|
||||
Func<EntityPhysicsHost?> playerHostSource,
|
||||
|
|
@ -104,7 +103,6 @@ internal sealed class LiveEntityNetworkUpdateController
|
|||
IPhysicsScriptTimeSource gameTime,
|
||||
Func<WorldSession?> session,
|
||||
Action<uint, AcceptedPhysicsTimestamps> publishTimestamps,
|
||||
Action<WorldSession.EntityPositionUpdate> aimTeleportDestination,
|
||||
IMovementTruthDiagnosticSink movementTruthDiagnostics)
|
||||
{
|
||||
_liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities));
|
||||
|
|
@ -127,7 +125,8 @@ internal sealed class LiveEntityNetworkUpdateController
|
|||
_animLoader = animLoader ?? throw new ArgumentNullException(nameof(animLoader));
|
||||
_combatTargetController = combatTargetController;
|
||||
_origin = origin ?? throw new ArgumentNullException(nameof(origin));
|
||||
_teleportTransit = teleportTransit ?? throw new ArgumentNullException(nameof(teleportTransit));
|
||||
_localPlayerTeleport = localPlayerTeleport
|
||||
?? throw new ArgumentNullException(nameof(localPlayerTeleport));
|
||||
_playerControllerSource = playerControllerSource ?? throw new ArgumentNullException(nameof(playerControllerSource));
|
||||
_localPlayerOutbound = localPlayerOutbound
|
||||
?? throw new ArgumentNullException(nameof(localPlayerOutbound));
|
||||
|
|
@ -138,7 +137,6 @@ internal sealed class LiveEntityNetworkUpdateController
|
|||
_authorityGate = new LiveEntityInboundAuthorityGate(
|
||||
liveEntities,
|
||||
publishTimestamps);
|
||||
_aimTeleportDestination = aimTeleportDestination ?? throw new ArgumentNullException(nameof(aimTeleportDestination));
|
||||
_movementTruthDiagnostics = movementTruthDiagnostics
|
||||
?? throw new ArgumentNullException(nameof(movementTruthDiagnostics));
|
||||
}
|
||||
|
|
@ -1776,14 +1774,11 @@ internal sealed class LiveEntityNetworkUpdateController
|
|||
// packet first. The presentation coordinator exposes exactly one
|
||||
// sequence-correlated destination without reordering that state.
|
||||
if (timestampDisposition is AcDream.Core.Physics.PositionTimestampDisposition.Apply
|
||||
&& update.Guid == _playerServerGuid
|
||||
&& _teleportTransit.OfferDestination(
|
||||
update.TeleportSequence,
|
||||
timestamps.TeleportAdvanced,
|
||||
update,
|
||||
out var teleportDestination))
|
||||
&& update.Guid == _playerServerGuid)
|
||||
{
|
||||
_aimTeleportDestination(teleportDestination);
|
||||
_localPlayerTeleport.OfferDestination(
|
||||
update,
|
||||
timestamps.TeleportAdvanced);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
117
src/AcDream.App/Physics/LocalPlayerShadowSynchronizer.cs
Normal file
117
src/AcDream.App/Physics/LocalPlayerShadowSynchronizer.cs
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
using AcDream.App.Input;
|
||||
using AcDream.App.World;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.World;
|
||||
|
||||
namespace AcDream.App.Physics;
|
||||
|
||||
/// <summary>
|
||||
/// Owns publication and suspension of the local player's collision shadow.
|
||||
/// Identity/incarnation checks stay beside the cached shadow pose so player
|
||||
/// mode, projection, and teleport placement cannot publish different owners.
|
||||
/// </summary>
|
||||
internal sealed class LocalPlayerShadowSynchronizer
|
||||
{
|
||||
private readonly PhysicsEngine _physics;
|
||||
private readonly LiveEntityRuntime _liveEntities;
|
||||
private readonly ILocalPlayerIdentitySource _identity;
|
||||
private readonly LiveWorldOriginState _origin;
|
||||
private readonly LocalPlayerShadowState _state;
|
||||
|
||||
public LocalPlayerShadowSynchronizer(
|
||||
PhysicsEngine physics,
|
||||
LiveEntityRuntime liveEntities,
|
||||
ILocalPlayerIdentitySource identity,
|
||||
LiveWorldOriginState origin,
|
||||
LocalPlayerShadowState state)
|
||||
{
|
||||
_physics = physics ?? throw new ArgumentNullException(nameof(physics));
|
||||
_liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities));
|
||||
_identity = identity ?? throw new ArgumentNullException(nameof(identity));
|
||||
_origin = origin ?? throw new ArgumentNullException(nameof(origin));
|
||||
_state = state ?? throw new ArgumentNullException(nameof(state));
|
||||
}
|
||||
|
||||
public void Sync(WorldEntity playerEntity, uint cellId, bool force = false)
|
||||
=> SyncPose(
|
||||
playerEntity,
|
||||
playerEntity.Position,
|
||||
playerEntity.Rotation,
|
||||
cellId,
|
||||
force);
|
||||
|
||||
public LocalPlayerShadowState.Snapshot? Capture() => _state.Current;
|
||||
|
||||
public void SyncPose(
|
||||
WorldEntity playerEntity,
|
||||
System.Numerics.Vector3 position,
|
||||
System.Numerics.Quaternion orientation,
|
||||
uint cellId,
|
||||
bool force = false)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(playerEntity);
|
||||
if (_liveEntities.IsHidden(_identity.ServerGuid)
|
||||
|| cellId == 0
|
||||
|| !IsCurrentVisibleProjection(playerEntity))
|
||||
{
|
||||
Suspend(playerEntity);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!force
|
||||
&& _state.Current is { } last
|
||||
&& last.CellId == cellId
|
||||
&& System.Numerics.Vector3.DistanceSquared(
|
||||
last.Position,
|
||||
position) <= 1e-4f
|
||||
&& MathF.Abs(System.Numerics.Quaternion.Dot(
|
||||
last.Orientation,
|
||||
orientation)) >= 0.99999f)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ShadowPositionSynchronizer.Sync(
|
||||
_physics.ShadowObjects,
|
||||
playerEntity.Id,
|
||||
position,
|
||||
orientation,
|
||||
cellId,
|
||||
_origin.CenterX,
|
||||
_origin.CenterY);
|
||||
_state.Set(position, orientation, cellId);
|
||||
}
|
||||
|
||||
public void Restore(
|
||||
WorldEntity playerEntity,
|
||||
LocalPlayerShadowState.Snapshot? snapshot)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(playerEntity);
|
||||
if (snapshot is not { } prior || !IsCurrentVisibleProjection(playerEntity))
|
||||
{
|
||||
Suspend(playerEntity);
|
||||
return;
|
||||
}
|
||||
|
||||
SyncPose(
|
||||
playerEntity,
|
||||
prior.Position,
|
||||
prior.Orientation,
|
||||
prior.CellId,
|
||||
force: true);
|
||||
}
|
||||
|
||||
public bool IsCurrentVisibleProjection(WorldEntity playerEntity) =>
|
||||
_liveEntities.TryGetRecord(playerEntity.ServerGuid, out LiveEntityRecord record)
|
||||
&& ReferenceEquals(record.WorldEntity, playerEntity)
|
||||
&& _liveEntities.IsCurrentSpatialRootObject(record);
|
||||
|
||||
public void Suspend(WorldEntity playerEntity)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(playerEntity);
|
||||
_physics.ShadowObjects.Suspend(playerEntity.Id);
|
||||
_state.Clear();
|
||||
}
|
||||
|
||||
public void ResetSession() => _state.Clear();
|
||||
}
|
||||
|
|
@ -5,6 +5,11 @@ namespace AcDream.App.Rendering;
|
|||
|
||||
public sealed class CameraController
|
||||
{
|
||||
internal readonly record struct CameraState(
|
||||
int ModeCode,
|
||||
ChaseCamera? Chase,
|
||||
RetailChaseCamera? RetailChase);
|
||||
|
||||
public OrbitCamera Orbit { get; }
|
||||
public FlyCamera Fly { get; }
|
||||
public ChaseCamera? Chase { get; private set; }
|
||||
|
|
@ -81,4 +86,22 @@ public sealed class CameraController
|
|||
Orbit.Aspect = aspect;
|
||||
Fly.Aspect = aspect;
|
||||
}
|
||||
|
||||
internal CameraState CaptureState() =>
|
||||
new((int)_mode, Chase, RetailChase);
|
||||
|
||||
/// <summary>
|
||||
/// Restores a previously captured camera lifetime before notifying
|
||||
/// subscribers. State remains restored even when a subscriber throws.
|
||||
/// </summary>
|
||||
internal void RestoreState(CameraState state)
|
||||
{
|
||||
if (state.ModeCode < (int)Mode.Orbit || state.ModeCode > (int)Mode.Chase)
|
||||
throw new ArgumentOutOfRangeException(nameof(state));
|
||||
|
||||
Chase = state.Chase;
|
||||
RetailChase = state.RetailChase;
|
||||
_mode = (Mode)state.ModeCode;
|
||||
ModeChanged?.Invoke(IsFlyMode || IsChaseMode);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
761
src/AcDream.App/Streaming/LocalPlayerTeleportController.cs
Normal file
761
src/AcDream.App/Streaming/LocalPlayerTeleportController.cs
Normal file
|
|
@ -0,0 +1,761 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.Input;
|
||||
using AcDream.App.Net;
|
||||
using AcDream.App.Physics;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.App.Update;
|
||||
using AcDream.App.World;
|
||||
using AcDream.Core.Net;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.Rendering;
|
||||
using AcDream.Core.World;
|
||||
|
||||
namespace AcDream.App.Streaming;
|
||||
|
||||
internal interface ILocalPlayerTeleportNetworkSink
|
||||
{
|
||||
void OnTeleportStarted(uint sequence);
|
||||
|
||||
void OfferDestination(
|
||||
WorldSession.EntityPositionUpdate update,
|
||||
bool teleportTimestampAdvanced);
|
||||
|
||||
void ResetSession();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Construction-order bridge for the inbound session. It carries no teleport
|
||||
/// state: after binding, every packet and reset is forwarded to the one
|
||||
/// <see cref="LocalPlayerTeleportController"/> owner.
|
||||
/// </summary>
|
||||
internal sealed class DeferredLocalPlayerTeleportNetworkSink
|
||||
: ILocalPlayerTeleportNetworkSink
|
||||
{
|
||||
private ILocalPlayerTeleportNetworkSink? _inner;
|
||||
|
||||
public void Bind(ILocalPlayerTeleportNetworkSink inner)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(inner);
|
||||
if (Interlocked.CompareExchange(ref _inner, inner, null) is not null)
|
||||
throw new InvalidOperationException("The local teleport sink is already bound.");
|
||||
}
|
||||
|
||||
public void OnTeleportStarted(uint sequence) => Required().OnTeleportStarted(sequence);
|
||||
|
||||
public void OfferDestination(
|
||||
WorldSession.EntityPositionUpdate update,
|
||||
bool teleportTimestampAdvanced) =>
|
||||
Required().OfferDestination(update, teleportTimestampAdvanced);
|
||||
|
||||
public void ResetSession() => Required().ResetSession();
|
||||
|
||||
private ILocalPlayerTeleportNetworkSink Required() =>
|
||||
_inner ?? throw new InvalidOperationException(
|
||||
"The local teleport sink was used before composition completed.");
|
||||
}
|
||||
|
||||
internal interface ILocalPlayerTeleportInputLifetime
|
||||
{
|
||||
void EndMouseLook();
|
||||
}
|
||||
|
||||
internal interface ILocalPlayerTeleportModeOperations
|
||||
{
|
||||
PlayerMovementController? Controller { get; }
|
||||
Matrix4x4 Projection { get; }
|
||||
bool TryEnterPortalSpace();
|
||||
void EnterWorld();
|
||||
}
|
||||
|
||||
internal interface ILocalPlayerTeleportAuthority
|
||||
{
|
||||
bool IsFreshStart(ushort sequence);
|
||||
}
|
||||
|
||||
internal sealed class LiveLocalPlayerTeleportAuthority
|
||||
: ILocalPlayerTeleportAuthority
|
||||
{
|
||||
private readonly LiveEntityRuntime _liveEntities;
|
||||
private readonly ILocalPlayerIdentitySource _identity;
|
||||
|
||||
public LiveLocalPlayerTeleportAuthority(
|
||||
LiveEntityRuntime liveEntities,
|
||||
ILocalPlayerIdentitySource identity)
|
||||
{
|
||||
_liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities));
|
||||
_identity = identity ?? throw new ArgumentNullException(nameof(identity));
|
||||
}
|
||||
|
||||
public bool IsFreshStart(ushort sequence) =>
|
||||
_liveEntities.IsFreshTeleportStart(_identity.ServerGuid, sequence);
|
||||
}
|
||||
|
||||
internal interface ILocalPlayerTeleportStreamingOperations
|
||||
{
|
||||
int CenterX { get; }
|
||||
int CenterY { get; }
|
||||
bool IsRecenterPending { get; }
|
||||
bool BeginRecenter(int x, int y, bool isSealedDungeon);
|
||||
bool ResetRecenter(bool sessionEnding);
|
||||
bool IsSealedDungeon(uint cellId);
|
||||
void SetPriority(uint landblockId, int radius);
|
||||
void ClearPriority();
|
||||
}
|
||||
|
||||
internal sealed class LocalPlayerTeleportStreamingOperations
|
||||
: ILocalPlayerTeleportStreamingOperations
|
||||
{
|
||||
private readonly LiveWorldOriginState _origin;
|
||||
private readonly StreamingOriginRecenterCoordinator _recenter;
|
||||
private readonly StreamingController _streaming;
|
||||
private readonly ISealedDungeonCellClassifier _sealedDungeonCells;
|
||||
|
||||
public LocalPlayerTeleportStreamingOperations(
|
||||
LiveWorldOriginState origin,
|
||||
StreamingOriginRecenterCoordinator recenter,
|
||||
StreamingController streaming,
|
||||
ISealedDungeonCellClassifier sealedDungeonCells)
|
||||
{
|
||||
_origin = origin ?? throw new ArgumentNullException(nameof(origin));
|
||||
_recenter = recenter ?? throw new ArgumentNullException(nameof(recenter));
|
||||
_streaming = streaming ?? throw new ArgumentNullException(nameof(streaming));
|
||||
_sealedDungeonCells = sealedDungeonCells
|
||||
?? throw new ArgumentNullException(nameof(sealedDungeonCells));
|
||||
}
|
||||
|
||||
public int CenterX => _origin.CenterX;
|
||||
public int CenterY => _origin.CenterY;
|
||||
public bool IsRecenterPending => _recenter.IsPending;
|
||||
|
||||
public bool BeginRecenter(int x, int y, bool isSealedDungeon) =>
|
||||
_recenter.Begin(x, y, isSealedDungeon);
|
||||
|
||||
public bool ResetRecenter(bool sessionEnding) =>
|
||||
_recenter.Reset(sessionEnding);
|
||||
|
||||
public bool IsSealedDungeon(uint cellId) =>
|
||||
_sealedDungeonCells.IsSealedDungeon(cellId);
|
||||
|
||||
public void SetPriority(uint landblockId, int radius)
|
||||
{
|
||||
_streaming.PriorityLandblockId = landblockId;
|
||||
_streaming.PriorityRadius = radius;
|
||||
}
|
||||
|
||||
public void ClearPriority()
|
||||
{
|
||||
_streaming.PriorityLandblockId = 0u;
|
||||
_streaming.PriorityRadius = 0;
|
||||
}
|
||||
}
|
||||
|
||||
internal interface ILocalPlayerTeleportPlacement
|
||||
{
|
||||
void Place(Vector3 position, uint cellId, Quaternion rotation, bool forced);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Commits the local player's deferred portal arrival. It owns the exact
|
||||
/// Place -> root/controller/camera mutation -> spatial reconcile edge.
|
||||
/// </summary>
|
||||
internal sealed class LocalPlayerTeleportPlacement : ILocalPlayerTeleportPlacement
|
||||
{
|
||||
private readonly PhysicsEngine _physics;
|
||||
private readonly LiveEntityRuntime _liveEntities;
|
||||
private readonly ILocalPlayerIdentitySource _identity;
|
||||
private readonly ILocalPlayerControllerSource _controller;
|
||||
private readonly ILocalPlayerPhysicsHostSource _host;
|
||||
private readonly ChaseCameraInputState _cameras;
|
||||
private readonly LiveWorldOriginState _origin;
|
||||
private readonly ILiveSpatialReconcilePhase _spatial;
|
||||
|
||||
public LocalPlayerTeleportPlacement(
|
||||
PhysicsEngine physics,
|
||||
LiveEntityRuntime liveEntities,
|
||||
ILocalPlayerIdentitySource identity,
|
||||
ILocalPlayerControllerSource controller,
|
||||
ILocalPlayerPhysicsHostSource host,
|
||||
ChaseCameraInputState cameras,
|
||||
LiveWorldOriginState origin,
|
||||
ILiveSpatialReconcilePhase spatial)
|
||||
{
|
||||
_physics = physics ?? throw new ArgumentNullException(nameof(physics));
|
||||
_liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities));
|
||||
_identity = identity ?? throw new ArgumentNullException(nameof(identity));
|
||||
_controller = controller ?? throw new ArgumentNullException(nameof(controller));
|
||||
_host = host ?? throw new ArgumentNullException(nameof(host));
|
||||
_cameras = cameras ?? throw new ArgumentNullException(nameof(cameras));
|
||||
_origin = origin ?? throw new ArgumentNullException(nameof(origin));
|
||||
_spatial = spatial ?? throw new ArgumentNullException(nameof(spatial));
|
||||
}
|
||||
|
||||
public void Place(Vector3 position, uint cellId, Quaternion rotation, bool forced)
|
||||
{
|
||||
PlayerMovementController controller = _controller.Controller
|
||||
?? throw new InvalidOperationException(
|
||||
"Teleport Place ran without the local player controller.");
|
||||
var resolved = _physics.Resolve(
|
||||
position,
|
||||
cellId,
|
||||
Vector3.Zero,
|
||||
controller.StepUpHeight);
|
||||
var snapped = new 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{cellId:X8} pos={position} -> 0x{resolved.CellId:X8} {snapped}");
|
||||
}
|
||||
|
||||
uint playerGuid = _identity.ServerGuid;
|
||||
controller.SetPosition(
|
||||
snapped,
|
||||
resolved.CellId,
|
||||
CellLocalForSeed(snapped, resolved.CellId));
|
||||
|
||||
// SnapToCell owns the retail Position frame and may normalize an
|
||||
// outdoor land-cell index from the cell-local origin. Publish that
|
||||
// canonical result, not the pre-snap resolver hint, to rendering.
|
||||
if (_liveEntities.MaterializedWorldEntities.TryGetValue(
|
||||
playerGuid,
|
||||
out WorldEntity? entity))
|
||||
{
|
||||
entity.SetPosition(controller.Position);
|
||||
entity.ParentCellId = controller.CellId;
|
||||
entity.Rotation = rotation;
|
||||
}
|
||||
|
||||
// Retail teleport_hook tail @ 0x00514ED0 clears the local target and
|
||||
// notifies every watcher that this object teleported.
|
||||
_host.Host?.NotifyTeleported();
|
||||
controller.SetBodyOrientation(rotation);
|
||||
|
||||
_cameras.Legacy?.Update(controller.Position, controller.Yaw);
|
||||
_cameras.Retail?.ResetViewerToPlayer(controller.Position, controller.Yaw);
|
||||
_spatial.Reconcile();
|
||||
|
||||
PhysicsDiagnostics.LogTeleport(
|
||||
"PLACED",
|
||||
controller.CellId,
|
||||
$"forced={forced}");
|
||||
Console.WriteLine(
|
||||
$"live: teleport materialized - snapped to {controller.Position} "
|
||||
+ $"cell=0x{controller.CellId:X8}");
|
||||
}
|
||||
|
||||
private Vector3 CellLocalForSeed(Vector3 worldPosition, uint cellId)
|
||||
{
|
||||
int landblockX = (int)((cellId >> 24) & 0xFFu);
|
||||
int landblockY = (int)((cellId >> 16) & 0xFFu);
|
||||
var origin = new Vector3(
|
||||
(landblockX - _origin.CenterX) * 192f,
|
||||
(landblockY - _origin.CenterY) * 192f,
|
||||
0f);
|
||||
return worldPosition - origin;
|
||||
}
|
||||
}
|
||||
|
||||
internal interface ILocalPlayerTeleportSession
|
||||
{
|
||||
void SendLoginComplete();
|
||||
}
|
||||
|
||||
internal sealed class LocalPlayerTeleportSession : ILocalPlayerTeleportSession
|
||||
{
|
||||
private readonly LiveSessionController _session;
|
||||
|
||||
public LocalPlayerTeleportSession(LiveSessionController session) =>
|
||||
_session = session ?? throw new ArgumentNullException(nameof(session));
|
||||
|
||||
public void SendLoginComplete() =>
|
||||
_session.CurrentSession?.SendGameAction(
|
||||
GameActionLoginComplete.Build());
|
||||
}
|
||||
|
||||
internal interface ILocalPlayerTeleportPresentation : IDisposable
|
||||
{
|
||||
bool IsPortalViewportVisible { get; }
|
||||
int CurrentTunnelFrame { get; }
|
||||
void Begin(Matrix4x4 projection);
|
||||
(TeleportAnimSnapshot Snapshot, IReadOnlyList<TeleportAnimEvent> Events)
|
||||
Tick(float deltaSeconds, bool worldReady);
|
||||
void TickTunnel(float deltaSeconds);
|
||||
void EnterTunnel();
|
||||
void ExitTunnel();
|
||||
void Reset();
|
||||
Matrix4x4 ApplyViewPlane(Matrix4x4 projection);
|
||||
ICamera ApplyViewPlane(ICamera camera);
|
||||
void DrawPortalViewport(int width, int height, Matrix4x4 projection);
|
||||
}
|
||||
|
||||
internal sealed class LocalPlayerTeleportPresentation
|
||||
: ILocalPlayerTeleportPresentation
|
||||
{
|
||||
private readonly TeleportAnimSequencer _animation = new();
|
||||
private readonly TeleportViewPlaneController _viewPlane = new();
|
||||
private readonly PortalTunnelPresentation _tunnel;
|
||||
|
||||
public LocalPlayerTeleportPresentation(PortalTunnelPresentation tunnel) =>
|
||||
_tunnel = tunnel ?? throw new ArgumentNullException(nameof(tunnel));
|
||||
|
||||
public bool IsPortalViewportVisible => _tunnel.IsVisible;
|
||||
public int CurrentTunnelFrame => _tunnel.CurrentAnimationFrame;
|
||||
|
||||
public void Begin(Matrix4x4 projection)
|
||||
{
|
||||
_viewPlane.Begin(projection);
|
||||
_animation.Begin(TeleportEntryKind.Portal);
|
||||
}
|
||||
|
||||
public (TeleportAnimSnapshot Snapshot, IReadOnlyList<TeleportAnimEvent> Events)
|
||||
Tick(float deltaSeconds, bool worldReady)
|
||||
{
|
||||
var (snapshot, events) = _animation.Tick(
|
||||
deltaSeconds,
|
||||
worldReady,
|
||||
CurrentTunnelFrame);
|
||||
_viewPlane.Update(snapshot);
|
||||
return (snapshot, events);
|
||||
}
|
||||
|
||||
public void TickTunnel(float deltaSeconds) => _tunnel.Tick(deltaSeconds);
|
||||
public void EnterTunnel() => _tunnel.Enter();
|
||||
public void ExitTunnel() => _tunnel.Exit();
|
||||
|
||||
public void Reset()
|
||||
{
|
||||
_animation.Reset();
|
||||
_viewPlane.Reset();
|
||||
_tunnel.Exit();
|
||||
}
|
||||
|
||||
public Matrix4x4 ApplyViewPlane(Matrix4x4 projection) =>
|
||||
_viewPlane.Apply(projection);
|
||||
|
||||
public ICamera ApplyViewPlane(ICamera camera) => _viewPlane.ApplyTo(camera);
|
||||
|
||||
public void DrawPortalViewport(int width, int height, Matrix4x4 projection) =>
|
||||
_tunnel.Draw(width, height, ApplyViewPlane(projection));
|
||||
|
||||
public void Dispose() => _tunnel.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Single update-thread owner of the local F751/Position-correlated portal
|
||||
/// lifetime: activation, destination aim, readiness hold, Place, viewport
|
||||
/// transitions, LoginComplete, and session/reset symmetry.
|
||||
/// </summary>
|
||||
internal sealed class LocalPlayerTeleportController
|
||||
: ILocalPlayerTeleportFramePhase,
|
||||
ILocalPlayerTeleportNetworkSink,
|
||||
IDisposable
|
||||
{
|
||||
internal const float MaximumWorldHoldSeconds = 10f;
|
||||
internal const int NearRingRadius =
|
||||
WorldRevealReadinessBarrier.OutdoorNeighborhoodRadius;
|
||||
|
||||
private readonly TeleportTransitCoordinator<WorldSession.EntityPositionUpdate> _transit = new();
|
||||
private readonly ILocalPlayerTeleportAuthority _authority;
|
||||
private readonly ILocalPlayerTeleportInputLifetime _input;
|
||||
private readonly ILocalPlayerTeleportModeOperations _mode;
|
||||
private readonly ILocalPlayerTeleportStreamingOperations _streaming;
|
||||
private readonly WorldRevealCoordinator _worldReveal;
|
||||
private readonly ILocalPlayerTeleportPlacement _placement;
|
||||
private readonly ILocalPlayerTeleportSession _session;
|
||||
private readonly ILocalPlayerTeleportPresentation _presentation;
|
||||
|
||||
private Vector3 _pendingPosition;
|
||||
private uint _pendingCell;
|
||||
private Quaternion _pendingRotation = Quaternion.Identity;
|
||||
private bool _hasAcceptedDestination;
|
||||
private WorldSession.EntityPositionUpdate _acceptedDestination;
|
||||
private float _holdSeconds;
|
||||
private bool _forced;
|
||||
private long _lifetimeGeneration;
|
||||
private bool _disposed;
|
||||
|
||||
public LocalPlayerTeleportController(
|
||||
ILocalPlayerTeleportAuthority authority,
|
||||
ILocalPlayerTeleportInputLifetime input,
|
||||
ILocalPlayerTeleportModeOperations mode,
|
||||
ILocalPlayerTeleportStreamingOperations streaming,
|
||||
WorldRevealCoordinator worldReveal,
|
||||
ILocalPlayerTeleportPlacement placement,
|
||||
ILocalPlayerTeleportSession session,
|
||||
ILocalPlayerTeleportPresentation presentation)
|
||||
{
|
||||
_authority = authority ?? throw new ArgumentNullException(nameof(authority));
|
||||
_input = input ?? throw new ArgumentNullException(nameof(input));
|
||||
_mode = mode ?? throw new ArgumentNullException(nameof(mode));
|
||||
_streaming = streaming ?? throw new ArgumentNullException(nameof(streaming));
|
||||
_worldReveal = worldReveal ?? throw new ArgumentNullException(nameof(worldReveal));
|
||||
_placement = placement ?? throw new ArgumentNullException(nameof(placement));
|
||||
_session = session ?? throw new ArgumentNullException(nameof(session));
|
||||
_presentation = presentation ?? throw new ArgumentNullException(nameof(presentation));
|
||||
}
|
||||
|
||||
public bool IsActive => _transit.IsActive;
|
||||
public bool IsPortalViewportVisible => _presentation.IsPortalViewportVisible;
|
||||
public uint ActiveDestinationCell => _transit.IsActive ? _pendingCell : 0u;
|
||||
|
||||
public void OnTeleportStarted(uint sequence)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
ushort teleportSequence = (ushort)sequence;
|
||||
if (!_authority.IsFreshStart(teleportSequence)
|
||||
|| !_transit.CanBegin(teleportSequence))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
long observedGeneration = _lifetimeGeneration;
|
||||
_input.EndMouseLook();
|
||||
if (_lifetimeGeneration != observedGeneration)
|
||||
return;
|
||||
|
||||
long resetGeneration = ResetTransit(clearSession: false);
|
||||
if (_lifetimeGeneration != resetGeneration)
|
||||
return;
|
||||
|
||||
_transit.QueueStart(teleportSequence);
|
||||
TryActivatePendingPresentation();
|
||||
Console.WriteLine($"live: teleport queued (seq={sequence})");
|
||||
}
|
||||
|
||||
public void OfferDestination(
|
||||
WorldSession.EntityPositionUpdate update,
|
||||
bool teleportTimestampAdvanced)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
if (_transit.OfferDestination(
|
||||
update.TeleportSequence,
|
||||
teleportTimestampAdvanced,
|
||||
update,
|
||||
out WorldSession.EntityPositionUpdate destination))
|
||||
{
|
||||
AcceptDestination(destination);
|
||||
}
|
||||
}
|
||||
|
||||
public void Tick(float deltaSeconds)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
TryActivatePendingPresentation();
|
||||
TryAimAcceptedDestination();
|
||||
if (!_transit.IsActive)
|
||||
return;
|
||||
|
||||
long generation = _lifetimeGeneration;
|
||||
ushort sequence = _transit.Sequence;
|
||||
if (!_mode.TryEnterPortalSpace()
|
||||
|| !IsCurrentLifetime(generation, sequence)
|
||||
|| _mode.Controller is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
bool haveDestination = _pendingCell != 0u;
|
||||
bool originReady = !_streaming.IsRecenterPending;
|
||||
bool ready = haveDestination
|
||||
&& originReady
|
||||
&& _worldReveal.Evaluate(_pendingCell).IsReady;
|
||||
if (!IsCurrentLifetime(generation, sequence))
|
||||
return;
|
||||
|
||||
if (haveDestination && originReady && !ready)
|
||||
{
|
||||
_holdSeconds += deltaSeconds;
|
||||
if (_holdSeconds >= MaximumWorldHoldSeconds)
|
||||
{
|
||||
ready = true;
|
||||
_forced = true;
|
||||
}
|
||||
}
|
||||
|
||||
var (_, events) = _presentation.Tick(deltaSeconds, ready);
|
||||
if (!IsCurrentLifetime(generation, sequence))
|
||||
return;
|
||||
|
||||
foreach (TeleportAnimEvent teleportEvent in events)
|
||||
{
|
||||
switch (teleportEvent)
|
||||
{
|
||||
case TeleportAnimEvent.Place:
|
||||
_placement.Place(
|
||||
_pendingPosition,
|
||||
_pendingCell,
|
||||
_pendingRotation,
|
||||
_forced);
|
||||
if (!IsCurrentLifetime(generation, sequence))
|
||||
return;
|
||||
_worldReveal.ObserveMaterialized();
|
||||
if (!IsCurrentLifetime(generation, sequence))
|
||||
return;
|
||||
_streaming.ClearPriority();
|
||||
if (!IsCurrentLifetime(generation, sequence))
|
||||
return;
|
||||
break;
|
||||
case TeleportAnimEvent.EnterTunnel:
|
||||
_presentation.EnterTunnel();
|
||||
if (!IsCurrentLifetime(generation, sequence))
|
||||
return;
|
||||
break;
|
||||
case TeleportAnimEvent.PlayExitSound:
|
||||
_presentation.ExitTunnel();
|
||||
if (!IsCurrentLifetime(generation, sequence))
|
||||
return;
|
||||
break;
|
||||
case TeleportAnimEvent.FireLoginComplete:
|
||||
_mode.EnterWorld();
|
||||
if (!IsCurrentLifetime(generation, sequence))
|
||||
return;
|
||||
_session.SendLoginComplete();
|
||||
if (!IsCurrentLifetime(generation, sequence))
|
||||
return;
|
||||
_worldReveal.Complete();
|
||||
if (!IsCurrentLifetime(generation, sequence))
|
||||
return;
|
||||
ResetTransit(clearSession: false);
|
||||
return;
|
||||
default:
|
||||
// ClientUISystem sound-table enum playback remains outside
|
||||
// this already-accepted presentation extraction.
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
_presentation.TickTunnel(deltaSeconds);
|
||||
}
|
||||
|
||||
public void ResetSession()
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
ResetTransit(clearSession: true);
|
||||
}
|
||||
|
||||
public Matrix4x4 ApplyViewPlane(Matrix4x4 projection) =>
|
||||
_presentation.ApplyViewPlane(projection);
|
||||
|
||||
public ICamera ApplyViewPlane(ICamera camera) =>
|
||||
_presentation.ApplyViewPlane(camera);
|
||||
|
||||
public void DrawPortalViewport(int width, int height, Matrix4x4 projection) =>
|
||||
_presentation.DrawPortalViewport(width, height, projection);
|
||||
|
||||
private void TryActivatePendingPresentation()
|
||||
{
|
||||
if (!_transit.HasPendingStart)
|
||||
return;
|
||||
|
||||
long generation = _lifetimeGeneration;
|
||||
if (!_mode.TryEnterPortalSpace()
|
||||
|| _lifetimeGeneration != generation
|
||||
|| !_transit.HasPendingStart)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_holdSeconds = 0f;
|
||||
_forced = false;
|
||||
_presentation.Begin(_mode.Projection);
|
||||
if (_lifetimeGeneration != generation || !_transit.HasPendingStart)
|
||||
return;
|
||||
|
||||
bool hasBufferedDestination = _transit.Activate(out var destination);
|
||||
if (hasBufferedDestination)
|
||||
AcceptDestination(destination);
|
||||
Console.WriteLine(
|
||||
$"live: teleport presentation started (seq={_transit.Sequence})");
|
||||
}
|
||||
|
||||
private void AcceptDestination(WorldSession.EntityPositionUpdate destination)
|
||||
{
|
||||
_acceptedDestination = destination;
|
||||
_hasAcceptedDestination = true;
|
||||
TryAimAcceptedDestination();
|
||||
}
|
||||
|
||||
private void TryAimAcceptedDestination()
|
||||
{
|
||||
if (!_hasAcceptedDestination || !_transit.IsActive)
|
||||
return;
|
||||
|
||||
long generation = _lifetimeGeneration;
|
||||
ushort sequence = _transit.Sequence;
|
||||
PlayerMovementController? controller = _mode.Controller;
|
||||
if (controller is null)
|
||||
{
|
||||
if (!_mode.TryEnterPortalSpace()
|
||||
|| !IsCurrentLifetime(generation, sequence))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
controller = _mode.Controller;
|
||||
if (controller is null)
|
||||
return;
|
||||
}
|
||||
|
||||
WorldSession.EntityPositionUpdate destination = _acceptedDestination;
|
||||
if (!AimDestination(destination, controller, generation, sequence))
|
||||
return;
|
||||
|
||||
if (IsCurrentLifetime(generation, sequence))
|
||||
{
|
||||
_hasAcceptedDestination = false;
|
||||
_acceptedDestination = default;
|
||||
}
|
||||
}
|
||||
|
||||
private bool AimDestination(
|
||||
WorldSession.EntityPositionUpdate update,
|
||||
PlayerMovementController controller,
|
||||
long generation,
|
||||
ushort sequence)
|
||||
{
|
||||
var position = update.Position;
|
||||
int landblockX = (int)((position.LandblockId >> 24) & 0xFFu);
|
||||
int landblockY = (int)((position.LandblockId >> 16) & 0xFFu);
|
||||
uint streamingOriginLandblockId = StreamingRegion.EncodeLandblockId(
|
||||
_streaming.CenterX,
|
||||
_streaming.CenterY);
|
||||
var origin = new Vector3(
|
||||
(landblockX - _streaming.CenterX) * 192f,
|
||||
(landblockY - _streaming.CenterY) * 192f,
|
||||
0f);
|
||||
var translated = new Vector3(
|
||||
position.PositionX,
|
||||
position.PositionY,
|
||||
position.PositionZ) + origin;
|
||||
|
||||
TeleportLandblockTransition transition = TeleportLandblockTransition.Classify(
|
||||
controller.CellId,
|
||||
position.LandblockId,
|
||||
streamingOriginLandblockId);
|
||||
int oldX = (int)((transition.SourceLandblockId >> 24) & 0xFFu);
|
||||
int oldY = (int)((transition.SourceLandblockId >> 16) & 0xFFu);
|
||||
|
||||
Console.WriteLine(
|
||||
$"live: teleport arrival - old lb=({oldX},{oldY}) "
|
||||
+ $"new lb=({landblockX},{landblockY}) "
|
||||
+ $"dist={Vector3.Distance(translated, controller.Position):F1}");
|
||||
|
||||
Vector3 worldPosition;
|
||||
if (transition.ChangesStreamingCenter)
|
||||
{
|
||||
bool isSealedDungeon = _streaming.IsSealedDungeon(
|
||||
position.LandblockId);
|
||||
if (!IsCurrentLifetime(generation, sequence))
|
||||
return false;
|
||||
|
||||
_streaming.BeginRecenter(
|
||||
landblockX,
|
||||
landblockY,
|
||||
isSealedDungeon);
|
||||
if (!IsCurrentLifetime(generation, sequence))
|
||||
return false;
|
||||
worldPosition = new Vector3(
|
||||
position.PositionX,
|
||||
position.PositionY,
|
||||
position.PositionZ);
|
||||
}
|
||||
else
|
||||
{
|
||||
worldPosition = translated;
|
||||
}
|
||||
|
||||
_pendingRotation = new Quaternion(
|
||||
position.RotationX,
|
||||
position.RotationY,
|
||||
position.RotationZ,
|
||||
position.RotationW);
|
||||
_pendingPosition = worldPosition;
|
||||
_pendingCell = position.LandblockId;
|
||||
_holdSeconds = 0f;
|
||||
_forced = false;
|
||||
_worldReveal.Begin(WorldRevealKind.Portal);
|
||||
if (!IsCurrentLifetime(generation, sequence))
|
||||
return false;
|
||||
_streaming.SetPriority(
|
||||
StreamingRegion.EncodeLandblockId(landblockX, landblockY),
|
||||
NearRingRadius);
|
||||
if (!IsCurrentLifetime(generation, sequence))
|
||||
return false;
|
||||
|
||||
PhysicsDiagnostics.LogTeleport(
|
||||
"AIM",
|
||||
position.LandblockId,
|
||||
$"seq={update.TeleportSequence} lb={landblockX},{landblockY} "
|
||||
+ $"indoor={((position.LandblockId & 0xFFFFu) >= 0x0100u)} "
|
||||
+ $"playerCross={transition.CrossesLandblock} "
|
||||
+ $"centerChange={transition.ChangesStreamingCenter}");
|
||||
return true;
|
||||
}
|
||||
|
||||
private long ResetTransit(bool clearSession)
|
||||
{
|
||||
long generation = checked(++_lifetimeGeneration);
|
||||
if (clearSession)
|
||||
{
|
||||
_transit.ClearSession();
|
||||
}
|
||||
else
|
||||
{
|
||||
_transit.EndActive();
|
||||
}
|
||||
|
||||
_pendingPosition = default;
|
||||
_pendingCell = 0u;
|
||||
_pendingRotation = Quaternion.Identity;
|
||||
_hasAcceptedDestination = false;
|
||||
_acceptedDestination = default;
|
||||
_holdSeconds = 0f;
|
||||
_forced = false;
|
||||
|
||||
bool originConverged = _streaming.ResetRecenter(clearSession);
|
||||
if (_lifetimeGeneration != generation)
|
||||
return generation;
|
||||
|
||||
if (clearSession)
|
||||
{
|
||||
_worldReveal.Cancel();
|
||||
if (_lifetimeGeneration != generation)
|
||||
return generation;
|
||||
}
|
||||
|
||||
_presentation.Reset();
|
||||
if (_lifetimeGeneration != generation)
|
||||
return generation;
|
||||
|
||||
_streaming.ClearPriority();
|
||||
|
||||
if (clearSession && !originConverged)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"The ending session's streaming-origin retirement has not converged.");
|
||||
}
|
||||
|
||||
return generation;
|
||||
}
|
||||
|
||||
private bool IsCurrentLifetime(long generation, ushort sequence) =>
|
||||
_lifetimeGeneration == generation
|
||||
&& _transit.IsActive
|
||||
&& _transit.Sequence == sequence;
|
||||
|
||||
private void ThrowIfDisposed() =>
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
_presentation.Dispose();
|
||||
_disposed = true;
|
||||
}
|
||||
}
|
||||
44
src/AcDream.App/Streaming/SealedDungeonCellClassifier.cs
Normal file
44
src/AcDream.App/Streaming/SealedDungeonCellClassifier.cs
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
using DatReaderWriter;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using DatReaderWriter.Enums;
|
||||
using AcDream.Content;
|
||||
|
||||
namespace AcDream.App.Streaming;
|
||||
|
||||
internal interface ISealedDungeonCellClassifier
|
||||
{
|
||||
bool IsSealedDungeon(uint cellId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Classifies a server-supplied full cell ID using the EnvCell's retail
|
||||
/// <see cref="EnvCellFlags.SeenOutside"/> bit. Missing, outdoor, and structural
|
||||
/// shell IDs are never guessed to be sealed.
|
||||
/// </summary>
|
||||
internal sealed class DatSealedDungeonCellClassifier
|
||||
: ISealedDungeonCellClassifier
|
||||
{
|
||||
private readonly IDatReaderWriter _dats;
|
||||
private readonly object _datLock;
|
||||
|
||||
public DatSealedDungeonCellClassifier(
|
||||
IDatReaderWriter dats,
|
||||
object datLock)
|
||||
{
|
||||
_dats = dats ?? throw new ArgumentNullException(nameof(dats));
|
||||
_datLock = datLock ?? throw new ArgumentNullException(nameof(datLock));
|
||||
}
|
||||
|
||||
public bool IsSealedDungeon(uint cellId)
|
||||
{
|
||||
uint low = cellId & 0xFFFFu;
|
||||
if (low < 0x0100u || low >= 0xFFFEu)
|
||||
return false;
|
||||
|
||||
EnvCell? envCell;
|
||||
lock (_datLock)
|
||||
envCell = _dats.Get<EnvCell>(cellId);
|
||||
return envCell is not null
|
||||
&& !envCell.Flags.HasFlag(EnvCellFlags.SeenOutside);
|
||||
}
|
||||
}
|
||||
|
|
@ -114,7 +114,7 @@ internal sealed class LiveEntityWorldOriginCoordinator : ILiveEntityWorldOriginC
|
|||
private readonly GpuWorldState _worldState;
|
||||
private readonly WorldRevealCoordinator _worldReveal;
|
||||
private readonly Func<uint> _playerGuid;
|
||||
private readonly Func<uint, bool> _isSealedDungeonCell;
|
||||
private readonly ISealedDungeonCellClassifier _sealedDungeonCells;
|
||||
private readonly Action<string>? _diagnostic;
|
||||
|
||||
public LiveEntityWorldOriginCoordinator(
|
||||
|
|
@ -123,7 +123,7 @@ internal sealed class LiveEntityWorldOriginCoordinator : ILiveEntityWorldOriginC
|
|||
GpuWorldState worldState,
|
||||
WorldRevealCoordinator worldReveal,
|
||||
Func<uint> playerGuid,
|
||||
Func<uint, bool> isSealedDungeonCell,
|
||||
ISealedDungeonCellClassifier sealedDungeonCells,
|
||||
Action<string>? diagnostic = null)
|
||||
{
|
||||
_origin = origin ?? throw new ArgumentNullException(nameof(origin));
|
||||
|
|
@ -131,8 +131,8 @@ internal sealed class LiveEntityWorldOriginCoordinator : ILiveEntityWorldOriginC
|
|||
_worldState = worldState ?? throw new ArgumentNullException(nameof(worldState));
|
||||
_worldReveal = worldReveal ?? throw new ArgumentNullException(nameof(worldReveal));
|
||||
_playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid));
|
||||
_isSealedDungeonCell = isSealedDungeonCell
|
||||
?? throw new ArgumentNullException(nameof(isSealedDungeonCell));
|
||||
_sealedDungeonCells = sealedDungeonCells
|
||||
?? throw new ArgumentNullException(nameof(sealedDungeonCells));
|
||||
_diagnostic = diagnostic;
|
||||
}
|
||||
|
||||
|
|
@ -164,7 +164,8 @@ internal sealed class LiveEntityWorldOriginCoordinator : ILiveEntityWorldOriginC
|
|||
_streaming.InitializeKnownLoginCenter(
|
||||
lbX,
|
||||
lbY,
|
||||
isSealedDungeon: _isSealedDungeonCell(position.LandblockId));
|
||||
isSealedDungeon: _sealedDungeonCells.IsSealedDungeon(
|
||||
position.LandblockId));
|
||||
_worldReveal.Begin(WorldRevealKind.Login);
|
||||
|
||||
return new(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue