refactor(input): own gameplay action routing
Move the sole semantic action-priority graph, combat and diagnostic commands, and retained-root item-drop lifetime behind focused typed owners. Preserve retail toggle behavior, explicit auto-wield cancellation, shutdown quiescence, and symmetric callback cleanup. Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
parent
8b8afeefa3
commit
4eae9b4f5a
25 changed files with 2608 additions and 418 deletions
214
src/AcDream.App/Combat/LiveCombatModeCommandController.cs
Normal file
214
src/AcDream.App/Combat/LiveCombatModeCommandController.cs
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
using AcDream.App.Input;
|
||||
using AcDream.App.Net;
|
||||
using AcDream.App.UI;
|
||||
using AcDream.Core.Combat;
|
||||
using AcDream.Core.Items;
|
||||
|
||||
namespace AcDream.App.Combat;
|
||||
|
||||
internal interface ILiveCombatModeAuthority
|
||||
{
|
||||
bool IsInWorld { get; }
|
||||
|
||||
void SendChangeCombatMode(CombatMode mode);
|
||||
}
|
||||
|
||||
internal sealed class LiveSessionCombatModeAuthority(LiveSessionHost session)
|
||||
: ILiveCombatModeAuthority
|
||||
{
|
||||
private readonly LiveSessionHost _session = session
|
||||
?? throw new ArgumentNullException(nameof(session));
|
||||
|
||||
public bool IsInWorld =>
|
||||
_session.IsInWorld && _session.CurrentSession is not null;
|
||||
|
||||
public void SendChangeCombatMode(CombatMode mode)
|
||||
{
|
||||
if (!IsInWorld)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"A combat-mode request requires an active in-world session.");
|
||||
}
|
||||
|
||||
_session.CurrentSession!.SendChangeCombatMode(mode);
|
||||
}
|
||||
}
|
||||
|
||||
internal interface ICombatEquipmentSource
|
||||
{
|
||||
IReadOnlyList<ClientObject> GetOrderedEquipment();
|
||||
}
|
||||
|
||||
internal sealed class LocalPlayerCombatEquipmentSource(
|
||||
ClientObjectTable objects,
|
||||
ILocalPlayerIdentitySource identity) : ICombatEquipmentSource
|
||||
{
|
||||
private readonly ClientObjectTable _objects = objects
|
||||
?? throw new ArgumentNullException(nameof(objects));
|
||||
private readonly ILocalPlayerIdentitySource _identity = identity
|
||||
?? throw new ArgumentNullException(nameof(identity));
|
||||
|
||||
public IReadOnlyList<ClientObject> GetOrderedEquipment() =>
|
||||
_objects.GetEquippedBy(_identity.ServerGuid);
|
||||
}
|
||||
|
||||
internal interface IExplicitCombatModeIntentSink
|
||||
{
|
||||
void NotifyExplicitCombatModeRequest();
|
||||
}
|
||||
|
||||
internal sealed class ItemInteractionCombatModeIntentSink(
|
||||
ItemInteractionController items) : IExplicitCombatModeIntentSink
|
||||
{
|
||||
private readonly ItemInteractionController _items = items
|
||||
?? throw new ArgumentNullException(nameof(items));
|
||||
|
||||
public void NotifyExplicitCombatModeRequest() =>
|
||||
_items.NotifyExplicitCombatModeRequest();
|
||||
}
|
||||
|
||||
internal interface ILiveCombatModeCommand
|
||||
{
|
||||
void Toggle();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Focused deferred edge shared by the early-created retained toolbar and the
|
||||
/// later-created gameplay router. It owns no combat state and forwards to one
|
||||
/// window-lifetime command owner after session composition is complete.
|
||||
/// </summary>
|
||||
internal sealed class LiveCombatModeCommandSlot : ILiveCombatModeCommand
|
||||
{
|
||||
private readonly object _gate = new();
|
||||
private ILiveCombatModeCommand? _target;
|
||||
private bool _deactivated;
|
||||
|
||||
public void Bind(ILiveCombatModeCommand target)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(target);
|
||||
lock (_gate)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_deactivated, this);
|
||||
if (_target is not null && !ReferenceEquals(_target, target))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Live combat-mode commands are already bound.");
|
||||
}
|
||||
|
||||
_target = target;
|
||||
}
|
||||
}
|
||||
|
||||
public void Unbind(ILiveCombatModeCommand target)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(target);
|
||||
lock (_gate)
|
||||
{
|
||||
if (ReferenceEquals(_target, target))
|
||||
_target = null;
|
||||
}
|
||||
}
|
||||
|
||||
public void Deactivate()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_deactivated = true;
|
||||
_target = null;
|
||||
}
|
||||
}
|
||||
|
||||
public void Toggle()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_deactivated)
|
||||
_target?.Toggle();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Owns the input-facing combat-mode command. The choice itself remains the
|
||||
/// retail port in <see cref="CombatInputPlanner"/>:
|
||||
/// <c>ClientCombatSystem::GetDefaultCombatMode @ 0x0056B310</c> and
|
||||
/// <c>ClientCombatSystem::ToggleCombatMode @ 0x0056C8C0</c>. ACE remains the
|
||||
/// authority; the local state update preserves the accepted immediate toolbar
|
||||
/// response while the server confirmation travels through the normal route.
|
||||
/// </summary>
|
||||
internal sealed class LiveCombatModeCommandController : ILiveCombatModeCommand
|
||||
{
|
||||
private readonly ILiveCombatModeAuthority _authority;
|
||||
private readonly ICombatEquipmentSource _equipment;
|
||||
private readonly CombatState _combat;
|
||||
private readonly IExplicitCombatModeIntentSink _itemIntent;
|
||||
private readonly Action<string> _log;
|
||||
private readonly Action<string>? _toast;
|
||||
private readonly Action<string> _systemMessage;
|
||||
|
||||
public LiveCombatModeCommandController(
|
||||
ILiveCombatModeAuthority authority,
|
||||
ICombatEquipmentSource equipment,
|
||||
CombatState combat,
|
||||
IExplicitCombatModeIntentSink itemIntent,
|
||||
Action<string>? log = null,
|
||||
Action<string>? toast = null,
|
||||
Action<string>? systemMessage = null)
|
||||
{
|
||||
_authority = authority ?? throw new ArgumentNullException(nameof(authority));
|
||||
_equipment = equipment ?? throw new ArgumentNullException(nameof(equipment));
|
||||
_combat = combat ?? throw new ArgumentNullException(nameof(combat));
|
||||
_itemIntent = itemIntent ?? throw new ArgumentNullException(nameof(itemIntent));
|
||||
_log = log ?? (_ => { });
|
||||
_toast = toast;
|
||||
_systemMessage = systemMessage ?? (_ => { });
|
||||
}
|
||||
|
||||
public void Toggle()
|
||||
{
|
||||
if (!_authority.IsInWorld)
|
||||
return;
|
||||
|
||||
// Every explicit user combat request supersedes an in-flight auto-wield
|
||||
// settlement, including a request retail rejects before SetCombatMode.
|
||||
_itemIntent.NotifyExplicitCombatModeRequest();
|
||||
|
||||
CombatMode currentMode = _combat.CurrentMode;
|
||||
CombatMode nextMode;
|
||||
if (currentMode != CombatMode.NonCombat)
|
||||
{
|
||||
// ToggleCombatMode @ 0x0056C8C0 immediately selects NonCombat and
|
||||
// never queries default equipment on this branch.
|
||||
nextMode = CombatMode.NonCombat;
|
||||
}
|
||||
else
|
||||
{
|
||||
DefaultCombatModeDecision defaultDecision =
|
||||
CombatInputPlanner.GetDefaultCombatModeDecision(
|
||||
_equipment.GetOrderedEquipment());
|
||||
nextMode = CombatInputPlanner.ToggleMode(
|
||||
currentMode,
|
||||
defaultDecision.Mode);
|
||||
|
||||
// GetDefaultCombatMode prints this notice before SetCombatMode.
|
||||
// SetCombatMode then sees NonCombat == NonCombat and does no work.
|
||||
if (defaultDecision.IncompatibleHeldItem is { } held)
|
||||
{
|
||||
string notice =
|
||||
$"You can't enter combat mode while wielding the {held.GetAppropriateName()}";
|
||||
_log($"combat: {notice}");
|
||||
_systemMessage(notice);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Preserve the accepted command order: explicit intent was published
|
||||
// above, then send the request and publish local presentation.
|
||||
_authority.SendChangeCombatMode(nextMode);
|
||||
_combat.SetCombatMode(nextMode);
|
||||
|
||||
string message = $"Combat mode {nextMode}";
|
||||
_log($"combat: {message}");
|
||||
_toast?.Invoke(message);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,340 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.Input;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.App.Streaming;
|
||||
using AcDream.App.World;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.World;
|
||||
using AcDream.UI.Abstractions.Input;
|
||||
|
||||
namespace AcDream.App.Diagnostics;
|
||||
|
||||
internal interface IRuntimeDiagnosticCommands
|
||||
{
|
||||
bool Handle(InputAction action);
|
||||
|
||||
void CycleTimeOfDay();
|
||||
|
||||
void CycleWeather();
|
||||
|
||||
void ToggleCollisionWireframes();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Focused deferred edge used by the early-created developer panel and the
|
||||
/// later-created gameplay router. It owns no diagnostic state and binds one
|
||||
/// window-lifetime command owner exactly once.
|
||||
/// </summary>
|
||||
internal sealed class RuntimeDiagnosticCommandSlot : IRuntimeDiagnosticCommands
|
||||
{
|
||||
private readonly object _gate = new();
|
||||
private IRuntimeDiagnosticCommands? _target;
|
||||
private bool _deactivated;
|
||||
|
||||
public void Bind(IRuntimeDiagnosticCommands target)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(target);
|
||||
lock (_gate)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_deactivated, this);
|
||||
if (_target is not null && !ReferenceEquals(_target, target))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Runtime diagnostic commands are already bound.");
|
||||
}
|
||||
|
||||
_target = target;
|
||||
}
|
||||
}
|
||||
|
||||
public void Unbind(IRuntimeDiagnosticCommands target)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(target);
|
||||
lock (_gate)
|
||||
{
|
||||
if (ReferenceEquals(_target, target))
|
||||
_target = null;
|
||||
}
|
||||
}
|
||||
|
||||
public void Deactivate()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_deactivated = true;
|
||||
_target = null;
|
||||
}
|
||||
}
|
||||
|
||||
public bool Handle(InputAction action)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_deactivated && _target is { } target)
|
||||
return target.Handle(action);
|
||||
}
|
||||
|
||||
return RuntimeDiagnosticCommandController.IsDiagnosticAction(action);
|
||||
}
|
||||
|
||||
public void CycleTimeOfDay()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_deactivated)
|
||||
_target?.CycleTimeOfDay();
|
||||
}
|
||||
}
|
||||
|
||||
public void CycleWeather()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_deactivated)
|
||||
_target?.CycleWeather();
|
||||
}
|
||||
}
|
||||
|
||||
public void ToggleCollisionWireframes()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_deactivated)
|
||||
_target?.ToggleCollisionWireframes();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal interface INearbyWorldDiagnosticSource
|
||||
{
|
||||
Vector3 ObserverPosition { get; }
|
||||
|
||||
int LiveCenterX { get; }
|
||||
|
||||
int LiveCenterY { get; }
|
||||
|
||||
int TotalShadowObjects { get; }
|
||||
|
||||
IReadOnlyList<WorldEntity> WorldEntities { get; }
|
||||
|
||||
IEnumerable<ShadowEntry> ShadowEntries { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Read-only view over canonical runtime owners. It does not cache the current
|
||||
/// player, camera, entity list, origin, or shadow registry.
|
||||
/// </summary>
|
||||
internal sealed class RuntimeNearbyWorldDiagnosticSource : INearbyWorldDiagnosticSource
|
||||
{
|
||||
private readonly ILocalPlayerModeSource _mode;
|
||||
private readonly ILocalPlayerControllerSource _player;
|
||||
private readonly CameraController _camera;
|
||||
private readonly LiveWorldOriginState _origin;
|
||||
private readonly GpuWorldState _world;
|
||||
private readonly PhysicsEngine _physics;
|
||||
|
||||
public RuntimeNearbyWorldDiagnosticSource(
|
||||
ILocalPlayerModeSource mode,
|
||||
ILocalPlayerControllerSource player,
|
||||
CameraController camera,
|
||||
LiveWorldOriginState origin,
|
||||
GpuWorldState world,
|
||||
PhysicsEngine physics)
|
||||
{
|
||||
_mode = mode ?? throw new ArgumentNullException(nameof(mode));
|
||||
_player = player ?? throw new ArgumentNullException(nameof(player));
|
||||
_camera = camera ?? throw new ArgumentNullException(nameof(camera));
|
||||
_origin = origin ?? throw new ArgumentNullException(nameof(origin));
|
||||
_world = world ?? throw new ArgumentNullException(nameof(world));
|
||||
_physics = physics ?? throw new ArgumentNullException(nameof(physics));
|
||||
}
|
||||
|
||||
public Vector3 ObserverPosition
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_mode.IsPlayerMode && _player.Controller is { } player)
|
||||
return player.Position;
|
||||
|
||||
Matrix4x4.Invert(_camera.Active.View, out Matrix4x4 inverseView);
|
||||
return new Vector3(
|
||||
inverseView.M41,
|
||||
inverseView.M42,
|
||||
inverseView.M43);
|
||||
}
|
||||
}
|
||||
|
||||
public int LiveCenterX => _origin.CenterX;
|
||||
|
||||
public int LiveCenterY => _origin.CenterY;
|
||||
|
||||
public int TotalShadowObjects => _physics.ShadowObjects.TotalRegistered;
|
||||
|
||||
public IReadOnlyList<WorldEntity> WorldEntities => _world.Entities;
|
||||
|
||||
public IEnumerable<ShadowEntry> ShadowEntries =>
|
||||
_physics.ShadowObjects.AllEntriesForDebug();
|
||||
}
|
||||
|
||||
internal sealed class NearbyWorldDiagnosticDumper
|
||||
{
|
||||
private const float NearbyDistance = 15f;
|
||||
private readonly INearbyWorldDiagnosticSource _source;
|
||||
private readonly Action<string> _log;
|
||||
|
||||
public NearbyWorldDiagnosticDumper(
|
||||
INearbyWorldDiagnosticSource source,
|
||||
Action<string>? log = null)
|
||||
{
|
||||
_source = source ?? throw new ArgumentNullException(nameof(source));
|
||||
_log = log ?? (_ => { });
|
||||
}
|
||||
|
||||
public void Dump()
|
||||
{
|
||||
Vector3 position = _source.ObserverPosition;
|
||||
int centerX = _source.LiveCenterX;
|
||||
int centerY = _source.LiveCenterY;
|
||||
int landblockX = centerX + (int)MathF.Floor(position.X / 192f);
|
||||
int landblockY = centerY + (int)MathF.Floor(position.Y / 192f);
|
||||
|
||||
_log(
|
||||
$"=== F3 DEBUG DUMP ===\n" +
|
||||
$" player pos=({position.X:F2},{position.Y:F2},{position.Z:F2})\n" +
|
||||
$" landblock=0x{(uint)((landblockX << 24) | (landblockY << 16) | 0xFFFF):X8} " +
|
||||
$"local=({position.X - (landblockX - centerX) * 192f:F2}," +
|
||||
$"{position.Y - (landblockY - centerY) * 192f:F2})\n" +
|
||||
$" total shadow objects: {_source.TotalShadowObjects}");
|
||||
|
||||
List<WorldEntity> visibleNearby = _source.WorldEntities
|
||||
.Where(entity => HorizontalDistanceSquared(entity.Position, position)
|
||||
< NearbyDistance * NearbyDistance)
|
||||
.OrderBy(entity => (entity.Position - position).Length())
|
||||
.ToList();
|
||||
_log($" VISIBLE entities within 15m: {visibleNearby.Count}");
|
||||
foreach (WorldEntity entity in visibleNearby.Take(12))
|
||||
{
|
||||
float distance = (entity.Position - position).Length();
|
||||
_log(
|
||||
$" VIS id=0x{entity.Id:X8} src=0x{entity.SourceGfxObjOrSetupId:X8} " +
|
||||
$"pos=({entity.Position.X:F2},{entity.Position.Y:F2},{entity.Position.Z:F2}) " +
|
||||
$"dist={distance:F2} scale={entity.Scale:F2}");
|
||||
}
|
||||
|
||||
List<(ShadowEntry Entry, float Distance)> shadows = _source.ShadowEntries
|
||||
.Select(entry =>
|
||||
{
|
||||
float dx = entry.Position.X - position.X;
|
||||
float dy = entry.Position.Y - position.Y;
|
||||
return (Entry: entry, Distance: MathF.Sqrt(dx * dx + dy * dy));
|
||||
})
|
||||
.Where(item => item.Distance < NearbyDistance)
|
||||
.OrderBy(item => item.Distance)
|
||||
.ToList();
|
||||
_log($" SHADOW objects within 15m: {shadows.Count}");
|
||||
foreach ((ShadowEntry entry, float distance) in shadows.Take(12))
|
||||
{
|
||||
_log(
|
||||
$" SHAD id=0x{entry.EntityId:X8} {entry.CollisionType} " +
|
||||
$"r={entry.Radius:F2} h={entry.CylHeight:F2} " +
|
||||
$"pos=({entry.Position.X:F2},{entry.Position.Y:F2},{entry.Position.Z:F2}) " +
|
||||
$"dist={distance:F2}");
|
||||
}
|
||||
}
|
||||
|
||||
private static float HorizontalDistanceSquared(Vector3 left, Vector3 right)
|
||||
{
|
||||
float dx = left.X - right.X;
|
||||
float dy = left.Y - right.Y;
|
||||
return dx * dx + dy * dy;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Routes developer-only semantic actions while leaving every mutable value in
|
||||
/// its canonical owner. No diagnostic command owns a second clock, weather,
|
||||
/// camera-sensitivity, collision, entity, or shadow state.
|
||||
/// </summary>
|
||||
internal sealed class RuntimeDiagnosticCommandController : IRuntimeDiagnosticCommands
|
||||
{
|
||||
private readonly WorldEnvironmentController _environment;
|
||||
private readonly WorldSceneDebugState _sceneDebug;
|
||||
private readonly IPointerSensitivityCommands? _pointer;
|
||||
private readonly NearbyWorldDiagnosticDumper _nearby;
|
||||
private readonly Action<string>? _toast;
|
||||
|
||||
public RuntimeDiagnosticCommandController(
|
||||
WorldEnvironmentController environment,
|
||||
WorldSceneDebugState sceneDebug,
|
||||
IPointerSensitivityCommands? pointer,
|
||||
NearbyWorldDiagnosticDumper nearby,
|
||||
Action<string>? toast = null)
|
||||
{
|
||||
_environment = environment ?? throw new ArgumentNullException(nameof(environment));
|
||||
_sceneDebug = sceneDebug ?? throw new ArgumentNullException(nameof(sceneDebug));
|
||||
_pointer = pointer;
|
||||
_nearby = nearby ?? throw new ArgumentNullException(nameof(nearby));
|
||||
_toast = toast;
|
||||
}
|
||||
|
||||
public bool Handle(InputAction action)
|
||||
{
|
||||
switch (action)
|
||||
{
|
||||
case InputAction.AcdreamToggleCollisionWires:
|
||||
ToggleCollisionWireframes();
|
||||
return true;
|
||||
case InputAction.AcdreamDumpNearby:
|
||||
_nearby.Dump();
|
||||
return true;
|
||||
case InputAction.AcdreamCycleTimeOfDay:
|
||||
CycleTimeOfDay();
|
||||
return true;
|
||||
case InputAction.AcdreamSensitivityDown:
|
||||
if (_pointer is not null)
|
||||
{
|
||||
string message = _pointer.AdjustSensitivity(1f / 1.2f);
|
||||
_toast?.Invoke(message);
|
||||
}
|
||||
return true;
|
||||
case InputAction.AcdreamSensitivityUp:
|
||||
if (_pointer is not null)
|
||||
{
|
||||
string message = _pointer.AdjustSensitivity(1.2f);
|
||||
_toast?.Invoke(message);
|
||||
}
|
||||
return true;
|
||||
case InputAction.AcdreamCycleWeather:
|
||||
CycleWeather();
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public void CycleTimeOfDay()
|
||||
{
|
||||
string message = _environment.CycleTimeOfDay();
|
||||
_toast?.Invoke(message);
|
||||
}
|
||||
|
||||
public void CycleWeather()
|
||||
{
|
||||
string message = _environment.CycleWeather();
|
||||
_toast?.Invoke(message);
|
||||
}
|
||||
|
||||
public void ToggleCollisionWireframes()
|
||||
{
|
||||
bool visible = _sceneDebug.ToggleCollisionWireframes();
|
||||
_toast?.Invoke($"Collision wireframes {(visible ? "ON" : "OFF")}");
|
||||
}
|
||||
|
||||
internal static bool IsDiagnosticAction(InputAction action) => action is
|
||||
InputAction.AcdreamToggleCollisionWires
|
||||
or InputAction.AcdreamDumpNearby
|
||||
or InputAction.AcdreamCycleTimeOfDay
|
||||
or InputAction.AcdreamSensitivityDown
|
||||
or InputAction.AcdreamSensitivityUp
|
||||
or InputAction.AcdreamCycleWeather;
|
||||
}
|
||||
|
|
@ -74,13 +74,19 @@ internal sealed class SilkPointerCursorModeTarget(IMouse mouse)
|
|||
}
|
||||
}
|
||||
|
||||
internal interface IPointerSensitivityCommands
|
||||
{
|
||||
string AdjustSensitivity(float factor);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Owns raw pointer subscriptions and the camera-facing pointer policy that
|
||||
/// previously lived in GameWindow. Physical event removal is retriable; logical
|
||||
/// deactivation is immediate, so a copied or stubborn Silk delegate cannot
|
||||
/// re-enter gameplay while shutdown is closing the live session.
|
||||
/// </summary>
|
||||
internal sealed class CameraPointerInputController : IDisposable
|
||||
internal sealed class CameraPointerInputController
|
||||
: IDisposable, IPointerSensitivityCommands
|
||||
{
|
||||
private readonly IReadOnlyList<IRawPointerSurface> _surfaces;
|
||||
private readonly IPointerCursorModeTarget _cursor;
|
||||
|
|
|
|||
316
src/AcDream.App/Input/GameplayInputActionRouter.cs
Normal file
316
src/AcDream.App/Input/GameplayInputActionRouter.cs
Normal file
|
|
@ -0,0 +1,316 @@
|
|||
using AcDream.App.Interaction;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.App.UI;
|
||||
using AcDream.Core.Combat;
|
||||
using AcDream.UI.Abstractions.Input;
|
||||
|
||||
namespace AcDream.App.Input;
|
||||
|
||||
internal interface IGameplayInputActionSurface
|
||||
{
|
||||
void AddFired(Action<InputAction, ActivationType> callback);
|
||||
|
||||
void RemoveFired(Action<InputAction, ActivationType> callback);
|
||||
|
||||
void SetCombatScope(InputScope? scope);
|
||||
}
|
||||
|
||||
internal sealed class DispatcherGameplayInputActionSurface(InputDispatcher dispatcher)
|
||||
: IGameplayInputActionSurface
|
||||
{
|
||||
private readonly InputDispatcher _dispatcher = dispatcher
|
||||
?? throw new ArgumentNullException(nameof(dispatcher));
|
||||
|
||||
public void AddFired(Action<InputAction, ActivationType> callback) =>
|
||||
_dispatcher.Fired += callback;
|
||||
|
||||
public void RemoveFired(Action<InputAction, ActivationType> callback) =>
|
||||
_dispatcher.Fired -= callback;
|
||||
|
||||
public void SetCombatScope(InputScope? scope) =>
|
||||
_dispatcher.SetCombatScope(scope);
|
||||
}
|
||||
|
||||
internal interface ICombatModeEventSurface
|
||||
{
|
||||
CombatMode CurrentMode { get; }
|
||||
|
||||
void AddChanged(Action<CombatMode> callback);
|
||||
|
||||
void RemoveChanged(Action<CombatMode> callback);
|
||||
}
|
||||
|
||||
internal sealed class CombatStateModeEventSurface(CombatState combat)
|
||||
: ICombatModeEventSurface
|
||||
{
|
||||
private readonly CombatState _combat = combat
|
||||
?? throw new ArgumentNullException(nameof(combat));
|
||||
|
||||
public CombatMode CurrentMode => _combat.CurrentMode;
|
||||
|
||||
public void AddChanged(Action<CombatMode> callback) =>
|
||||
_combat.CombatModeChanged += callback;
|
||||
|
||||
public void RemoveChanged(Action<CombatMode> callback) =>
|
||||
_combat.CombatModeChanged -= callback;
|
||||
}
|
||||
|
||||
internal interface IGameplayInputPriorityTargets
|
||||
{
|
||||
bool HandlePointerAction(InputAction action, ActivationType activation);
|
||||
|
||||
void HandleScroll(InputAction action);
|
||||
|
||||
bool HandleCombatAction(InputAction action, ActivationType activation);
|
||||
|
||||
bool HandleRetainedUiAction(InputAction action);
|
||||
|
||||
bool HandleSelectionAction(InputAction action);
|
||||
|
||||
bool HandlePressedMovementAction(InputAction action);
|
||||
|
||||
void HandleCommand(InputAction action);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Typed adapter over canonical gameplay owners. Optional presentation owners
|
||||
/// remain no-ops when their stack is disabled; no state is mirrored here.
|
||||
/// </summary>
|
||||
internal sealed class RuntimeGameplayInputPriorityTargets
|
||||
: IGameplayInputPriorityTargets
|
||||
{
|
||||
private readonly GameplayInputFrameController _frame;
|
||||
private readonly CameraPointerInputController _pointer;
|
||||
private readonly RetailUiRuntime? _retainedUi;
|
||||
private readonly SelectionInteractionController? _selection;
|
||||
private readonly IGameplayInputCommandTarget _commands;
|
||||
|
||||
public RuntimeGameplayInputPriorityTargets(
|
||||
GameplayInputFrameController frame,
|
||||
CameraPointerInputController pointer,
|
||||
RetailUiRuntime? retainedUi,
|
||||
SelectionInteractionController? selection,
|
||||
IGameplayInputCommandTarget commands)
|
||||
{
|
||||
_frame = frame ?? throw new ArgumentNullException(nameof(frame));
|
||||
_pointer = pointer ?? throw new ArgumentNullException(nameof(pointer));
|
||||
_retainedUi = retainedUi;
|
||||
_selection = selection;
|
||||
_commands = commands ?? throw new ArgumentNullException(nameof(commands));
|
||||
}
|
||||
|
||||
public bool HandlePointerAction(InputAction action, ActivationType activation) =>
|
||||
_frame.HandlePointerAction(action, activation);
|
||||
|
||||
public void HandleScroll(InputAction action) =>
|
||||
_pointer.HandleScroll(action);
|
||||
|
||||
public bool HandleCombatAction(InputAction action, ActivationType activation) =>
|
||||
_frame.HandleCombatAction(action, activation);
|
||||
|
||||
public bool HandleRetainedUiAction(InputAction action) =>
|
||||
_retainedUi?.HandleInputAction(action) == true;
|
||||
|
||||
public bool HandleSelectionAction(InputAction action) =>
|
||||
_selection?.HandleInputAction(action) == true;
|
||||
|
||||
public bool HandlePressedMovementAction(InputAction action) =>
|
||||
_frame.HandlePressedMovementAction(action);
|
||||
|
||||
public void HandleCommand(InputAction action) =>
|
||||
_commands.Handle(action);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sole gameplay subscriber to <see cref="InputDispatcher.Fired"/>. The route
|
||||
/// preserves the accepted priority exactly: pointer, scroll, combat,
|
||||
/// Press/DoubleClick gate, retained UI, selection, pressed movement, commands.
|
||||
/// Subscription ownership is reversible and callbacks become inert before the
|
||||
/// live-session shutdown barrier.
|
||||
/// </summary>
|
||||
internal sealed class GameplayInputActionRouter : IDisposable
|
||||
{
|
||||
private readonly IGameplayInputActionSurface _actions;
|
||||
private readonly ICombatModeEventSurface _combat;
|
||||
private readonly IGameplayInputPriorityTargets _targets;
|
||||
private readonly HostQuiescenceGate _quiescence;
|
||||
private readonly Action<string> _log;
|
||||
private readonly Action<InputAction, ActivationType> _fired;
|
||||
private readonly Action<CombatMode> _combatModeChanged;
|
||||
private readonly bool[] _attached = new bool[2];
|
||||
private ResourceShutdownTransaction? _detach;
|
||||
private bool _attachStarted;
|
||||
private int _disposeRequested;
|
||||
private int _active;
|
||||
|
||||
public GameplayInputActionRouter(
|
||||
IGameplayInputActionSurface actions,
|
||||
ICombatModeEventSurface combat,
|
||||
IGameplayInputPriorityTargets targets,
|
||||
HostQuiescenceGate quiescence,
|
||||
Action<string>? log = null)
|
||||
{
|
||||
_actions = actions ?? throw new ArgumentNullException(nameof(actions));
|
||||
_combat = combat ?? throw new ArgumentNullException(nameof(combat));
|
||||
_targets = targets ?? throw new ArgumentNullException(nameof(targets));
|
||||
_quiescence = quiescence ?? throw new ArgumentNullException(nameof(quiescence));
|
||||
_log = log ?? (_ => { });
|
||||
_fired = OnFired;
|
||||
_combatModeChanged = OnCombatModeChanged;
|
||||
}
|
||||
|
||||
public static GameplayInputActionRouter Create(
|
||||
InputDispatcher dispatcher,
|
||||
CombatState combat,
|
||||
IGameplayInputPriorityTargets targets,
|
||||
HostQuiescenceGate quiescence,
|
||||
Action<string>? log = null) =>
|
||||
new(
|
||||
new DispatcherGameplayInputActionSurface(dispatcher),
|
||||
new CombatStateModeEventSurface(combat),
|
||||
targets,
|
||||
quiescence,
|
||||
log);
|
||||
|
||||
public bool IsDisposalComplete =>
|
||||
_attached.All(static attached => !attached);
|
||||
|
||||
public void Attach()
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(
|
||||
Volatile.Read(ref _disposeRequested) != 0,
|
||||
this);
|
||||
if (_attachStarted)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Gameplay action attachment has already started.");
|
||||
}
|
||||
|
||||
_attachStarted = true;
|
||||
try
|
||||
{
|
||||
// Mark each edge before invoking its custom accessor. An accessor
|
||||
// may perform the add and then throw; rollback must still remove it.
|
||||
_attached[0] = true;
|
||||
_actions.AddFired(_fired);
|
||||
_attached[1] = true;
|
||||
_combat.AddChanged(_combatModeChanged);
|
||||
|
||||
Volatile.Write(ref _active, 1);
|
||||
SetCombatScope(_combat.CurrentMode);
|
||||
}
|
||||
catch (Exception attachError)
|
||||
{
|
||||
Deactivate();
|
||||
try
|
||||
{
|
||||
EnsureDetachTransaction().CompleteOrThrow();
|
||||
}
|
||||
catch (Exception rollbackError)
|
||||
{
|
||||
throw new AggregateException(
|
||||
"Gameplay action registration and rollback both failed.",
|
||||
new InvalidOperationException(
|
||||
"Gameplay action registration failed.", attachError),
|
||||
rollbackError);
|
||||
}
|
||||
|
||||
throw new InvalidOperationException(
|
||||
"Gameplay action registration failed and was rolled back.",
|
||||
attachError);
|
||||
}
|
||||
}
|
||||
|
||||
public void Deactivate() => Interlocked.Exchange(ref _active, 0);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Interlocked.Exchange(ref _disposeRequested, 1);
|
||||
Deactivate();
|
||||
EnsureDetachTransaction().CompleteOrThrow();
|
||||
}
|
||||
|
||||
private void OnFired(InputAction action, ActivationType activation) =>
|
||||
_quiescence.Invoke(() =>
|
||||
{
|
||||
if (Volatile.Read(ref _active) != 0)
|
||||
Route(action, activation);
|
||||
});
|
||||
|
||||
private void OnCombatModeChanged(CombatMode mode) =>
|
||||
_quiescence.Invoke(() =>
|
||||
{
|
||||
if (Volatile.Read(ref _active) != 0)
|
||||
SetCombatScope(mode);
|
||||
});
|
||||
|
||||
private void Route(InputAction action, ActivationType activation)
|
||||
{
|
||||
_log($"[input] {action} {activation}");
|
||||
|
||||
if (_targets.HandlePointerAction(action, activation))
|
||||
return;
|
||||
|
||||
if (action is InputAction.ScrollUp or InputAction.ScrollDown)
|
||||
{
|
||||
if (activation == ActivationType.Press)
|
||||
_targets.HandleScroll(action);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_targets.HandleCombatAction(action, activation))
|
||||
return;
|
||||
|
||||
if (activation is not ActivationType.Press
|
||||
and not ActivationType.DoubleClick)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_targets.HandleRetainedUiAction(action))
|
||||
return;
|
||||
if (_targets.HandleSelectionAction(action))
|
||||
return;
|
||||
if (_targets.HandlePressedMovementAction(action))
|
||||
return;
|
||||
|
||||
_targets.HandleCommand(action);
|
||||
}
|
||||
|
||||
private void SetCombatScope(CombatMode mode) =>
|
||||
_actions.SetCombatScope(mode switch
|
||||
{
|
||||
CombatMode.Melee => InputScope.MeleeCombat,
|
||||
CombatMode.Missile => InputScope.MissileCombat,
|
||||
CombatMode.Magic => InputScope.MagicCombat,
|
||||
_ => null,
|
||||
});
|
||||
|
||||
private ResourceShutdownTransaction EnsureDetachTransaction() =>
|
||||
_detach ??= new ResourceShutdownTransaction(
|
||||
new ResourceShutdownStage("gameplay action callbacks",
|
||||
[
|
||||
new("combat mode", () => Remove(1)),
|
||||
new("dispatcher fired", () => Remove(0)),
|
||||
]));
|
||||
|
||||
private void Remove(int index)
|
||||
{
|
||||
if (!_attached[index])
|
||||
return;
|
||||
|
||||
switch (index)
|
||||
{
|
||||
case 0:
|
||||
_actions.RemoveFired(_fired);
|
||||
break;
|
||||
case 1:
|
||||
_combat.RemoveChanged(_combatModeChanged);
|
||||
break;
|
||||
default:
|
||||
throw new ArgumentOutOfRangeException(nameof(index));
|
||||
}
|
||||
|
||||
_attached[index] = false;
|
||||
}
|
||||
}
|
||||
210
src/AcDream.App/Input/GameplayInputCommandController.cs
Normal file
210
src/AcDream.App/Input/GameplayInputCommandController.cs
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
using AcDream.App.Combat;
|
||||
using AcDream.App.Diagnostics;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.App.UI;
|
||||
using AcDream.UI.Abstractions.Input;
|
||||
|
||||
namespace AcDream.App.Input;
|
||||
|
||||
internal interface IRetainedGameplayWindowCommands
|
||||
{
|
||||
void ToggleInventory();
|
||||
}
|
||||
|
||||
internal sealed class RetainedGameplayWindowCommands(RetailUiRuntime? runtime)
|
||||
: IRetainedGameplayWindowCommands
|
||||
{
|
||||
private readonly RetailUiRuntime? _runtime = runtime;
|
||||
|
||||
public void ToggleInventory() =>
|
||||
_runtime?.ToggleWindow(WindowNames.Inventory);
|
||||
}
|
||||
|
||||
internal interface IDevToolsGameplayCommands
|
||||
{
|
||||
void ToggleDebugPanel();
|
||||
|
||||
void FocusChatInput();
|
||||
|
||||
void ToggleSettingsPanel();
|
||||
}
|
||||
|
||||
internal sealed class DevToolsGameplayCommands(DevToolsFramePresenter? presenter)
|
||||
: IDevToolsGameplayCommands
|
||||
{
|
||||
private readonly DevToolsFramePresenter? _presenter = presenter;
|
||||
|
||||
public void ToggleDebugPanel() => _presenter?.ToggleDebugPanel();
|
||||
|
||||
public void FocusChatInput() => _presenter?.FocusChatInput();
|
||||
|
||||
public void ToggleSettingsPanel() => _presenter?.ToggleSettingsPanel();
|
||||
}
|
||||
|
||||
internal interface IPlayerModeGameplayCommands
|
||||
{
|
||||
bool IsPlayerMode { get; }
|
||||
|
||||
void ToggleFlyOrChase();
|
||||
|
||||
void TogglePlayerMode();
|
||||
|
||||
void ExitPlayerMode();
|
||||
}
|
||||
|
||||
internal sealed class PlayerModeGameplayCommands(
|
||||
ILocalPlayerModeSource mode,
|
||||
PlayerModeController controller) : IPlayerModeGameplayCommands
|
||||
{
|
||||
private readonly ILocalPlayerModeSource _mode = mode
|
||||
?? throw new ArgumentNullException(nameof(mode));
|
||||
private readonly PlayerModeController _controller = controller
|
||||
?? throw new ArgumentNullException(nameof(controller));
|
||||
|
||||
public bool IsPlayerMode => _mode.IsPlayerMode;
|
||||
|
||||
public void ToggleFlyOrChase() => _controller.ToggleFlyOrChase();
|
||||
|
||||
public void TogglePlayerMode() => _controller.Toggle();
|
||||
|
||||
public void ExitPlayerMode() => _controller.Exit();
|
||||
}
|
||||
|
||||
internal interface IItemTargetModeCommands
|
||||
{
|
||||
bool IsAnyTargetModeActive { get; }
|
||||
|
||||
void CancelTargetMode();
|
||||
}
|
||||
|
||||
internal sealed class ItemTargetModeCommands(ItemInteractionController items)
|
||||
: IItemTargetModeCommands
|
||||
{
|
||||
private readonly ItemInteractionController _items = items
|
||||
?? throw new ArgumentNullException(nameof(items));
|
||||
|
||||
public bool IsAnyTargetModeActive => _items.IsAnyTargetModeActive;
|
||||
|
||||
public void CancelTargetMode() => _items.CancelTargetMode();
|
||||
}
|
||||
|
||||
internal interface IGameplayCameraModeCommands
|
||||
{
|
||||
bool IsFlyMode { get; }
|
||||
|
||||
void ExitFlyMode();
|
||||
}
|
||||
|
||||
internal sealed class GameplayCameraModeCommands(CameraController camera)
|
||||
: IGameplayCameraModeCommands
|
||||
{
|
||||
private readonly CameraController _camera = camera
|
||||
?? throw new ArgumentNullException(nameof(camera));
|
||||
|
||||
public bool IsFlyMode => _camera.IsFlyMode;
|
||||
|
||||
public void ExitFlyMode() => _camera.ToggleFly();
|
||||
}
|
||||
|
||||
internal interface IGameplayWindowCommands
|
||||
{
|
||||
void Close();
|
||||
}
|
||||
|
||||
internal sealed class GameplayWindowCommands(Action close) : IGameplayWindowCommands
|
||||
{
|
||||
private readonly Action _close = close
|
||||
?? throw new ArgumentNullException(nameof(close));
|
||||
|
||||
public void Close() => _close();
|
||||
}
|
||||
|
||||
internal interface IGameplayInputCommandTarget
|
||||
{
|
||||
bool Handle(InputAction action);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Owns the final command tier of the frozen gameplay-action priority graph.
|
||||
/// It coordinates canonical owners but stores no UI, camera, player, combat,
|
||||
/// item-target, or diagnostic state of its own.
|
||||
/// </summary>
|
||||
internal sealed class GameplayInputCommandController : IGameplayInputCommandTarget
|
||||
{
|
||||
private readonly IRetainedGameplayWindowCommands _retained;
|
||||
private readonly IDevToolsGameplayCommands _devTools;
|
||||
private readonly IRuntimeDiagnosticCommands _diagnostics;
|
||||
private readonly IPlayerModeGameplayCommands _playerMode;
|
||||
private readonly IItemTargetModeCommands _targetMode;
|
||||
private readonly IGameplayCameraModeCommands _camera;
|
||||
private readonly ILiveCombatModeCommand _combat;
|
||||
private readonly IGameplayWindowCommands _window;
|
||||
|
||||
public GameplayInputCommandController(
|
||||
IRetainedGameplayWindowCommands retained,
|
||||
IDevToolsGameplayCommands devTools,
|
||||
IRuntimeDiagnosticCommands diagnostics,
|
||||
IPlayerModeGameplayCommands playerMode,
|
||||
IItemTargetModeCommands targetMode,
|
||||
IGameplayCameraModeCommands camera,
|
||||
ILiveCombatModeCommand combat,
|
||||
IGameplayWindowCommands window)
|
||||
{
|
||||
_retained = retained ?? throw new ArgumentNullException(nameof(retained));
|
||||
_devTools = devTools ?? throw new ArgumentNullException(nameof(devTools));
|
||||
_diagnostics = diagnostics ?? throw new ArgumentNullException(nameof(diagnostics));
|
||||
_playerMode = playerMode ?? throw new ArgumentNullException(nameof(playerMode));
|
||||
_targetMode = targetMode ?? throw new ArgumentNullException(nameof(targetMode));
|
||||
_camera = camera ?? throw new ArgumentNullException(nameof(camera));
|
||||
_combat = combat ?? throw new ArgumentNullException(nameof(combat));
|
||||
_window = window ?? throw new ArgumentNullException(nameof(window));
|
||||
}
|
||||
|
||||
public bool Handle(InputAction action)
|
||||
{
|
||||
if (_diagnostics.Handle(action))
|
||||
return true;
|
||||
|
||||
switch (action)
|
||||
{
|
||||
case InputAction.ToggleInventoryPanel:
|
||||
_retained.ToggleInventory();
|
||||
return true;
|
||||
case InputAction.AcdreamToggleDebugPanel:
|
||||
_devTools.ToggleDebugPanel();
|
||||
return true;
|
||||
case InputAction.AcdreamToggleFlyMode:
|
||||
_playerMode.ToggleFlyOrChase();
|
||||
return true;
|
||||
case InputAction.AcdreamTogglePlayerMode:
|
||||
_playerMode.TogglePlayerMode();
|
||||
return true;
|
||||
case InputAction.ToggleChatEntry:
|
||||
_devTools.FocusChatInput();
|
||||
return true;
|
||||
case InputAction.ToggleOptionsPanel:
|
||||
_devTools.ToggleSettingsPanel();
|
||||
return true;
|
||||
case InputAction.CombatToggleCombat:
|
||||
_combat.Toggle();
|
||||
return true;
|
||||
case InputAction.EscapeKey:
|
||||
HandleEscape();
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleEscape()
|
||||
{
|
||||
if (_targetMode.IsAnyTargetModeActive)
|
||||
_targetMode.CancelTargetMode();
|
||||
else if (_camera.IsFlyMode)
|
||||
_camera.ExitFlyMode();
|
||||
else if (_playerMode.IsPlayerMode)
|
||||
_playerMode.ExitPlayerMode();
|
||||
else
|
||||
_window.Close();
|
||||
}
|
||||
}
|
||||
139
src/AcDream.App/Input/RetainedUiGameplayBinding.cs
Normal file
139
src/AcDream.App/Input/RetainedUiGameplayBinding.cs
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
using AcDream.App.Rendering;
|
||||
using AcDream.App.UI;
|
||||
|
||||
namespace AcDream.App.Input;
|
||||
|
||||
internal interface IRetainedUiDragReleaseSurface
|
||||
{
|
||||
void AddReleasedOutside(Action<object, int, int> callback);
|
||||
|
||||
void RemoveReleasedOutside(Action<object, int, int> callback);
|
||||
}
|
||||
|
||||
internal sealed class UiRootDragReleaseSurface(UiRoot root)
|
||||
: IRetainedUiDragReleaseSurface
|
||||
{
|
||||
private readonly UiRoot _root = root
|
||||
?? throw new ArgumentNullException(nameof(root));
|
||||
|
||||
public void AddReleasedOutside(Action<object, int, int> callback) =>
|
||||
_root.DragReleasedOutsideUi += callback;
|
||||
|
||||
public void RemoveReleasedOutside(Action<object, int, int> callback) =>
|
||||
_root.DragReleasedOutsideUi -= callback;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Owns the retained-root gameplay edge used when an item drag is released in
|
||||
/// the world. Logical deactivation precedes live-session teardown and physical
|
||||
/// removal remains retriable if a custom event accessor fails.
|
||||
/// </summary>
|
||||
internal sealed class RetainedUiGameplayBinding : IDisposable
|
||||
{
|
||||
private readonly IRetainedUiDragReleaseSurface _surface;
|
||||
private readonly Action<ItemDragPayload, int, int> _placeDraggedItem;
|
||||
private readonly HostQuiescenceGate _quiescence;
|
||||
private readonly Action<object, int, int> _releasedOutside;
|
||||
private ResourceShutdownTransaction? _detach;
|
||||
private bool _attached;
|
||||
private bool _attachStarted;
|
||||
private int _disposeRequested;
|
||||
private int _active;
|
||||
|
||||
public RetainedUiGameplayBinding(
|
||||
IRetainedUiDragReleaseSurface surface,
|
||||
Action<ItemDragPayload, int, int> placeDraggedItem,
|
||||
HostQuiescenceGate quiescence)
|
||||
{
|
||||
_surface = surface ?? throw new ArgumentNullException(nameof(surface));
|
||||
_placeDraggedItem = placeDraggedItem
|
||||
?? throw new ArgumentNullException(nameof(placeDraggedItem));
|
||||
_quiescence = quiescence
|
||||
?? throw new ArgumentNullException(nameof(quiescence));
|
||||
_releasedOutside = OnReleasedOutside;
|
||||
}
|
||||
|
||||
public static RetainedUiGameplayBinding Create(
|
||||
UiRoot root,
|
||||
Action<ItemDragPayload, int, int> placeDraggedItem,
|
||||
HostQuiescenceGate quiescence) =>
|
||||
new(new UiRootDragReleaseSurface(root), placeDraggedItem, quiescence);
|
||||
|
||||
public bool IsDisposalComplete => !_attached;
|
||||
|
||||
public void Attach()
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(
|
||||
Volatile.Read(ref _disposeRequested) != 0,
|
||||
this);
|
||||
if (_attachStarted)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Retained gameplay attachment has already started.");
|
||||
}
|
||||
|
||||
_attachStarted = true;
|
||||
try
|
||||
{
|
||||
// Publish before the custom accessor: it may add and then throw.
|
||||
_attached = true;
|
||||
_surface.AddReleasedOutside(_releasedOutside);
|
||||
Volatile.Write(ref _active, 1);
|
||||
}
|
||||
catch (Exception attachError)
|
||||
{
|
||||
Deactivate();
|
||||
try
|
||||
{
|
||||
EnsureDetachTransaction().CompleteOrThrow();
|
||||
}
|
||||
catch (Exception rollbackError)
|
||||
{
|
||||
throw new AggregateException(
|
||||
"Retained gameplay registration and rollback both failed.",
|
||||
new InvalidOperationException(
|
||||
"Retained gameplay registration failed.", attachError),
|
||||
rollbackError);
|
||||
}
|
||||
|
||||
throw new InvalidOperationException(
|
||||
"Retained gameplay registration failed and was rolled back.",
|
||||
attachError);
|
||||
}
|
||||
}
|
||||
|
||||
public void Deactivate() => Interlocked.Exchange(ref _active, 0);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Interlocked.Exchange(ref _disposeRequested, 1);
|
||||
Deactivate();
|
||||
EnsureDetachTransaction().CompleteOrThrow();
|
||||
}
|
||||
|
||||
private void OnReleasedOutside(object payload, int x, int y) =>
|
||||
_quiescence.Invoke(() =>
|
||||
{
|
||||
if (Volatile.Read(ref _active) != 0
|
||||
&& payload is ItemDragPayload item)
|
||||
{
|
||||
_placeDraggedItem(item, x, y);
|
||||
}
|
||||
});
|
||||
|
||||
private ResourceShutdownTransaction EnsureDetachTransaction() =>
|
||||
_detach ??= new ResourceShutdownTransaction(
|
||||
new ResourceShutdownStage("retained gameplay callbacks",
|
||||
[
|
||||
new("drag released outside UI", Remove),
|
||||
]));
|
||||
|
||||
private void Remove()
|
||||
{
|
||||
if (!_attached)
|
||||
return;
|
||||
|
||||
_surface.RemoveReleasedOutside(_releasedOutside);
|
||||
_attached = false;
|
||||
}
|
||||
}
|
||||
|
|
@ -447,12 +447,9 @@ public sealed class GameWindow : IDisposable
|
|||
// the bool here.
|
||||
private AcDream.App.Input.PlayerModeAutoEntry? _playerModeAutoEntry;
|
||||
|
||||
// Phase K.1b — single input path. Every keyboard/mouse-button reaction
|
||||
// flows through InputDispatcher.Fired (see OnInputAction below) or
|
||||
// IsActionHeld (per-frame polling for movement). The legacy direct
|
||||
// kb.KeyDown switch + mouse.MouseDown/MouseUp handlers are GONE; only
|
||||
// mouse.MouseMove survives as a direct handler because mouse-delta is
|
||||
// axis input, not chord input.
|
||||
// Phase K.1b / Slice 8 F — one semantic input path. Transitional actions
|
||||
// flow through GameplayInputActionRouter; held movement is polled through
|
||||
// InputDispatcher. Raw axis motion belongs to CameraPointerInputController.
|
||||
private AcDream.App.Input.SilkKeyboardSource? _kbSource;
|
||||
private AcDream.App.Input.SilkMouseSource? _mouseSource;
|
||||
private AcDream.UI.Abstractions.Input.InputDispatcher? _inputDispatcher;
|
||||
|
|
@ -462,6 +459,12 @@ public sealed class GameWindow : IDisposable
|
|||
private readonly AcDream.App.Input.DispatcherCameraInputSource _cameraInput = new();
|
||||
private AcDream.App.Input.IMouseLookCursor? _mouseLookCursor;
|
||||
private AcDream.App.Input.GameplayInputFrameController? _gameplayInputFrame;
|
||||
private AcDream.App.Input.GameplayInputActionRouter? _gameplayInputActions;
|
||||
private AcDream.App.Input.RetainedUiGameplayBinding? _retainedUiGameplayBinding;
|
||||
private readonly AcDream.App.Diagnostics.RuntimeDiagnosticCommandSlot
|
||||
_runtimeDiagnosticCommands = new();
|
||||
private readonly AcDream.App.Combat.LiveCombatModeCommandSlot
|
||||
_liveCombatModeCommands = new();
|
||||
// K.1c: load user-customized bindings from %LOCALAPPDATA%\acdream\keybinds.json,
|
||||
// falling back to the retail-faithful defaults if the file is missing
|
||||
// or corrupt. This is THE single source of truth for the keymap at
|
||||
|
|
@ -693,10 +696,6 @@ public sealed class GameWindow : IDisposable
|
|||
_inputDispatcher.Attach();
|
||||
_movementInput.Bind(_inputDispatcher);
|
||||
_cameraInput.Bind(_inputDispatcher);
|
||||
_inputDispatcher.Fired += OnInputAction;
|
||||
Combat.CombatModeChanged += SetInputCombatScope;
|
||||
SetInputCombatScope(Combat.CurrentMode);
|
||||
|
||||
}
|
||||
|
||||
_gl.ClearColor(0.05f, 0.10f, 0.18f, 1.0f);
|
||||
|
|
@ -960,9 +959,12 @@ public sealed class GameWindow : IDisposable
|
|||
getFrameMs: () =>
|
||||
(float)(_renderFrameDiagnostics?.Snapshot.FrameMilliseconds ?? 16.7),
|
||||
combat: Combat);
|
||||
_debugVm.CycleTimeOfDay = CycleTimeOfDay;
|
||||
_debugVm.CycleWeather = CycleWeather;
|
||||
_debugVm.ToggleCollisionWires = ToggleCollisionWires;
|
||||
_debugVm.CycleTimeOfDay =
|
||||
_runtimeDiagnosticCommands.CycleTimeOfDay;
|
||||
_debugVm.CycleWeather =
|
||||
_runtimeDiagnosticCommands.CycleWeather;
|
||||
_debugVm.ToggleCollisionWires =
|
||||
_runtimeDiagnosticCommands.ToggleCollisionWireframes;
|
||||
// Phase K.2: free-fly toggle button — same routine the
|
||||
// legacy F-key alias hits. Cancels the one-shot
|
||||
// auto-entry if the user opts out of player mode before
|
||||
|
|
@ -1406,8 +1408,6 @@ public sealed class GameWindow : IDisposable
|
|||
displayMessage: text => Chat.OnSystemMessage(text, 0x1Au),
|
||||
incrementBusy: () => _itemInteractionController.IncrementBusyCount(),
|
||||
canSend: () => _liveSessionHost?.IsInWorld == true);
|
||||
_uiHost.Root.DragReleasedOutsideUi += OnUiDragReleasedOutside;
|
||||
|
||||
// Feed Silk input to the UiRoot tree so windows drag / close / select.
|
||||
// UiRoot consumes UI events; the game InputDispatcher (subscribed to the
|
||||
// same devices) is gated off via WantCaptureMouse/Keyboard above when the
|
||||
|
|
@ -1577,7 +1577,7 @@ public sealed class GameWindow : IDisposable
|
|||
UseItemByGuid,
|
||||
Combat,
|
||||
ItemMana,
|
||||
ToggleLiveCombatMode,
|
||||
_liveCombatModeCommands.Toggle,
|
||||
_itemInteractionController,
|
||||
entry => LiveSession?.SendAddShortcut(entry),
|
||||
index => LiveSession?.SendRemoveShortcut(index),
|
||||
|
|
@ -2007,6 +2007,17 @@ public sealed class GameWindow : IDisposable
|
|||
text => _debugVm?.AddToast(text),
|
||||
_playerApproachCompletions);
|
||||
}
|
||||
if (_uiHost is { } retainedHost
|
||||
&& _selectionInteractions is { } selectionInteractions)
|
||||
{
|
||||
_retainedUiGameplayBinding =
|
||||
AcDream.App.Input.RetainedUiGameplayBinding.Create(
|
||||
retainedHost.Root,
|
||||
(item, x, y) =>
|
||||
selectionInteractions.PlaceDraggedItem(item, x, y),
|
||||
_hostQuiescence);
|
||||
_retainedUiGameplayBinding.Attach();
|
||||
}
|
||||
// A.5 T22.5: apply A2C gate from quality preset.
|
||||
_wbDrawDispatcher.AlphaToCoverage = _resolvedQuality.AlphaToCoverage;
|
||||
|
||||
|
|
@ -2813,6 +2824,81 @@ public sealed class GameWindow : IDisposable
|
|||
_playerModeAutoEntry),
|
||||
cameraFrame);
|
||||
_liveSessionHost = CreateLiveSessionHost();
|
||||
|
||||
AcDream.UI.Abstractions.Panels.Debug.DebugVM? debugVm = _debugVm;
|
||||
Action<string>? debugToast = debugVm is null
|
||||
? null
|
||||
: message => debugVm.AddToast(message);
|
||||
AcDream.Core.Chat.ChatLog chat = Chat;
|
||||
var combatCommand =
|
||||
new AcDream.App.Combat.LiveCombatModeCommandController(
|
||||
new AcDream.App.Combat.LiveSessionCombatModeAuthority(
|
||||
_liveSessionHost),
|
||||
new AcDream.App.Combat.LocalPlayerCombatEquipmentSource(
|
||||
Objects,
|
||||
_localPlayerIdentity),
|
||||
Combat,
|
||||
new AcDream.App.Combat.ItemInteractionCombatModeIntentSink(
|
||||
_itemInteractionController!),
|
||||
Console.WriteLine,
|
||||
debugToast,
|
||||
text => chat.OnSystemMessage(text, 0x1Au));
|
||||
_liveCombatModeCommands.Bind(combatCommand);
|
||||
|
||||
var nearbyDiagnostics =
|
||||
new AcDream.App.Diagnostics.NearbyWorldDiagnosticDumper(
|
||||
new AcDream.App.Diagnostics.RuntimeNearbyWorldDiagnosticSource(
|
||||
_localPlayerMode,
|
||||
_playerControllerSlot,
|
||||
_cameraController!,
|
||||
_liveWorldOrigin,
|
||||
_worldState,
|
||||
_physicsEngine),
|
||||
Console.WriteLine);
|
||||
var runtimeDiagnostics =
|
||||
new AcDream.App.Diagnostics.RuntimeDiagnosticCommandController(
|
||||
_worldEnvironment,
|
||||
_worldSceneDebugState,
|
||||
_cameraPointerInput,
|
||||
nearbyDiagnostics,
|
||||
debugToast);
|
||||
_runtimeDiagnosticCommands.Bind(runtimeDiagnostics);
|
||||
|
||||
if (_inputDispatcher is { } dispatcher)
|
||||
{
|
||||
var pointer = _cameraPointerInput
|
||||
?? throw new InvalidOperationException(
|
||||
"Gameplay action routing requires the composed pointer owner.");
|
||||
var commands = new AcDream.App.Input.GameplayInputCommandController(
|
||||
new AcDream.App.Input.RetainedGameplayWindowCommands(
|
||||
_retailUiRuntime),
|
||||
new AcDream.App.Input.DevToolsGameplayCommands(
|
||||
_devToolsFramePresenter),
|
||||
runtimeDiagnostics,
|
||||
new AcDream.App.Input.PlayerModeGameplayCommands(
|
||||
_localPlayerMode,
|
||||
_playerModeController),
|
||||
new AcDream.App.Input.ItemTargetModeCommands(
|
||||
_itemInteractionController!),
|
||||
new AcDream.App.Input.GameplayCameraModeCommands(
|
||||
_cameraController!),
|
||||
combatCommand,
|
||||
new AcDream.App.Input.GameplayWindowCommands(_window!.Close));
|
||||
var targets = new AcDream.App.Input.RuntimeGameplayInputPriorityTargets(
|
||||
_gameplayInputFrame!,
|
||||
pointer,
|
||||
_retailUiRuntime,
|
||||
_selectionInteractions,
|
||||
commands);
|
||||
_gameplayInputActions = AcDream.App.Input.GameplayInputActionRouter.Create(
|
||||
dispatcher,
|
||||
Combat,
|
||||
targets,
|
||||
_hostQuiescence,
|
||||
Console.WriteLine);
|
||||
_gameplayInputActions.Attach();
|
||||
}
|
||||
|
||||
AcDream.App.Net.LiveSessionStartResult liveStart =
|
||||
_liveSessionHost.Start(_options);
|
||||
switch (liveStart.Status)
|
||||
|
|
@ -3241,36 +3327,6 @@ public sealed class GameWindow : IDisposable
|
|||
private bool GetDebugPlayerOnGround() =>
|
||||
_playerMode && _playerController is not null && !_playerController.IsAirborne;
|
||||
|
||||
/// <summary>
|
||||
/// Cycle the time-of-day debug override. Same body as the old F7
|
||||
/// keybind handler; called by both the keybind AND the DebugPanel
|
||||
/// "Cycle time of day" button via DebugVM.CycleTimeOfDay.
|
||||
/// </summary>
|
||||
private void CycleTimeOfDay()
|
||||
{
|
||||
string message = _worldEnvironment.CycleTimeOfDay();
|
||||
_debugVm?.AddToast(message);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cycle the weather kind. Same body as the old F10 keybind handler.
|
||||
/// </summary>
|
||||
private void CycleWeather()
|
||||
{
|
||||
string message = _worldEnvironment.CycleWeather();
|
||||
_debugVm?.AddToast(message);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Toggle the collision-wires debug renderer. Same body as the old
|
||||
/// F2 keybind handler.
|
||||
/// </summary>
|
||||
private void ToggleCollisionWires()
|
||||
{
|
||||
bool visible = _worldSceneDebugState.ToggleCollisionWireframes();
|
||||
_debugVm?.AddToast($"Collision wireframes {(visible ? "ON" : "OFF")}");
|
||||
}
|
||||
|
||||
// Phase K.3 settings state remains runtime-owned; the presenter owns the
|
||||
// optional SettingsPanel and its visibility/input operations.
|
||||
private AcDream.UI.Abstractions.Panels.Settings.SettingsVM? _settingsVm;
|
||||
|
|
@ -3282,11 +3338,6 @@ public sealed class GameWindow : IDisposable
|
|||
private AcDream.UI.Abstractions.Panels.Settings.SettingsStore? _settingsStore;
|
||||
private string _activeToonKey = "default";
|
||||
|
||||
private bool ToggleRetailWindow(string name)
|
||||
{
|
||||
return _retailUiRuntime?.ToggleWindow(name) ?? false;
|
||||
}
|
||||
|
||||
private void SyncToolbarWindowButtons()
|
||||
{
|
||||
_retailUiRuntime?.SyncToolbarWindowButtons();
|
||||
|
|
@ -3369,12 +3420,6 @@ public sealed class GameWindow : IDisposable
|
|||
}
|
||||
}
|
||||
|
||||
private void OnUiDragReleasedOutside(object payload, int x, int y)
|
||||
{
|
||||
if (payload is AcDream.App.UI.ItemDragPayload itemPayload)
|
||||
_selectionInteractions?.PlaceDraggedItem(itemPayload, x, y);
|
||||
}
|
||||
|
||||
// L.0 follow-up: persisted-settings cache populated by
|
||||
// LoadAndApplyPersistedSettings (runs unconditionally in OnLoad,
|
||||
// not gated on DevToolsEnabled). The Settings PANEL construction
|
||||
|
|
@ -3563,202 +3608,6 @@ public sealed class GameWindow : IDisposable
|
|||
&& width > 0
|
||||
&& height > 0;
|
||||
}
|
||||
// ── K.1b: dispatcher action handler ──────────────────────────────────
|
||||
//
|
||||
// SINGLE place where every game-side keyboard/mouse-button reaction
|
||||
// lives. The legacy direct kb.KeyDown switch + mouse.MouseDown/MouseUp
|
||||
// handlers are gone; everything now flows through InputDispatcher.Fired
|
||||
// → here. New behaviors register a new InputAction in the enum + a
|
||||
// case in this switch + a binding in KeyBindings.
|
||||
|
||||
/// <summary>
|
||||
/// K.1b — multicast subscriber on <see cref="InputDispatcher.Fired"/>.
|
||||
/// Handles every game-side reaction to a keyboard/mouse-button chord.
|
||||
/// Per-frame held-state polling (movement WASD/Shift/Space) lives in
|
||||
/// <see cref="OnUpdate"/> via <see cref="InputDispatcher.IsActionHeld"/>;
|
||||
/// this method handles transitional Press/Release events only.
|
||||
/// </summary>
|
||||
private void SetInputCombatScope(AcDream.Core.Combat.CombatMode mode)
|
||||
{
|
||||
_inputDispatcher?.SetCombatScope(mode switch
|
||||
{
|
||||
AcDream.Core.Combat.CombatMode.Melee => AcDream.UI.Abstractions.Input.InputScope.MeleeCombat,
|
||||
AcDream.Core.Combat.CombatMode.Missile => AcDream.UI.Abstractions.Input.InputScope.MissileCombat,
|
||||
AcDream.Core.Combat.CombatMode.Magic => AcDream.UI.Abstractions.Input.InputScope.MagicCombat,
|
||||
_ => null,
|
||||
});
|
||||
}
|
||||
|
||||
private void OnInputAction(
|
||||
AcDream.UI.Abstractions.Input.InputAction action,
|
||||
AcDream.UI.Abstractions.Input.ActivationType activation)
|
||||
{
|
||||
// Diagnostic — kept from K.1a; helpful for K.1c verification.
|
||||
Console.WriteLine($"[input] {action} {activation}");
|
||||
|
||||
// RMB orbit and instant mouse-look remain the first accepted input
|
||||
// transition. Their state is owned by the gameplay/pointer owners.
|
||||
if (_gameplayInputFrame?.HandlePointerAction(action, activation) == true)
|
||||
return;
|
||||
|
||||
// Phase K.2 — MMB-hold instant mouse-look. Press hides the
|
||||
// cursor + activates yaw drive; release restores. WantCapture
|
||||
// edge handling lives in OnUpdate; only Press needs to read it
|
||||
// for the initial gate (defense in depth — the dispatcher
|
||||
// already filters on WantCaptureMouse in OnMouseDown).
|
||||
// ScrollUp / ScrollDown — emit by InputDispatcher.OnScroll on every
|
||||
// wheel tick. Press is the only activation type for wheel.
|
||||
if (action == AcDream.UI.Abstractions.Input.InputAction.ScrollUp
|
||||
|| action == AcDream.UI.Abstractions.Input.InputAction.ScrollDown)
|
||||
{
|
||||
if (activation != AcDream.UI.Abstractions.Input.ActivationType.Press) return;
|
||||
HandleScrollAction(action);
|
||||
return;
|
||||
}
|
||||
|
||||
// ACCmdInterp::HandleNewForwardMovement (0x0058B1F0) aborts automatic
|
||||
// combat before the movement command enters CommandInterpreter. This
|
||||
// notification does not consume the input; the movement owner below
|
||||
// still receives it normally.
|
||||
// Retail attack actions consume their full transition stream: key-down
|
||||
// starts the power build and key-up commits it. Handle them before the
|
||||
// generic Press-only gate below.
|
||||
if (_gameplayInputFrame?.HandleCombatAction(action, activation) == true)
|
||||
return;
|
||||
|
||||
// Every other action fires on Press only (no Release / Hold side-
|
||||
// effects in the K.1b set). Filter out non-Press activations early
|
||||
// so subscribers that have Release-mode bindings don't accidentally
|
||||
// re-fire. B.4b exception: DoubleClick must pass through so
|
||||
// SelectDblLeft / SelectDblRight / SelectDblMid can reach the switch.
|
||||
if (activation != AcDream.UI.Abstractions.Input.ActivationType.Press
|
||||
&& activation != AcDream.UI.Abstractions.Input.ActivationType.DoubleClick) return;
|
||||
|
||||
// Wave 4.1: one retained-UI delegation seam. The runtime routes only
|
||||
// toolbar-owned semantic actions; every other action continues through
|
||||
// the game switch below. Keyboard capture is already enforced at the
|
||||
// InputDispatcher source, matching retail's stack-entry focus gate.
|
||||
if (_retailUiRuntime?.HandleInputAction(action) == true)
|
||||
return;
|
||||
|
||||
if (_selectionInteractions?.HandleInputAction(action) == true)
|
||||
return;
|
||||
|
||||
// K-fix1 (2026-04-26): Q is autorun TOGGLE, not hold-to-run. Press
|
||||
// Q to start, press Q again to stop. Pressing Backup / Stop /
|
||||
// StrafeLeft / StrafeRight while autorun is active also cancels it
|
||||
// — retail-faithful: any deliberate movement input wins. (Pressing
|
||||
// Forward AGAIN does NOT cancel — retail's autorun stays active
|
||||
// even when you press W; the two stack.)
|
||||
if (_gameplayInputFrame?.HandlePressedMovementAction(action) == true)
|
||||
return;
|
||||
|
||||
switch (action)
|
||||
{
|
||||
case AcDream.UI.Abstractions.Input.InputAction.ToggleInventoryPanel:
|
||||
// Retail F12 (rebindable). Gated upstream by WantsKeyboard, so it
|
||||
// does not fire while the chat input holds focus. Null _uiHost =
|
||||
// retail UI off (ACDREAM_RETAIL_UI unset) → no-op.
|
||||
ToggleRetailWindow(AcDream.App.UI.WindowNames.Inventory);
|
||||
break;
|
||||
|
||||
case AcDream.UI.Abstractions.Input.InputAction.AcdreamToggleDebugPanel:
|
||||
_devToolsFramePresenter?.ToggleDebugPanel();
|
||||
break;
|
||||
|
||||
case AcDream.UI.Abstractions.Input.InputAction.AcdreamToggleCollisionWires:
|
||||
ToggleCollisionWires();
|
||||
break;
|
||||
|
||||
case AcDream.UI.Abstractions.Input.InputAction.AcdreamDumpNearby:
|
||||
DumpPlayerAndNearbyEntities();
|
||||
break;
|
||||
|
||||
case AcDream.UI.Abstractions.Input.InputAction.AcdreamCycleTimeOfDay:
|
||||
CycleTimeOfDay();
|
||||
break;
|
||||
|
||||
case AcDream.UI.Abstractions.Input.InputAction.AcdreamSensitivityDown:
|
||||
AdjustActiveSensitivity(1f / 1.2f);
|
||||
break;
|
||||
|
||||
case AcDream.UI.Abstractions.Input.InputAction.AcdreamSensitivityUp:
|
||||
AdjustActiveSensitivity(1.2f);
|
||||
break;
|
||||
|
||||
case AcDream.UI.Abstractions.Input.InputAction.AcdreamCycleWeather:
|
||||
CycleWeather();
|
||||
break;
|
||||
|
||||
case AcDream.UI.Abstractions.Input.InputAction.AcdreamToggleFlyMode:
|
||||
// K-fix3 (2026-04-26): proper round-trip when player has
|
||||
// an active chase camera. ToggleFly() only swaps
|
||||
// Fly↔Orbit, so a user who flew out of player mode used
|
||||
// to land in Holtburg-orbit on toggle-back. With a chase
|
||||
// camera available, prefer Fly→Chase / Chase→Fly so the
|
||||
// user round-trips back to the same player view.
|
||||
_playerModeController?.ToggleFlyOrChase();
|
||||
break;
|
||||
|
||||
case AcDream.UI.Abstractions.Input.InputAction.AcdreamTogglePlayerMode:
|
||||
_playerModeController?.Toggle();
|
||||
break;
|
||||
|
||||
case AcDream.UI.Abstractions.Input.InputAction.ToggleChatEntry:
|
||||
// K.2: Tab focuses the chat input. ChatPanel.FocusInput()
|
||||
// sets a one-shot flag that emits SetKeyboardFocusHere on
|
||||
// the next render. No-op when the devtools presenter is absent
|
||||
// (offline / non-devtools build) — the dispatcher still
|
||||
// logs the action via the [input] diagnostic above so the
|
||||
// path is observable in either case.
|
||||
_devToolsFramePresenter?.FocusChatInput();
|
||||
break;
|
||||
|
||||
case AcDream.UI.Abstractions.Input.InputAction.ToggleOptionsPanel:
|
||||
// K.3: F11 toggles the Settings panel. Null-safe vs.
|
||||
// devtools-off / panel-not-registered — the [input] log
|
||||
// line above still records the press regardless.
|
||||
_devToolsFramePresenter?.ToggleSettingsPanel();
|
||||
break;
|
||||
|
||||
case AcDream.UI.Abstractions.Input.InputAction.CombatToggleCombat:
|
||||
ToggleLiveCombatMode();
|
||||
break;
|
||||
|
||||
case AcDream.UI.Abstractions.Input.InputAction.EscapeKey:
|
||||
if (_itemInteractionController?.IsAnyTargetModeActive == true)
|
||||
_itemInteractionController.CancelTargetMode();
|
||||
else if (_cameraController?.IsFlyMode == true)
|
||||
_cameraController.ToggleFly(); // exit fly, release cursor
|
||||
else if (_playerMode)
|
||||
_playerModeController?.Exit();
|
||||
else
|
||||
_window!.Close();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void ToggleLiveCombatMode()
|
||||
{
|
||||
AcDream.Core.Net.WorldSession? session = LiveSession;
|
||||
if (_liveSessionHost?.IsInWorld != true || session is null)
|
||||
return;
|
||||
|
||||
IReadOnlyList<AcDream.Core.Items.ClientObject> orderedEquipment =
|
||||
Objects.GetEquippedBy(_playerServerGuid);
|
||||
var defaultMode = AcDream.Core.Combat.CombatInputPlanner
|
||||
.GetDefaultCombatMode(orderedEquipment);
|
||||
var nextMode = AcDream.Core.Combat.CombatInputPlanner.ToggleMode(
|
||||
Combat.CurrentMode,
|
||||
defaultMode);
|
||||
_itemInteractionController?.NotifyExplicitCombatModeRequest();
|
||||
session.SendChangeCombatMode(nextMode);
|
||||
Combat.SetCombatMode(nextMode);
|
||||
string text = $"Combat mode {nextMode}";
|
||||
Console.WriteLine($"combat: {text}");
|
||||
_debugVm?.AddToast(text);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Item-target-mode world pick at the current cursor. The renderer supplies the
|
||||
/// exact visible CPhysicsPart equivalents and RetailWorldPicker performs
|
||||
|
|
@ -3843,87 +3692,6 @@ public sealed class GameWindow : IDisposable
|
|||
return unhydratable;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// K.1b: F8/F9 sensitivity adjust extracted into a helper. Multiplies
|
||||
/// the currently-active mode's sensitivity (chase / fly / orbit) by the
|
||||
/// given factor and clamps to [0.005, 3.0].
|
||||
/// </summary>
|
||||
private void AdjustActiveSensitivity(float factor)
|
||||
{
|
||||
if (_cameraPointerInput is not { } pointer)
|
||||
return;
|
||||
string message = pointer.AdjustSensitivity(factor);
|
||||
_debugVm?.AddToast(message);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// K.1b: F3 dump handler extracted into a method. Same body as the
|
||||
/// previous in-line F3 branch — prints the player's position +
|
||||
/// nearby visible entities + nearby shadow physics objects.
|
||||
/// </summary>
|
||||
private void DumpPlayerAndNearbyEntities()
|
||||
{
|
||||
System.Numerics.Vector3 pos;
|
||||
if (_playerMode && _playerController is not null)
|
||||
pos = _playerController.Position;
|
||||
else
|
||||
{
|
||||
System.Numerics.Matrix4x4.Invert(_cameraController!.Active.View, out var iv);
|
||||
pos = new System.Numerics.Vector3(iv.M41, iv.M42, iv.M43);
|
||||
}
|
||||
int lbX = _liveCenterX + (int)MathF.Floor(pos.X / 192f);
|
||||
int lbY = _liveCenterY + (int)MathF.Floor(pos.Y / 192f);
|
||||
Console.WriteLine(
|
||||
$"=== F3 DEBUG DUMP ===\n" +
|
||||
$" player pos=({pos.X:F2},{pos.Y:F2},{pos.Z:F2})\n" +
|
||||
$" landblock=0x{(uint)((lbX << 24) | (lbY << 16) | 0xFFFF):X8} local=({pos.X - (lbX - _liveCenterX) * 192f:F2},{pos.Y - (lbY - _liveCenterY) * 192f:F2})\n" +
|
||||
$" total shadow objects: {_physicsEngine.ShadowObjects.TotalRegistered}");
|
||||
|
||||
var visibleNearby = new List<AcDream.Core.World.WorldEntity>();
|
||||
foreach (var e in _worldState.Entities)
|
||||
{
|
||||
float dx = e.Position.X - pos.X;
|
||||
float dy = e.Position.Y - pos.Y;
|
||||
if (dx * dx + dy * dy < 15f * 15f) visibleNearby.Add(e);
|
||||
}
|
||||
Console.WriteLine($" VISIBLE entities within 15m: {visibleNearby.Count}");
|
||||
foreach (var e in visibleNearby.OrderBy(e => (e.Position - pos).Length()).Take(12))
|
||||
{
|
||||
float d = (e.Position - pos).Length();
|
||||
Console.WriteLine(
|
||||
$" VIS id=0x{e.Id:X8} src=0x{e.SourceGfxObjOrSetupId:X8} " +
|
||||
$"pos=({e.Position.X:F2},{e.Position.Y:F2},{e.Position.Z:F2}) dist={d:F2} scale={e.Scale:F2}");
|
||||
}
|
||||
|
||||
var sorted = new List<(AcDream.Core.Physics.ShadowEntry obj, float dist)>();
|
||||
foreach (var o in _physicsEngine.ShadowObjects.AllEntriesForDebug())
|
||||
{
|
||||
float dx = o.Position.X - pos.X;
|
||||
float dy = o.Position.Y - pos.Y;
|
||||
float d = MathF.Sqrt(dx * dx + dy * dy);
|
||||
if (d < 15f) sorted.Add((o, d));
|
||||
}
|
||||
sorted.Sort((a, b) => a.dist.CompareTo(b.dist));
|
||||
Console.WriteLine($" SHADOW objects within 15m: {sorted.Count}");
|
||||
foreach (var (o, d) in sorted.Take(12))
|
||||
{
|
||||
Console.WriteLine(
|
||||
$" SHAD id=0x{o.EntityId:X8} {o.CollisionType} r={o.Radius:F2} h={o.CylHeight:F2} " +
|
||||
$"pos=({o.Position.X:F2},{o.Position.Y:F2},{o.Position.Z:F2}) dist={d:F2}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// K.1b: ScrollUp / ScrollDown action handler. Adjusts whichever
|
||||
/// camera distance is current — chase camera distance in player mode,
|
||||
/// orbit camera distance otherwise. Fly mode ignores scroll. Magnitude
|
||||
/// is fixed-step (the previous proportional scroll.Y was lost when we
|
||||
/// moved scroll into the dispatcher, but the discrete step matches
|
||||
/// retail wheel feel).
|
||||
/// </summary>
|
||||
private void HandleScrollAction(AcDream.UI.Abstractions.Input.InputAction action)
|
||||
=> _cameraPointerInput?.HandleScroll(action);
|
||||
|
||||
private void OnClosing()
|
||||
{
|
||||
_hostQuiescence.StopAccepting();
|
||||
|
|
@ -3956,6 +3724,10 @@ public sealed class GameWindow : IDisposable
|
|||
// are already inert.
|
||||
new ResourceShutdownStage("input callback deactivation",
|
||||
[
|
||||
new("combat command slot", _liveCombatModeCommands.Deactivate),
|
||||
new("diagnostic command slot", _runtimeDiagnosticCommands.Deactivate),
|
||||
new("retained gameplay", () => _retainedUiGameplayBinding?.Deactivate()),
|
||||
new("gameplay actions", () => _gameplayInputActions?.Deactivate()),
|
||||
new("camera pointer", () => _cameraPointerInput?.Deactivate()),
|
||||
new("dispatcher", () => _inputDispatcher?.Deactivate()),
|
||||
new("mouse source", () => _mouseSource?.Deactivate()),
|
||||
|
|
@ -3991,6 +3763,28 @@ public sealed class GameWindow : IDisposable
|
|||
]),
|
||||
new ResourceShutdownStage("input callback detach",
|
||||
[
|
||||
new("retained gameplay", () =>
|
||||
{
|
||||
AcDream.App.Input.RetainedUiGameplayBinding? binding =
|
||||
_retainedUiGameplayBinding;
|
||||
if (binding is null) return;
|
||||
binding.Dispose();
|
||||
if (!binding.IsDisposalComplete)
|
||||
throw new InvalidOperationException(
|
||||
"Retained gameplay callback removal remains pending.");
|
||||
_retainedUiGameplayBinding = null;
|
||||
}),
|
||||
new("gameplay actions", () =>
|
||||
{
|
||||
AcDream.App.Input.GameplayInputActionRouter? actions =
|
||||
_gameplayInputActions;
|
||||
if (actions is null) return;
|
||||
actions.Dispose();
|
||||
if (!actions.IsDisposalComplete)
|
||||
throw new InvalidOperationException(
|
||||
"Gameplay action callback removal remains pending.");
|
||||
_gameplayInputActions = null;
|
||||
}),
|
||||
new("retained UI input", () => _uiHost?.DeactivateInput()),
|
||||
new("developer tools input", () => _devToolsInputContext?.Dispose()),
|
||||
new("camera pointer", () =>
|
||||
|
|
@ -4003,14 +3797,11 @@ public sealed class GameWindow : IDisposable
|
|||
throw new InvalidOperationException(
|
||||
"Camera pointer callback removal remains pending.");
|
||||
}),
|
||||
new("combat input subscription", () =>
|
||||
Combat.CombatModeChanged -= SetInputCombatScope),
|
||||
new("dispatcher", () =>
|
||||
{
|
||||
AcDream.UI.Abstractions.Input.InputDispatcher? dispatcher =
|
||||
_inputDispatcher;
|
||||
if (dispatcher is null) return;
|
||||
dispatcher.Fired -= OnInputAction;
|
||||
_movementInput.Unbind(dispatcher);
|
||||
_cameraInput.Unbind(dispatcher);
|
||||
dispatcher.Dispose();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue