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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue