fix(client): restore retail interaction parity
Harden keyboard and camera routing, inventory and vendor interactions, chat/emotes, relog portal flow, and paperdoll rendering. Add retail research, connected gate coverage, and release-gate validation.
This commit is contained in:
parent
0c699240e0
commit
f6fe0f2a4f
151 changed files with 10162 additions and 1211 deletions
|
|
@ -316,6 +316,88 @@ internal sealed class CameraPointerInputController
|
|||
}
|
||||
}
|
||||
|
||||
public bool HandleCameraAction(
|
||||
InputAction action,
|
||||
ActivationType activation)
|
||||
{
|
||||
if (activation != ActivationType.Press
|
||||
|| !_playerMode.IsPlayerMode
|
||||
|| !_camera.IsChaseMode)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
bool handled = action is
|
||||
InputAction.CameraViewDefault
|
||||
or InputAction.CameraAlternateViewDefault
|
||||
or InputAction.CameraViewFirstPerson
|
||||
or InputAction.CameraAlternateViewFirstPerson
|
||||
or InputAction.CameraViewLookDown
|
||||
or InputAction.CameraAlternateViewLookDown
|
||||
or InputAction.CameraViewMapMode
|
||||
or InputAction.CameraAlternateViewMapMode;
|
||||
if (!handled)
|
||||
return false;
|
||||
|
||||
ApplyCameraPreset(_chase.Retail, action);
|
||||
ApplyCameraPreset(_chase.Legacy, action);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void ApplyCameraPreset(
|
||||
RetailChaseCamera? camera,
|
||||
InputAction action)
|
||||
{
|
||||
if (camera is null)
|
||||
return;
|
||||
switch (action)
|
||||
{
|
||||
case InputAction.CameraViewDefault:
|
||||
case InputAction.CameraAlternateViewDefault:
|
||||
camera.SetRetailDefaultView();
|
||||
break;
|
||||
case InputAction.CameraViewFirstPerson:
|
||||
case InputAction.CameraAlternateViewFirstPerson:
|
||||
camera.SetRetailFirstPersonView();
|
||||
break;
|
||||
case InputAction.CameraViewLookDown:
|
||||
case InputAction.CameraAlternateViewLookDown:
|
||||
camera.ToggleRetailLookDownView();
|
||||
break;
|
||||
case InputAction.CameraViewMapMode:
|
||||
case InputAction.CameraAlternateViewMapMode:
|
||||
camera.ToggleRetailMapModeView();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private static void ApplyCameraPreset(
|
||||
ChaseCamera? camera,
|
||||
InputAction action)
|
||||
{
|
||||
if (camera is null)
|
||||
return;
|
||||
switch (action)
|
||||
{
|
||||
case InputAction.CameraViewDefault:
|
||||
case InputAction.CameraAlternateViewDefault:
|
||||
camera.SetRetailDefaultView();
|
||||
break;
|
||||
case InputAction.CameraViewFirstPerson:
|
||||
case InputAction.CameraAlternateViewFirstPerson:
|
||||
camera.SetRetailFirstPersonView();
|
||||
break;
|
||||
case InputAction.CameraViewLookDown:
|
||||
case InputAction.CameraAlternateViewLookDown:
|
||||
camera.ToggleRetailLookDownView();
|
||||
break;
|
||||
case InputAction.CameraViewMapMode:
|
||||
case InputAction.CameraAlternateViewMapMode:
|
||||
camera.ToggleRetailMapModeView();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
public string AdjustSensitivity(float factor)
|
||||
{
|
||||
string mode;
|
||||
|
|
|
|||
|
|
@ -15,7 +15,9 @@ internal readonly record struct ChaseCameraAdjustmentInput(
|
|||
bool ZoomIn,
|
||||
bool ZoomOut,
|
||||
bool Raise,
|
||||
bool Lower);
|
||||
bool Lower,
|
||||
bool RotateLeft,
|
||||
bool RotateRight);
|
||||
|
||||
internal interface ICameraFrameInputSource
|
||||
{
|
||||
|
|
@ -71,9 +73,21 @@ internal sealed class DispatcherCameraInputSource : ICameraFrameInputSource
|
|||
return default;
|
||||
|
||||
return new ChaseCameraAdjustmentInput(
|
||||
dispatcher.IsActionHeld(InputAction.CameraZoomIn),
|
||||
dispatcher.IsActionHeld(InputAction.CameraZoomOut),
|
||||
dispatcher.IsActionHeld(InputAction.CameraRaise),
|
||||
dispatcher.IsActionHeld(InputAction.CameraLower));
|
||||
dispatcher.IsActionHeld(InputAction.CameraZoomIn)
|
||||
|| dispatcher.IsActionHeld(InputAction.CameraMoveToward)
|
||||
|| dispatcher.IsActionHeld(InputAction.CameraAlternateMoveToward),
|
||||
dispatcher.IsActionHeld(InputAction.CameraZoomOut)
|
||||
|| dispatcher.IsActionHeld(InputAction.CameraMoveAway)
|
||||
|| dispatcher.IsActionHeld(InputAction.CameraAlternateMoveAway),
|
||||
dispatcher.IsActionHeld(InputAction.CameraRaise)
|
||||
|| dispatcher.IsActionHeld(InputAction.CameraRotateUp)
|
||||
|| dispatcher.IsActionHeld(InputAction.CameraAlternateRotateUp),
|
||||
dispatcher.IsActionHeld(InputAction.CameraLower)
|
||||
|| dispatcher.IsActionHeld(InputAction.CameraRotateDown)
|
||||
|| dispatcher.IsActionHeld(InputAction.CameraAlternateRotateDown),
|
||||
dispatcher.IsActionHeld(InputAction.CameraRotateLeft)
|
||||
|| dispatcher.IsActionHeld(InputAction.CameraAlternateRotateLeft),
|
||||
dispatcher.IsActionHeld(InputAction.CameraRotateRight)
|
||||
|| dispatcher.IsActionHeld(InputAction.CameraAlternateRotateRight));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ using AcDream.App.Rendering;
|
|||
using AcDream.App.UI;
|
||||
using AcDream.Core.Combat;
|
||||
using AcDream.Runtime;
|
||||
using AcDream.Runtime.Gameplay;
|
||||
using AcDream.UI.Abstractions.Input;
|
||||
|
||||
namespace AcDream.App.Input;
|
||||
|
|
@ -14,6 +15,8 @@ internal interface IGameplayInputActionSurface
|
|||
void RemoveFired(Action<InputAction, ActivationType> callback);
|
||||
|
||||
void SetCombatScope(InputScope? scope);
|
||||
|
||||
void SetCameraAlternateScope(bool active);
|
||||
}
|
||||
|
||||
internal sealed class DispatcherGameplayInputActionSurface(InputDispatcher dispatcher)
|
||||
|
|
@ -30,6 +33,9 @@ internal sealed class DispatcherGameplayInputActionSurface(InputDispatcher dispa
|
|||
|
||||
public void SetCombatScope(InputScope? scope) =>
|
||||
_dispatcher.SetCombatScope(scope);
|
||||
|
||||
public void SetCameraAlternateScope(bool active) =>
|
||||
_dispatcher.SetCameraAlternateScope(active);
|
||||
}
|
||||
|
||||
internal interface ICombatModeEventSurface
|
||||
|
|
@ -66,6 +72,8 @@ internal interface IGameplayInputPriorityTargets
|
|||
|
||||
bool HandleRetainedUiAction(InputAction action);
|
||||
|
||||
bool HandleCharacterOptionAction(InputAction action);
|
||||
|
||||
bool HandleSelectionAction(InputAction action);
|
||||
|
||||
bool HandlePressedMovementAction(InputAction action);
|
||||
|
|
@ -87,6 +95,7 @@ internal sealed class RuntimeGameplayInputPriorityTargets
|
|||
private readonly IGameRuntimeView _runtimeView;
|
||||
private readonly IRuntimeSelectionCommands _runtimeSelection;
|
||||
private readonly IRuntimeMovementCommands _runtimeMovement;
|
||||
private readonly IRuntimeCharacterCommands _runtimeCharacter;
|
||||
private readonly IGameplayInputCommandTarget _commands;
|
||||
|
||||
public RuntimeGameplayInputPriorityTargets(
|
||||
|
|
@ -97,6 +106,7 @@ internal sealed class RuntimeGameplayInputPriorityTargets
|
|||
IGameRuntimeView runtimeView,
|
||||
IRuntimeSelectionCommands runtimeSelection,
|
||||
IRuntimeMovementCommands runtimeMovement,
|
||||
IRuntimeCharacterCommands runtimeCharacter,
|
||||
IGameplayInputCommandTarget commands)
|
||||
{
|
||||
_frame = frame ?? throw new ArgumentNullException(nameof(frame));
|
||||
|
|
@ -109,11 +119,14 @@ internal sealed class RuntimeGameplayInputPriorityTargets
|
|||
?? throw new ArgumentNullException(nameof(runtimeSelection));
|
||||
_runtimeMovement = runtimeMovement
|
||||
?? throw new ArgumentNullException(nameof(runtimeMovement));
|
||||
_runtimeCharacter = runtimeCharacter
|
||||
?? throw new ArgumentNullException(nameof(runtimeCharacter));
|
||||
_commands = commands ?? throw new ArgumentNullException(nameof(commands));
|
||||
}
|
||||
|
||||
public bool HandlePointerAction(InputAction action, ActivationType activation) =>
|
||||
_frame.HandlePointerAction(action, activation);
|
||||
_frame.HandlePointerAction(action, activation)
|
||||
|| _pointer.HandleCameraAction(action, activation);
|
||||
|
||||
public void HandleScroll(InputAction action) =>
|
||||
_pointer.HandleScroll(action);
|
||||
|
|
@ -122,10 +135,74 @@ internal sealed class RuntimeGameplayInputPriorityTargets
|
|||
_frame.HandleCombatAction(action, activation);
|
||||
|
||||
public bool HandleRetainedUiAction(InputAction action) =>
|
||||
_retainedUi?.HandleInputAction(action) == true;
|
||||
FinishJumpBeforeUi(action)
|
||||
|| _retainedUi?.HandleInputAction(action) == true;
|
||||
|
||||
public bool HandleCharacterOptionAction(InputAction action)
|
||||
{
|
||||
if (!RetailActionIdentityTable.TryGetCharacterOptionId(
|
||||
action,
|
||||
out uint optionId)
|
||||
|| !CharacterOptionTable.TryGet(
|
||||
optionId,
|
||||
out CharacterOptionTableEntry entry))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
RuntimeCharacterOptionsSnapshot options =
|
||||
_runtimeView.Character.Snapshot.Options;
|
||||
uint word = entry.IsOptions1 ? options.Options1 : options.Options2;
|
||||
bool current = (word & entry.Mask) != 0u;
|
||||
_runtimeCharacter.SetSingleOption(
|
||||
_runtimeView.Generation,
|
||||
optionId,
|
||||
!current);
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool FinishJumpBeforeUi(InputAction action)
|
||||
{
|
||||
RuntimeMovementCommand? command = ResolveEscapeMovementCommand(
|
||||
action,
|
||||
_runtimeView.Movement.IsStandingStill,
|
||||
_runtimeView.Movement.JumpCharge,
|
||||
_runtimeView.Actions.Snapshot.CombatAttack);
|
||||
if (command != RuntimeMovementCommand.FinishJump)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
_runtimeMovement.Execute(
|
||||
_runtimeView.Generation,
|
||||
command.Value);
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool HandleSelectionAction(InputAction action)
|
||||
{
|
||||
if (action == InputAction.EscapeKey)
|
||||
{
|
||||
IRuntimeMovementView movement = _runtimeView.Movement;
|
||||
RuntimeCombatAttackSnapshot attack = _runtimeView.Actions.Snapshot
|
||||
.CombatAttack;
|
||||
RuntimeMovementCommand? escapeCommand =
|
||||
ResolveEscapeMovementCommand(
|
||||
action,
|
||||
movement.IsStandingStill,
|
||||
movement.JumpCharge,
|
||||
attack);
|
||||
if (escapeCommand == RuntimeMovementCommand.StopCompletely)
|
||||
{
|
||||
_runtimeMovement.Execute(
|
||||
_runtimeView.Generation,
|
||||
escapeCommand.Value);
|
||||
if (attack.RepeatAttackInProgress)
|
||||
_frame.AbortAutomaticAttack();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
RuntimeSelectionCommand? command = action switch
|
||||
{
|
||||
InputAction.SelectionClosestMonster =>
|
||||
|
|
@ -149,15 +226,32 @@ internal sealed class RuntimeGameplayInputPriorityTargets
|
|||
return _selection?.HandleInputAction(action) == true;
|
||||
}
|
||||
|
||||
internal static RuntimeMovementCommand? ResolveEscapeMovementCommand(
|
||||
InputAction action,
|
||||
bool isStandingStill,
|
||||
in AcDream.Runtime.Gameplay.JumpChargeSnapshot jumpCharge,
|
||||
in RuntimeCombatAttackSnapshot attack)
|
||||
{
|
||||
if (action != InputAction.EscapeKey)
|
||||
return null;
|
||||
if (jumpCharge.IsCharging)
|
||||
return RuntimeMovementCommand.FinishJump;
|
||||
if (!isStandingStill || attack.RepeatAttackInProgress)
|
||||
return RuntimeMovementCommand.StopCompletely;
|
||||
return null;
|
||||
}
|
||||
|
||||
public bool HandlePressedMovementAction(InputAction action)
|
||||
{
|
||||
RuntimeMovementCommand? command = action switch
|
||||
if (RetailEmoteMotionTable.TryGetMotion(action, out uint motion))
|
||||
{
|
||||
InputAction.MovementRunLock =>
|
||||
RuntimeMovementCommand.ToggleRunLock,
|
||||
InputAction.MovementStop => RuntimeMovementCommand.Stop,
|
||||
_ => null,
|
||||
};
|
||||
_runtimeMovement.ExecuteMotion(
|
||||
_runtimeView.Generation,
|
||||
motion);
|
||||
return true;
|
||||
}
|
||||
|
||||
RuntimeMovementCommand? command = ResolvePressedMovementCommand(action);
|
||||
if (command is { } typed)
|
||||
{
|
||||
_runtimeMovement.Execute(_runtimeView.Generation, typed);
|
||||
|
|
@ -167,6 +261,18 @@ internal sealed class RuntimeGameplayInputPriorityTargets
|
|||
return _frame.HandlePressedMovementAction(action);
|
||||
}
|
||||
|
||||
internal static RuntimeMovementCommand? ResolvePressedMovementCommand(
|
||||
InputAction action) => action switch
|
||||
{
|
||||
InputAction.MovementRunLock => RuntimeMovementCommand.ToggleRunLock,
|
||||
InputAction.MovementStop => RuntimeMovementCommand.Stop,
|
||||
InputAction.Ready => RuntimeMovementCommand.Ready,
|
||||
InputAction.Sitting => RuntimeMovementCommand.Sit,
|
||||
InputAction.Crouch => RuntimeMovementCommand.Crouch,
|
||||
InputAction.Sleeping => RuntimeMovementCommand.Sleep,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
public void HandleCommand(InputAction action) =>
|
||||
_commands.Handle(action);
|
||||
}
|
||||
|
|
@ -298,6 +404,14 @@ internal sealed class GameplayInputActionRouter : IDisposable
|
|||
{
|
||||
_log($"[input] {action} {activation}");
|
||||
|
||||
if (action == InputAction.CameraActivateAlternateMode)
|
||||
{
|
||||
if (activation == ActivationType.Press)
|
||||
_actions.SetCameraAlternateScope(true);
|
||||
else if (activation == ActivationType.Release)
|
||||
_actions.SetCameraAlternateScope(false);
|
||||
}
|
||||
|
||||
if (_targets.HandlePointerAction(action, activation))
|
||||
return;
|
||||
|
||||
|
|
@ -320,6 +434,8 @@ internal sealed class GameplayInputActionRouter : IDisposable
|
|||
|
||||
if (_targets.HandleRetainedUiAction(action))
|
||||
return;
|
||||
if (_targets.HandleCharacterOptionAction(action))
|
||||
return;
|
||||
if (_targets.HandleSelectionAction(action))
|
||||
return;
|
||||
if (_targets.HandlePressedMovementAction(action))
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
using AcDream.App.Combat;
|
||||
using AcDream.App.Diagnostics;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.App.UI;
|
||||
using AcDream.Runtime;
|
||||
using AcDream.UI.Abstractions.Input;
|
||||
|
|
@ -24,6 +23,12 @@ internal interface IRetainedGameplayWindowCommands
|
|||
/// <c>RetailUiRuntime.BindToolbarPanelButtons</c>.
|
||||
/// </summary>
|
||||
void ToggleOptionsPanel();
|
||||
|
||||
void ToggleGameplayOptionsPage();
|
||||
|
||||
void FocusChatEntry();
|
||||
|
||||
void LogOutCharacter();
|
||||
}
|
||||
|
||||
internal sealed class RetainedGameplayWindowCommands(RetailUiRuntime? runtime)
|
||||
|
|
@ -39,35 +44,31 @@ internal sealed class RetainedGameplayWindowCommands(RetailUiRuntime? runtime)
|
|||
|
||||
public void ToggleOptionsPanel() =>
|
||||
_runtime?.ToggleWindow(WindowNames.Options);
|
||||
|
||||
public void ToggleGameplayOptionsPage() =>
|
||||
_runtime?.ToggleGameplayOptionsPage();
|
||||
|
||||
public void FocusChatEntry() => _runtime?.FocusChatEntry();
|
||||
|
||||
public void LogOutCharacter() => _runtime?.LogOutCharacter();
|
||||
}
|
||||
|
||||
internal interface IPlayerModeGameplayCommands
|
||||
{
|
||||
bool IsPlayerMode { get; }
|
||||
|
||||
void ToggleFlyOrChase();
|
||||
|
||||
void TogglePlayerMode();
|
||||
|
||||
void ExitPlayerMode();
|
||||
}
|
||||
|
||||
internal sealed class PlayerModeGameplayCommands(
|
||||
ILocalPlayerModeSource mode,
|
||||
PlayerModeController controller) : IPlayerModeGameplayCommands
|
||||
internal sealed class PlayerModeGameplayCommands(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
|
||||
|
|
@ -88,37 +89,6 @@ internal sealed class ItemTargetModeCommands(ItemInteractionController items)
|
|||
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);
|
||||
|
|
@ -135,10 +105,8 @@ internal sealed class GameplayInputCommandController : IGameplayInputCommandTarg
|
|||
private readonly IRuntimeDiagnosticCommands _diagnostics;
|
||||
private readonly IPlayerModeGameplayCommands _playerMode;
|
||||
private readonly IItemTargetModeCommands _targetMode;
|
||||
private readonly IGameplayCameraModeCommands _camera;
|
||||
private readonly IGameRuntimeView _runtimeView;
|
||||
private readonly IRuntimeCombatCommands _combat;
|
||||
private readonly IGameplayWindowCommands _window;
|
||||
private readonly Action? _toggleAudioMute;
|
||||
|
||||
public GameplayInputCommandController(
|
||||
|
|
@ -146,21 +114,17 @@ internal sealed class GameplayInputCommandController : IGameplayInputCommandTarg
|
|||
IRuntimeDiagnosticCommands diagnostics,
|
||||
IPlayerModeGameplayCommands playerMode,
|
||||
IItemTargetModeCommands targetMode,
|
||||
IGameplayCameraModeCommands camera,
|
||||
IGameRuntimeView runtimeView,
|
||||
IRuntimeCombatCommands combat,
|
||||
IGameplayWindowCommands window,
|
||||
Action? toggleAudioMute = null)
|
||||
{
|
||||
_retained = retained ?? throw new ArgumentNullException(nameof(retained));
|
||||
_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));
|
||||
_runtimeView = runtimeView
|
||||
?? throw new ArgumentNullException(nameof(runtimeView));
|
||||
_combat = combat ?? throw new ArgumentNullException(nameof(combat));
|
||||
_window = window ?? throw new ArgumentNullException(nameof(window));
|
||||
_toggleAudioMute = toggleAudioMute;
|
||||
}
|
||||
|
||||
|
|
@ -206,10 +170,12 @@ internal sealed class GameplayInputCommandController : IGameplayInputCommandTarg
|
|||
_playerMode.TogglePlayerMode();
|
||||
return true;
|
||||
case InputAction.ToggleChatEntry:
|
||||
// OP9: IDevToolsGameplayCommands.FocusChatInput() retired —
|
||||
// same shape as AcdreamToggleDebugPanel above (its ImGui
|
||||
// ChatPanel target was already gone). Tab is still consumed
|
||||
// here, matching the prior no-op's "handled" contract.
|
||||
case InputAction.EnterChatMode:
|
||||
// Physical Tab/Enter are normally consumed by UiRoot before
|
||||
// the dispatcher. This semantic route is what makes a rebound
|
||||
// key and headless/UI automation reach that same retained
|
||||
// chat field.
|
||||
_retained.FocusChatEntry();
|
||||
return true;
|
||||
case InputAction.ToggleOptionsPanel:
|
||||
// Campaign OP slice OP3 (D1): F11 opens the RETAIL Options
|
||||
|
|
@ -227,6 +193,9 @@ internal sealed class GameplayInputCommandController : IGameplayInputCommandTarg
|
|||
_runtimeView.Generation,
|
||||
RuntimeCombatCommand.ToggleMode);
|
||||
return true;
|
||||
case InputAction.LOGOUT:
|
||||
_retained.LogOutCharacter();
|
||||
return true;
|
||||
case InputAction.EscapeKey:
|
||||
HandleEscape();
|
||||
return true;
|
||||
|
|
@ -239,9 +208,7 @@ internal sealed class GameplayInputCommandController : IGameplayInputCommandTarg
|
|||
{
|
||||
if (_targetMode.IsAnyTargetModeActive)
|
||||
_targetMode.CancelTargetMode();
|
||||
else if (_playerMode.IsPlayerMode)
|
||||
_playerMode.ExitPlayerMode();
|
||||
else
|
||||
_window.Close();
|
||||
_retained.ToggleGameplayOptionsPage();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ internal interface ICombatInputFrameController
|
|||
{
|
||||
void Tick();
|
||||
void HandleMovementInput(InputAction action, ActivationType activation);
|
||||
void AbortAutomaticAttack();
|
||||
bool HandleInputAction(InputAction action, ActivationType activation);
|
||||
}
|
||||
|
||||
|
|
@ -38,6 +39,11 @@ internal sealed class CombatAttackInputFrameAdapter : ICombatInputFrameControlle
|
|||
RuntimeInputActivation.Press));
|
||||
}
|
||||
|
||||
public void AbortAutomaticAttack() =>
|
||||
_owner.HandleCommand(new RuntimeCombatAttackInput(
|
||||
RuntimeCombatAttackCommand.AbortForMovement,
|
||||
RuntimeInputActivation.Press));
|
||||
|
||||
public bool HandleInputAction(InputAction action, ActivationType activation)
|
||||
{
|
||||
RuntimeCombatAttackCommand? command = action switch
|
||||
|
|
@ -52,16 +58,37 @@ internal sealed class CombatAttackInputFrameAdapter : ICombatInputFrameControlle
|
|||
RuntimeCombatAttackCommand.DecreasePower,
|
||||
InputAction.CombatIncreaseAttackPower =>
|
||||
RuntimeCombatAttackCommand.IncreasePower,
|
||||
InputAction.CombatDecreaseMissileAccuracy =>
|
||||
RuntimeCombatAttackCommand.DecreasePower,
|
||||
InputAction.CombatIncreaseMissileAccuracy =>
|
||||
RuntimeCombatAttackCommand.IncreasePower,
|
||||
InputAction.CombatAimLow =>
|
||||
RuntimeCombatAttackCommand.LowAttack,
|
||||
InputAction.CombatAimMedium =>
|
||||
RuntimeCombatAttackCommand.MediumAttack,
|
||||
InputAction.CombatAimHigh =>
|
||||
RuntimeCombatAttackCommand.HighAttack,
|
||||
_ => null,
|
||||
};
|
||||
if (command is null)
|
||||
return false;
|
||||
|
||||
// A retail Hold binding emits Press once, Hold every input frame, then
|
||||
// Release on key-up. RuntimeCombatAttackState already measures the
|
||||
// Press-to-Release interval; forwarding the repeated Hold pulse as a
|
||||
// Release made Delete/End/PageDown attack on the first frame instead
|
||||
// of charging until the player released the key.
|
||||
if (activation == ActivationType.Hold)
|
||||
return true;
|
||||
|
||||
return _owner.HandleCommand(new RuntimeCombatAttackInput(
|
||||
command.Value,
|
||||
activation == ActivationType.Press
|
||||
? RuntimeInputActivation.Press
|
||||
: RuntimeInputActivation.Release));
|
||||
activation switch
|
||||
{
|
||||
ActivationType.Press => RuntimeInputActivation.Press,
|
||||
ActivationType.Release => RuntimeInputActivation.Release,
|
||||
_ => RuntimeInputActivation.Press,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -114,6 +141,8 @@ internal sealed class GameplayInputFrameController
|
|||
public bool HandlePressedMovementAction(InputAction action) =>
|
||||
_movement.HandlePressedAction(action);
|
||||
|
||||
public void AbortAutomaticAttack() => _combat.AbortAutomaticAttack();
|
||||
|
||||
public void QueueRawMouseDelta(float dx, float dy) =>
|
||||
_mouseLook?.QueueRawDelta(dx, dy);
|
||||
|
||||
|
|
|
|||
|
|
@ -149,7 +149,9 @@ internal sealed class MouseLookController : IMouseLookInputFrameController
|
|||
return true;
|
||||
}
|
||||
|
||||
if (action != InputAction.CameraInstantMouseLook)
|
||||
if (action is not (
|
||||
InputAction.CameraInstantMouseLook
|
||||
or InputAction.CameraActivateAlternateMode))
|
||||
return false;
|
||||
|
||||
if (activation == ActivationType.Press)
|
||||
|
|
|
|||
129
src/AcDream.App/Input/RetailEmoteMotionTable.cs
Normal file
129
src/AcDream.App/Input/RetailEmoteMotionTable.cs
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
using AcDream.UI.Abstractions.Input;
|
||||
|
||||
namespace AcDream.App.Input;
|
||||
|
||||
/// <summary>
|
||||
/// Verbatim Sept-2013 <c>ACCmdInterp::InitializeEmoteInputActionHash</c>
|
||||
/// (<c>0x0058B510</c>). <c>ACCmdInterp::OnAction</c>
|
||||
/// (<c>0x0058B370</c>) resolves one of these input actions and submits the
|
||||
/// corresponding raw motion through <c>SetMotion</c> with start=true.
|
||||
/// </summary>
|
||||
internal static class RetailEmoteMotionTable
|
||||
{
|
||||
private const uint EmoteInputMap = 0x10000006u;
|
||||
private const uint FirstEmoteAction = 0x10000098u;
|
||||
|
||||
// Action ids 0x10000098..0x100000EE are contiguous in retail's ActionMap.
|
||||
// Values come from the named Motion_* globals used by the initializer.
|
||||
private static readonly uint[] Motions =
|
||||
[
|
||||
0x43000118u, // AFKState
|
||||
0x13000088u, // Akimbo
|
||||
0x420000F9u, // ATOYOT
|
||||
0x430000F2u, // AkimboState
|
||||
0x43000146u, // AtEaseState
|
||||
0x1300007Au, // Beckon
|
||||
0x1300007Bu, // BeSeeingYou
|
||||
0x1300007Cu, // BlowKiss
|
||||
0x1300007Du, // BowDeep
|
||||
0x430000ECu, // BowDeepState
|
||||
0x1300004Cu, // Cheer
|
||||
0x1300007Eu, // ClapHands
|
||||
0x430000EDu, // ClapHandsState
|
||||
0x13000091u, // Cringe
|
||||
0x430000EEu, // CrossArmsState
|
||||
0x1300007Fu, // Cry
|
||||
0x43000117u, // CurtseyState
|
||||
0x1300014Eu, // DrudgeDance
|
||||
0x43000141u, // DrudgeDanceState
|
||||
0x1300014Fu, // HaveASeat
|
||||
0x43000145u, // HaveASeatState
|
||||
0x13000089u, // HeartyLaugh
|
||||
0x13000132u, // Helper
|
||||
0x13000092u, // Kneel
|
||||
0x430000F7u, // KneelState
|
||||
0x1300014Cu, // Knock
|
||||
0x13000080u, // Laugh
|
||||
0x430000F6u, // LeanState
|
||||
0x43000119u, // MeditateState
|
||||
0x13000082u, // MimeDrink
|
||||
0x13000081u, // MimeEat
|
||||
0x130000CBu, // Mock
|
||||
0x13000083u, // Nod
|
||||
0x13000147u, // NudgeLeft
|
||||
0x13000148u, // NudgeRight
|
||||
0x13000093u, // Plead
|
||||
0x430000F8u, // PleadState
|
||||
0x13000084u, // Point
|
||||
0x430000F0u, // PointState
|
||||
0x1300014Bu, // PointDown
|
||||
0x43000140u, // PointDownState
|
||||
0x13000149u, // PointLeft
|
||||
0x4300013Du, // PointLeftState
|
||||
0x1300014Au, // PointRight
|
||||
0x4300013Eu, // PointRightState
|
||||
0x43000142u, // PossumState
|
||||
0x130000CAu, // Pray
|
||||
0x430000EBu, // PrayState
|
||||
0x43000143u, // ReadState
|
||||
0x1300008Au, // Salute
|
||||
0x430000F3u, // SaluteState
|
||||
0x1300014Du, // ScanHorizon
|
||||
0x1300008Bu, // ScratchHead
|
||||
0x430000F4u, // ScratchHeadState
|
||||
0x13000079u, // ShakeFist
|
||||
0x430000EAu, // ShakeFistState
|
||||
0x13000085u, // ShakeHead
|
||||
0x13000094u, // Shiver
|
||||
0x430000EFu, // ShiverState
|
||||
0x13000095u, // Shoo
|
||||
0x13000086u, // Shrug
|
||||
0x4300013Au, // SitState
|
||||
0x4300013Cu, // SitBackState
|
||||
0x4300013Bu, // SitCrossleggedState
|
||||
0x13000096u, // Slouch
|
||||
0x430000FAu, // SlouchState
|
||||
0x1300008Cu, // SmackHead
|
||||
0x43000115u, // SnowAngelState
|
||||
0x13000097u, // Spit
|
||||
0x13000098u, // Surrender
|
||||
0x430000FBu, // SurrenderState
|
||||
0x4300013Fu, // TalktotheHandState
|
||||
0x1300008Du, // TapFoot
|
||||
0x430000F5u, // TapFootState
|
||||
0x130000CCu, // Teapot
|
||||
0x43000144u, // ThinkerState
|
||||
0x13000116u, // WarmHands
|
||||
0x13000087u, // Wave
|
||||
0x430000F1u, // WaveState
|
||||
0x1300008Fu, // WaveLow
|
||||
0x1300008Eu, // WaveHigh
|
||||
0x1300009Au, // Winded
|
||||
0x430000FDu, // WindedState
|
||||
0x13000099u, // Woah
|
||||
0x430000FCu, // WoahState
|
||||
0x13000090u, // YawnStretch
|
||||
0x1200009Bu, // YMCA
|
||||
];
|
||||
|
||||
public static int Count => Motions.Length;
|
||||
|
||||
public static bool TryGetMotion(InputAction action, out uint motion)
|
||||
{
|
||||
motion = 0u;
|
||||
if (!RetailActionIdentityTable.TryGetRetailIdentity(
|
||||
action,
|
||||
out var identity)
|
||||
|| identity.InputMapId != EmoteInputMap)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
uint index = identity.ActionId - FirstEmoteAction;
|
||||
if (index >= Motions.Length)
|
||||
return false;
|
||||
|
||||
motion = Motions[index];
|
||||
return true;
|
||||
}
|
||||
}
|
||||
601
src/AcDream.App/Input/RetailKeymapFile.cs
Normal file
601
src/AcDream.App/Input/RetailKeymapFile.cs
Normal file
|
|
@ -0,0 +1,601 @@
|
|||
using System.Globalization;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using AcDream.UI.Abstractions.Input;
|
||||
|
||||
namespace AcDream.App.Input;
|
||||
|
||||
/// <summary>
|
||||
/// Parser/writer for retail's editable <c>Documents\Asheron's Call\*.keymap</c>
|
||||
/// PFile text. Only the fourteen user-bindable input maps are replaced when a
|
||||
/// profile is loaded; acdream-only commands and retail's fixed system/edit/
|
||||
/// pointer maps remain owned by the host's base <see cref="KeyBindings"/>.
|
||||
/// </summary>
|
||||
public static class RetailKeymapFile
|
||||
{
|
||||
private static readonly Regex BindingLine = new(
|
||||
"^(?<action>[A-Za-z0-9_]+)\\s*\\[\\s*\"\"\\s*\\[\\s*"
|
||||
+ "(?<device>[0-9]+)\\s+(?<control>[A-Za-z0-9_]+)"
|
||||
+ "(?:\\s+(?<sub>[A-Za-z]+))?\\s*\\]"
|
||||
+ "(?:\\s+(?<modifier>0x[0-9A-Fa-f]+|[0-9]+))?"
|
||||
+ "(?:\\s+(?<activation>[A-Za-z]+))?\\s*\\]$",
|
||||
RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
||||
|
||||
private static readonly (uint Id, string Name)[] GroupOrder =
|
||||
{
|
||||
(0x00000004u, "MovementCommands"),
|
||||
(0x10000007u, "ItemSelectionCommands"),
|
||||
(0x10000009u, "UICommands"),
|
||||
(0x1000000Cu, "QuickslotCommands"),
|
||||
(0x1000000Du, "ToggleChatEntry"),
|
||||
(0x1000000Au, "ChatCommands"),
|
||||
(0x10000002u, "Combat"),
|
||||
(0x10000003u, "MeleeCombat"),
|
||||
(0x10000004u, "MissileCombat"),
|
||||
(0x10000005u, "MagicCombat"),
|
||||
(0x10000006u, "Emotes"),
|
||||
(0x00000005u, "CameraControls"),
|
||||
(0x00000006u, "CameraAlternateControls"),
|
||||
(0x10000008u, "CharacterOptionCommands"),
|
||||
};
|
||||
|
||||
private static readonly IReadOnlyDictionary<string, uint> GroupIds =
|
||||
GroupOrder.ToDictionary(static group => group.Name, static group => group.Id,
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Lazy because the explicit character-option semantic table is declared
|
||||
// later in this type; field initializers otherwise observe it as null.
|
||||
private static readonly Lazy<IReadOnlyDictionary<InputAction, string>> ActionNamesHolder =
|
||||
new(BuildActionNames);
|
||||
private static readonly Lazy<IReadOnlyDictionary<string, InputAction>> ActionsByFileNameHolder =
|
||||
new(BuildActionsByFileName);
|
||||
private static IReadOnlyDictionary<InputAction, string> ActionNames => ActionNamesHolder.Value;
|
||||
private static IReadOnlyDictionary<string, InputAction> ActionsByFileName =>
|
||||
ActionsByFileNameHolder.Value;
|
||||
|
||||
public static KeyBindings Parse(string text, KeyBindings baseBindings)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(text);
|
||||
ArgumentNullException.ThrowIfNull(baseBindings);
|
||||
|
||||
var result = new KeyBindings();
|
||||
foreach (Binding binding in baseBindings.All)
|
||||
{
|
||||
if (!RetailActionIdentityTable.ReverseMap.ContainsKey(binding.Action))
|
||||
result.Add(binding);
|
||||
}
|
||||
|
||||
bool foundBindings = false;
|
||||
bool inBindings = false;
|
||||
uint? currentGroup = null;
|
||||
int lineNumber = 0;
|
||||
foreach (string rawLine in text.Replace("\r\n", "\n", StringComparison.Ordinal).Split('\n'))
|
||||
{
|
||||
lineNumber++;
|
||||
string line = rawLine.Trim();
|
||||
if (line.Length == 0 || line.StartsWith('#'))
|
||||
continue;
|
||||
if (line.Equals("Bindings", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
foundBindings = true;
|
||||
inBindings = true;
|
||||
currentGroup = null;
|
||||
continue;
|
||||
}
|
||||
if (!inBindings)
|
||||
continue;
|
||||
|
||||
// In the PFile grammar every input-map name is a bare identifier on
|
||||
// the line before its opening bracket. An unknown map clears the
|
||||
// user-map context so fixed SystemKeys/EditControls rows are ignored.
|
||||
if (Regex.IsMatch(line, "^[A-Za-z][A-Za-z0-9_]*$",
|
||||
RegexOptions.CultureInvariant))
|
||||
{
|
||||
currentGroup = GroupIds.TryGetValue(line, out uint groupId)
|
||||
? groupId
|
||||
: null;
|
||||
continue;
|
||||
}
|
||||
if (currentGroup is not uint inputMapId || line is "[" or "]")
|
||||
continue;
|
||||
|
||||
Match match = BindingLine.Match(line);
|
||||
if (!match.Success)
|
||||
throw new FormatException(
|
||||
$"Malformed retail key binding at line {lineNumber}: {line}");
|
||||
|
||||
string actionName = match.Groups["action"].Value;
|
||||
if (!ActionsByFileName.TryGetValue(FileIdentity(inputMapId, actionName), out InputAction action))
|
||||
{
|
||||
// Several fixed/non-user-bindable controls live inside an
|
||||
// otherwise editable map (UICommands.EscapeKey/LOGOUT in the
|
||||
// shipped file). They remain in baseBindings just like the
|
||||
// wholly fixed maps below the user maps.
|
||||
continue;
|
||||
}
|
||||
|
||||
string control = match.Groups["control"].Value;
|
||||
if (!RetailScanCodeMap.TryFromFileControl(control, out uint scan, out uint tokenDevice)
|
||||
|| !uint.TryParse(match.Groups["device"].Value,
|
||||
NumberStyles.None, CultureInfo.InvariantCulture, out uint device)
|
||||
|| device != tokenDevice
|
||||
|| RetailScanCodeMap.ToSilkKey(scan, device) is not { } key)
|
||||
{
|
||||
throw new FormatException(
|
||||
$"Unsupported retail control '{control}' at line {lineNumber}.");
|
||||
}
|
||||
|
||||
uint fileModifier = 0u;
|
||||
if (match.Groups["modifier"].Success)
|
||||
{
|
||||
string value = match.Groups["modifier"].Value;
|
||||
NumberStyles style = value.StartsWith("0x", StringComparison.OrdinalIgnoreCase)
|
||||
? NumberStyles.AllowHexSpecifier
|
||||
: NumberStyles.None;
|
||||
string digits = style == NumberStyles.AllowHexSpecifier ? value[2..] : value;
|
||||
if (!uint.TryParse(digits, style, CultureInfo.InvariantCulture, out fileModifier))
|
||||
throw new FormatException($"Invalid modifier at line {lineNumber}.");
|
||||
}
|
||||
|
||||
var chord = new KeyChord(key, (ModifierMask)(fileModifier & 0x0Fu), (byte)device);
|
||||
result.Add(new Binding(
|
||||
chord,
|
||||
action,
|
||||
RetailActionIdentityTable.ActivationFor(inputMapId,
|
||||
RetailActionIdentityTable.ReverseMap[action].ActionId),
|
||||
RetailActionIdentityTable.ScopeForInputMap(inputMapId)));
|
||||
}
|
||||
|
||||
if (!foundBindings)
|
||||
throw new FormatException("The file does not contain a retail Bindings section.");
|
||||
return result;
|
||||
}
|
||||
|
||||
public static string Write(KeyBindings bindings)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(bindings);
|
||||
var output = new StringBuilder(24_000);
|
||||
output.AppendLine("#Asheron's Call: Throne of Destiny Keymap File")
|
||||
.AppendLine("#")
|
||||
.AppendLine("#Generated by acdream's retail Configure Keyboard screen.")
|
||||
.AppendLine("#This file is compatible with the Sept-2013 retail PFile keymap grammar.")
|
||||
.AppendLine("#")
|
||||
.AppendLine("\"User Defined Keymap\" [ 00000000-0000-0000-0000-000000000000 ]")
|
||||
.AppendLine()
|
||||
.AppendLine("Devices")
|
||||
.AppendLine("[")
|
||||
.AppendLine(" Keyboard [ GUID_SysKeyboard ]")
|
||||
.AppendLine(" Mouse [ GUID_SysMouse ]")
|
||||
.AppendLine(" Virtual [ GUID_Virtual ]")
|
||||
.AppendLine("]")
|
||||
.AppendLine()
|
||||
.AppendLine("MetaKeys")
|
||||
.AppendLine("[")
|
||||
.AppendLine(" 1 [ 0 DIK_LSHIFT ]")
|
||||
.AppendLine(" 2 [ 0 DIK_LCONTROL ]")
|
||||
.AppendLine(" 2 [ 0 DIK_RCONTROL ]")
|
||||
.AppendLine(" 3 [ 0 DIK_LMENU ]")
|
||||
.AppendLine(" 3 [ 0 DIK_RALT ]")
|
||||
.AppendLine(" 4 [ 0 DIK_LWIN ]")
|
||||
.AppendLine(" 4 [ 0 DIK_RWIN ]")
|
||||
.AppendLine(" 5 [ 1 DIMOFS_BUTTON3 ]")
|
||||
.AppendLine(" 6 [ 1 DIMOFS_BUTTON4 ]")
|
||||
.AppendLine("]")
|
||||
.AppendLine()
|
||||
.AppendLine("Bindings")
|
||||
.AppendLine("[");
|
||||
|
||||
foreach ((uint inputMapId, string groupName) in GroupOrder)
|
||||
{
|
||||
output.Append(" ").AppendLine(groupName).AppendLine(" [");
|
||||
foreach (((uint InputMapId, uint ActionId) identity, InputAction action) in
|
||||
RetailActionIdentityTable.Map
|
||||
.Where(pair => pair.Key.InputMapId == inputMapId)
|
||||
.OrderBy(static pair => pair.Key.ActionId))
|
||||
{
|
||||
foreach (Binding binding in bindings.ForAction(action))
|
||||
{
|
||||
if (!RetailScanCodeMap.TryToFileControl(binding.Chord, out string control))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"{binding.Chord} cannot be represented by the retail DirectInput keymap.");
|
||||
}
|
||||
|
||||
output.Append(" ").Append(ActionNames[action])
|
||||
.Append(" [ \"\" [ ").Append(binding.Chord.Device)
|
||||
.Append(' ').Append(control).Append(" ]");
|
||||
uint modifier = (uint)binding.Chord.Modifiers & 0x0Fu;
|
||||
if (modifier != 0u)
|
||||
output.Append(" 0x").Append(modifier.ToString("X8", CultureInfo.InvariantCulture));
|
||||
output.AppendLine(" ]");
|
||||
}
|
||||
}
|
||||
// Bare Escape is a fixed MasterInputMap control rather than one of
|
||||
// the user-bindable ActionMap rows (LOGOUT is a normal row and was
|
||||
// emitted above). Keep it in exported files so the Sept-2013 client
|
||||
// retains its priority Escape ladder when opening our profile.
|
||||
if (inputMapId == 0x10000009u)
|
||||
output.AppendLine(" EscapeKey [ \"\" [ 0 DIK_ESCAPE ] ]");
|
||||
output.AppendLine(" ]").AppendLine();
|
||||
}
|
||||
|
||||
// Retail's fixed maps are included so a file can also be opened by the
|
||||
// 2013 client. They are deliberately not imported into the 306-row GUI.
|
||||
output.Append(FixedRetailMaps);
|
||||
output.AppendLine("]");
|
||||
return output.ToString();
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<InputAction, string> BuildActionNames()
|
||||
{
|
||||
var names = new Dictionary<InputAction, string>();
|
||||
foreach (InputAction action in RetailActionIdentityTable.ReverseMap.Keys)
|
||||
names[action] = FileActionName(action);
|
||||
return names;
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, InputAction> BuildActionsByFileName()
|
||||
{
|
||||
var actions = new Dictionary<string, InputAction>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (((uint InputMapId, uint ActionId) identity, InputAction action) in
|
||||
RetailActionIdentityTable.Map)
|
||||
{
|
||||
actions.Add(FileIdentity(identity.InputMapId, ActionNames[action]), action);
|
||||
}
|
||||
return actions;
|
||||
}
|
||||
|
||||
private static string FileIdentity(uint inputMapId, string actionName) =>
|
||||
$"{inputMapId:X8}:{actionName}";
|
||||
|
||||
private static string GroupName(uint inputMapId) =>
|
||||
GroupOrder.First(group => group.Id == inputMapId).Name;
|
||||
|
||||
private static string FileActionName(InputAction action)
|
||||
{
|
||||
if (CharacterOptionNames.TryGetValue(action, out string? characterOption))
|
||||
return characterOption;
|
||||
if (action == InputAction.SelectionPlaceInInventory) return "SelectionPickUp";
|
||||
if (action == InputAction.UseSelected) return "USE";
|
||||
string name = action.ToString();
|
||||
if (name.StartsWith("CameraAlternate", StringComparison.Ordinal))
|
||||
return "Camera" + name["CameraAlternate".Length..];
|
||||
if (!name.StartsWith("Emote", StringComparison.Ordinal))
|
||||
return name;
|
||||
string emote = name["Emote".Length..];
|
||||
return emote switch
|
||||
{
|
||||
"AfkState" => "AFKState",
|
||||
"AToyotState" => "ATOYOT",
|
||||
"MimeDrinking" => "MimeDrink",
|
||||
"MimeEating" => "MimeEat",
|
||||
"TalkToTheHandState" => "TalktotheHandState",
|
||||
"YawnAndStretch" => "YawnStretch",
|
||||
"Ymca" => "YMCA",
|
||||
_ => emote,
|
||||
};
|
||||
}
|
||||
|
||||
private static readonly IReadOnlyDictionary<InputAction, string> CharacterOptionNames =
|
||||
new Dictionary<InputAction, string>
|
||||
{
|
||||
[InputAction.ToggleCharacterOptionAutoRepeatAttack] = "AutoRepeatAttacks",
|
||||
[InputAction.ToggleCharacterOptionIgnoreAllegianceRequests] = "IgnoreAllegianceRequests",
|
||||
[InputAction.ToggleCharacterOptionIgnoreFellowshipRequests] = "IgnoreFellowshipRequests",
|
||||
[InputAction.ToggleCharacterOptionIgnoreTradeRequests] = "IgnoreTradeRequests",
|
||||
[InputAction.ToggleCharacterOptionPersistentAtDay] = "PersistentAtDay",
|
||||
[InputAction.ToggleCharacterOptionAllowGive] = "LetPlayersGiveYouItems",
|
||||
[InputAction.ToggleCharacterOptionViewCombatTarget] = "AutoTrackCombatTargets",
|
||||
[InputAction.ToggleCharacterOptionShowTooltips] = "DisplayTooltips",
|
||||
[InputAction.ToggleCharacterOptionUseDeception] = "AttemptToDeceivePlayers",
|
||||
[InputAction.ToggleCharacterOptionToggleRun] = "RunAsDefaultMovement",
|
||||
[InputAction.ToggleCharacterOptionStayInChatMode] = "StayInChatModeAfterSend",
|
||||
[InputAction.ToggleCharacterOptionAdvancedCombatUi] = "AdvancedCombatInterface",
|
||||
[InputAction.ToggleCharacterOptionAutoTarget] = "AutoTarget",
|
||||
[InputAction.ToggleCharacterOptionVividTargetingIndicator] = "VividTargetIndicator",
|
||||
[InputAction.ToggleCharacterOptionFellowshipShareXp] = "ShareFellowshipXP",
|
||||
[InputAction.ToggleCharacterOptionAcceptLootPermits] = "AcceptCorpseLooting",
|
||||
[InputAction.ToggleCharacterOptionFellowshipShareLoot] = "ShareFellowshipLoot",
|
||||
[InputAction.ToggleCharacterOptionFellowshipAutoAcceptRequests] = "AutomaticallyAcceptFellowshipRequests",
|
||||
[InputAction.ToggleCharacterOptionCoordinatesOnRadar] = "ShowRadarCoordinates",
|
||||
[InputAction.ToggleCharacterOptionSpellDuration] = "ShowSpellDurations",
|
||||
[InputAction.ToggleCharacterOptionDisableHouseRestrictionEffects] = "DisableHouseEffect",
|
||||
[InputAction.ToggleCharacterOptionDragItemOnPlayerOpensSecureTrade] = "DragItemOnPlayerOpensSecureTrade",
|
||||
[InputAction.ToggleCharacterOptionDisplayAllegianceLogonNotifications] = "DisplayAllegianceLogonNotifications",
|
||||
[InputAction.ToggleCharacterOptionUseChargeAttack] = "UseChargeAttack",
|
||||
[InputAction.ToggleCharacterOptionUseCraftSuccessDialog] = "ToggleCraftingChanceOfSuccessDialog",
|
||||
[InputAction.ToggleCharacterOptionListenToAllegianceChat] = "AllegianceChat",
|
||||
[InputAction.ToggleCharacterOptionDisplayDateOfBirth] = "DisplayDateOfBirth",
|
||||
[InputAction.ToggleCharacterOptionDisplayAge] = "DisplayAge",
|
||||
[InputAction.ToggleCharacterOptionDisplayChessRank] = "DisplayChessRank",
|
||||
[InputAction.ToggleCharacterOptionDisplayFishingSkill] = "Fishing",
|
||||
[InputAction.ToggleCharacterOptionDisplayNumberDeaths] = "DisplayNumberDeaths",
|
||||
[InputAction.ToggleCharacterOptionDisplayTimeStamps] = "DisplayTimeStamps",
|
||||
[InputAction.ToggleCharacterOptionSalvageMultiple] = "SalvageMultiple",
|
||||
[InputAction.ToggleCharacterOptionListenToGeneralChat] = "GeneralChat",
|
||||
[InputAction.ToggleCharacterOptionListenToTradeChat] = "TradeChat",
|
||||
[InputAction.ToggleCharacterOptionListenToLfgChat] = "LFGChat",
|
||||
[InputAction.ToggleCharacterOptionListenToRoleplayChat] = "RoleplayChat",
|
||||
[InputAction.ToggleCharacterOptionDisplayNumberCharacterTitles] = "DisplayNumberCharacterTitles",
|
||||
[InputAction.ToggleCharacterOptionMainPackPreferred] = "MainPackPreferred",
|
||||
[InputAction.ToggleCharacterOptionLeadMissileTargets] = "LeadMissileTargets",
|
||||
[InputAction.ToggleCharacterOptionUseFastMissiles] = "UseFastMissiles",
|
||||
[InputAction.ToggleCharacterOptionFilterLanguage] = "FilterLanguage",
|
||||
[InputAction.ToggleCharacterOptionConfirmVolatileRareUse] = "ConfirmVolatileRareUse",
|
||||
[InputAction.ToggleCharacterOptionListenToSocietyChat] = "SocietyChat",
|
||||
[InputAction.ToggleCharacterOptionShowHelm] = "ShowHelm",
|
||||
[InputAction.ToggleCharacterOptionDisableDistanceFog] = "DisableDistanceFog",
|
||||
[InputAction.ToggleCharacterOptionShowCloak] = "ShowCloak",
|
||||
[InputAction.ToggleCharacterOptionSideBySideVitals] = "SideBySideVitals",
|
||||
};
|
||||
|
||||
private const string FixedRetailMaps = """
|
||||
TargetedUsage
|
||||
[
|
||||
SelectLeft [ "" [ 1 DIMOFS_BUTTON0 ] ]
|
||||
SelectRight [ "" [ 1 DIMOFS_BUTTON1 ] ]
|
||||
]
|
||||
|
||||
SystemKeys
|
||||
[
|
||||
AltEnter [ "" [ 0 DIK_RETURN ] 0x00000004 ]
|
||||
AltTab [ "" [ 0 DIK_TAB ] 0x00000004 ]
|
||||
AltF4 [ "" [ 0 DIK_F4 ] 0x00000004 ]
|
||||
CtrlShiftEsc [ "" [ 0 DIK_ESCAPE ] 0x00000003 ]
|
||||
]
|
||||
|
||||
MouseCommands
|
||||
[
|
||||
PointerX [ "" [ 1 DIMOFS_X ] 0x00000000 Analog ]
|
||||
PointerY [ "" [ 1 DIMOFS_Y ] 0x00000000 Analog ]
|
||||
SelectLeft [ "" [ 1 DIMOFS_BUTTON0 ] ]
|
||||
SelectRight [ "" [ 1 DIMOFS_BUTTON1 ] ]
|
||||
SelectMid [ "" [ 1 DIMOFS_BUTTON2 ] ]
|
||||
SelectDblLeft [ "" [ 1 DIMOFS_BUTTON0 ] 0x00000000 MouseDblClick ]
|
||||
SelectDblRight [ "" [ 1 DIMOFS_BUTTON1 ] 0x00000000 MouseDblClick ]
|
||||
SelectDblMid [ "" [ 1 DIMOFS_BUTTON2 ] 0x00000000 MouseDblClick ]
|
||||
]
|
||||
|
||||
ScrollableControls
|
||||
[
|
||||
ScrollUp [ "" [ 1 DIMOFS_Z AxisPositive ] ]
|
||||
ScrollDown [ "" [ 1 DIMOFS_Z AxisNegative ] ]
|
||||
ScrollUp [ "" [ 0 DIK_UPARROW ] 0x00000002 ]
|
||||
ScrollDown [ "" [ 0 DIK_DOWNARROW ] 0x00000002 ]
|
||||
]
|
||||
|
||||
EditControls
|
||||
[
|
||||
CursorCharLeft [ "" [ 0 DIK_LEFT ] ]
|
||||
CursorCharRight [ "" [ 0 DIK_RIGHTARROW ] ]
|
||||
CursorPreviousLine [ "" [ 0 DIK_UPARROW ] ]
|
||||
CursorNextLine [ "" [ 0 DIK_DOWNARROW ] ]
|
||||
CursorPreviousPage [ "" [ 0 DIK_PGUP ] ]
|
||||
CursorNextPage [ "" [ 0 DIK_PGDN ] ]
|
||||
CursorWordLeft [ "" [ 0 DIK_LEFT ] 0x00000002 ]
|
||||
CursorWordRight [ "" [ 0 DIK_RIGHTARROW ] 0x00000002 ]
|
||||
CursorStartOfLine [ "" [ 0 DIK_HOME ] ]
|
||||
CursorStartOfDocument [ "" [ 0 DIK_HOME ] 0x00000002 ]
|
||||
CursorEndOfLine [ "" [ 0 DIK_END ] ]
|
||||
CursorEndOfDocument [ "" [ 0 DIK_END ] 0x00000002 ]
|
||||
EscapeKey [ "" [ 0 DIK_ESCAPE ] ]
|
||||
AcceptInput [ "" [ 0 DIK_RETURN ] ]
|
||||
DeleteKey [ "" [ 0 DIK_DELETE ] ]
|
||||
BackspaceKey [ "" [ 0 DIK_BACK ] ]
|
||||
]
|
||||
|
||||
CopyAndPasteControls
|
||||
[
|
||||
CopyText [ "" [ 0 DIK_C ] 0x00000002 ]
|
||||
CopyText [ "" [ 0 DIK_INSERT ] 0x00000002 ]
|
||||
CutText [ "" [ 0 DIK_X ] 0x00000002 ]
|
||||
CutText [ "" [ 0 DIK_DELETE ] 0x00000001 ]
|
||||
PasteText [ "" [ 0 DIK_V ] 0x00000002 ]
|
||||
PasteText [ "" [ 0 DIK_INSERT ] 0x00000001 ]
|
||||
]
|
||||
|
||||
DialogBoxes
|
||||
[
|
||||
EscapeKey [ "" [ 0 DIK_ESCAPE ] ]
|
||||
AcceptInput [ "" [ 0 DIK_RETURN ] ]
|
||||
]
|
||||
|
||||
""";
|
||||
}
|
||||
|
||||
public enum RetailKeymapSaveStatus
|
||||
{
|
||||
Saved,
|
||||
Exists,
|
||||
ReadOnly,
|
||||
InvalidName,
|
||||
Failed,
|
||||
}
|
||||
|
||||
public readonly record struct RetailKeymapSaveResult(
|
||||
RetailKeymapSaveStatus Status,
|
||||
string FileName,
|
||||
string? Error = null);
|
||||
|
||||
/// <summary>
|
||||
/// Owns retail's active-profile preference and <c>*.keymap</c> directory.
|
||||
/// The profile selector lives beside acdream's portable JSON mirror; profile
|
||||
/// files live in retail's Documents/Asheron's Call folder.
|
||||
/// </summary>
|
||||
public sealed class RetailKeymapProfileStore
|
||||
{
|
||||
public const string DefaultFileName = "acdream.keymap";
|
||||
|
||||
private readonly string _jsonPath;
|
||||
private readonly string _directory;
|
||||
private readonly string _selectorPath;
|
||||
|
||||
public RetailKeymapProfileStore(string jsonPath, string? keymapDirectory = null)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(jsonPath);
|
||||
_jsonPath = Path.GetFullPath(jsonPath);
|
||||
string configDirectory = Path.GetDirectoryName(_jsonPath)
|
||||
?? Directory.GetCurrentDirectory();
|
||||
_selectorPath = Path.Combine(configDirectory, "active-keymap.txt");
|
||||
_directory = keymapDirectory ?? Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments),
|
||||
"Asheron's Call");
|
||||
}
|
||||
|
||||
public string DirectoryPath => _directory;
|
||||
|
||||
public string CurrentFileName
|
||||
{
|
||||
get
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(_selectorPath))
|
||||
{
|
||||
string selected = NormalizeFileName(File.ReadAllText(_selectorPath));
|
||||
if (selected.Length != 0) return selected;
|
||||
}
|
||||
}
|
||||
catch (Exception failure)
|
||||
{
|
||||
Console.WriteLine($"keymap: active-profile preference could not be read: {failure.Message}");
|
||||
}
|
||||
return DefaultFileName;
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<string> ListFiles()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!Directory.Exists(_directory)) return Array.Empty<string>();
|
||||
return Directory.EnumerateFiles(_directory, "*.keymap", SearchOption.TopDirectoryOnly)
|
||||
.Select(Path.GetFileName)
|
||||
.Where(static name => !string.IsNullOrEmpty(name))
|
||||
.Cast<string>()
|
||||
.OrderBy(static name => name, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
catch (Exception failure)
|
||||
{
|
||||
Console.WriteLine($"keymap: profile list failed: {failure.Message}");
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryLoad(
|
||||
string fileName,
|
||||
KeyBindings baseBindings,
|
||||
out KeyBindings bindings,
|
||||
out string? error)
|
||||
{
|
||||
bindings = baseBindings;
|
||||
error = null;
|
||||
string normalized = NormalizeFileName(fileName);
|
||||
if (normalized.Length == 0)
|
||||
{
|
||||
error = "The keymap filename is invalid.";
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
string text = File.ReadAllText(Path.Combine(_directory, normalized));
|
||||
bindings = RetailKeymapFile.Parse(text, baseBindings);
|
||||
WriteSelector(normalized);
|
||||
return true;
|
||||
}
|
||||
catch (Exception failure)
|
||||
{
|
||||
error = failure.Message;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public RetailKeymapSaveResult Save(
|
||||
string fileName,
|
||||
KeyBindings bindings,
|
||||
bool overwrite)
|
||||
{
|
||||
string normalized = NormalizeFileName(fileName);
|
||||
if (normalized.Length == 0)
|
||||
return new(RetailKeymapSaveStatus.InvalidName, string.Empty);
|
||||
|
||||
string path = Path.Combine(_directory, normalized);
|
||||
try
|
||||
{
|
||||
if (File.Exists(path))
|
||||
{
|
||||
if (!overwrite)
|
||||
return new(RetailKeymapSaveStatus.Exists, normalized);
|
||||
if ((File.GetAttributes(path) & FileAttributes.ReadOnly) != 0)
|
||||
return new(RetailKeymapSaveStatus.ReadOnly, normalized);
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(_directory);
|
||||
AtomicWrite(path, RetailKeymapFile.Write(bindings));
|
||||
WriteSelector(normalized);
|
||||
return new(RetailKeymapSaveStatus.Saved, normalized);
|
||||
}
|
||||
catch (UnauthorizedAccessException failure)
|
||||
{
|
||||
return new(RetailKeymapSaveStatus.ReadOnly, normalized, failure.Message);
|
||||
}
|
||||
catch (Exception failure)
|
||||
{
|
||||
return new(RetailKeymapSaveStatus.Failed, normalized, failure.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public RetailKeymapSaveResult SaveActive(KeyBindings bindings) =>
|
||||
Save(CurrentFileName, bindings, overwrite: true);
|
||||
|
||||
public static KeyBindings LoadActiveOrJson(
|
||||
string jsonPath,
|
||||
out string profileName)
|
||||
{
|
||||
KeyBindings fallback = KeyBindings.LoadOrDefault(jsonPath);
|
||||
var store = new RetailKeymapProfileStore(jsonPath);
|
||||
profileName = store.CurrentFileName;
|
||||
string profilePath = Path.Combine(store.DirectoryPath, profileName);
|
||||
if (!File.Exists(profilePath)) return fallback;
|
||||
if (store.TryLoad(profileName, fallback, out KeyBindings loaded, out string? error))
|
||||
return loaded;
|
||||
Console.WriteLine($"keymap: '{profileName}' could not be loaded; using JSON/defaults: {error}");
|
||||
return fallback;
|
||||
}
|
||||
|
||||
public static string NormalizeFileName(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return string.Empty;
|
||||
string trimmed = value.Trim();
|
||||
if (!string.Equals(trimmed, Path.GetFileName(trimmed), StringComparison.Ordinal))
|
||||
return string.Empty;
|
||||
if (trimmed.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0)
|
||||
return string.Empty;
|
||||
return trimmed.EndsWith(".keymap", StringComparison.OrdinalIgnoreCase)
|
||||
? trimmed
|
||||
: trimmed + ".keymap";
|
||||
}
|
||||
|
||||
private void WriteSelector(string fileName)
|
||||
{
|
||||
string? directory = Path.GetDirectoryName(_selectorPath);
|
||||
if (!string.IsNullOrEmpty(directory)) Directory.CreateDirectory(directory);
|
||||
AtomicWrite(_selectorPath, fileName + Environment.NewLine);
|
||||
}
|
||||
|
||||
private static void AtomicWrite(string path, string content)
|
||||
{
|
||||
string temp = path + ".tmp-" + Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture);
|
||||
try
|
||||
{
|
||||
File.WriteAllText(temp, content, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
|
||||
File.Move(temp, path, overwrite: true);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(temp)) File.Delete(temp);
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue