feat(input): assess world objects on right click
Port SmartBox's release-completed sr_Examine gesture through the configurable SelectRight binding. Cancel camera drags at the shared retail three-pixel threshold, then reuse the canonical picker, selection pulse, and appraisal request path without inventing another wire or UI owner. Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
parent
6718ee45a0
commit
5ad32d5753
16 changed files with 394 additions and 13 deletions
|
|
@ -21,6 +21,12 @@ public enum ActivationType
|
|||
Hold,
|
||||
/// <summary>Mouse-button double-click within retail's chord window.</summary>
|
||||
DoubleClick,
|
||||
/// <summary>
|
||||
/// A complete pointer click: button down and up without crossing the
|
||||
/// retail-observed three-pixel drag threshold. Keyboard chords bound to a
|
||||
/// click action fire on key-down, preserving configurable action bindings.
|
||||
/// </summary>
|
||||
Click,
|
||||
/// <summary>Mouse axis or other analog input. Reserved for future
|
||||
/// rebindable mouse-look — K.1a does not emit this.</summary>
|
||||
Analog,
|
||||
|
|
|
|||
|
|
@ -39,7 +39,8 @@ public sealed class InputDispatcher : IDisposable
|
|||
private InputScope? _combatScope;
|
||||
private readonly HashSet<KeyChord> _heldHoldChords = new();
|
||||
private readonly HashSet<InputAction> _automationHeldActions = new();
|
||||
private readonly bool[] _sourceAttached = new bool[5];
|
||||
private readonly Dictionary<MouseButton, float> _mouseClickTravel = new();
|
||||
private readonly bool[] _sourceAttached = new bool[6];
|
||||
private bool _attachStarted;
|
||||
private int _disposeRequested;
|
||||
private int _active;
|
||||
|
|
@ -51,13 +52,15 @@ public sealed class InputDispatcher : IDisposable
|
|||
private MouseButton? _lastMouseDownButton;
|
||||
private long _lastMouseDownTickMs;
|
||||
private const long DoubleClickThresholdMs = 500;
|
||||
private const float ClickDragThresholdPixels = 3f;
|
||||
|
||||
/// <summary>K.3 modal-rebind hook: when non-null, the next non-modifier
|
||||
/// chord is reported via this callback INSTEAD of firing actions. Esc
|
||||
/// cancels (callback receives <c>default(KeyChord)</c>).</summary>
|
||||
private Action<KeyChord>? _captureCallback;
|
||||
|
||||
/// <summary>Fires every time a binding matches a press / release / hold tick.
|
||||
/// <summary>Fires every time a binding matches a press, release, hold,
|
||||
/// complete click, or double-click.
|
||||
/// Multicast — every subscriber gets every event in subscription order.</summary>
|
||||
public event Action<InputAction, ActivationType>? Fired;
|
||||
|
||||
|
|
@ -110,6 +113,8 @@ public sealed class InputDispatcher : IDisposable
|
|||
_sourceAttached[3] = true;
|
||||
_mouse.MouseUp += OnMouseUp;
|
||||
_sourceAttached[4] = true;
|
||||
_mouse.MouseMove += OnMouseMove;
|
||||
_sourceAttached[5] = true;
|
||||
_mouse.Scroll += OnScroll;
|
||||
Volatile.Write(ref _active, 1);
|
||||
}
|
||||
|
|
@ -140,6 +145,7 @@ public sealed class InputDispatcher : IDisposable
|
|||
_captureCallback = null;
|
||||
_heldHoldChords.Clear();
|
||||
_automationHeldActions.Clear();
|
||||
_mouseClickTravel.Clear();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
|
|
@ -479,6 +485,9 @@ public sealed class InputDispatcher : IDisposable
|
|||
var press = FindActive(chord, ActivationType.Press);
|
||||
if (press is not null) Fired?.Invoke(press.Value.Action, ActivationType.Press);
|
||||
|
||||
var click = FindActive(chord, ActivationType.Click);
|
||||
if (click is not null) Fired?.Invoke(click.Value.Action, ActivationType.Click);
|
||||
|
||||
var hold = FindActive(chord, ActivationType.Hold);
|
||||
if (hold is not null)
|
||||
{
|
||||
|
|
@ -532,6 +541,7 @@ public sealed class InputDispatcher : IDisposable
|
|||
private void OnMouseDown(MouseButton button, ModifierMask mods)
|
||||
{
|
||||
if (Volatile.Read(ref _active) == 0) return;
|
||||
_mouseClickTravel.Remove(button);
|
||||
if (_mouse.WantCaptureMouse) return;
|
||||
var chord = new KeyChord(MouseButtonToKey(button), mods, Device: 1);
|
||||
|
||||
|
|
@ -545,6 +555,9 @@ public sealed class InputDispatcher : IDisposable
|
|||
_heldHoldChords.Add(chord);
|
||||
}
|
||||
|
||||
if (FindActive(chord, ActivationType.Click) is not null)
|
||||
_mouseClickTravel[button] = 0f;
|
||||
|
||||
// Double-click recognition. Same button within DoubleClickThresholdMs
|
||||
// -> additionally fire ActivationType.DoubleClick for any matching
|
||||
// binding. Press has already fired for the second click (same as a
|
||||
|
|
@ -568,6 +581,7 @@ public sealed class InputDispatcher : IDisposable
|
|||
{
|
||||
if (Volatile.Read(ref _active) == 0) return;
|
||||
var chord = new KeyChord(MouseButtonToKey(button), mods, Device: 1);
|
||||
bool wasClickCandidate = _mouseClickTravel.Remove(button, out float travel);
|
||||
|
||||
var release = FindActive(chord, ActivationType.Release);
|
||||
if (release is not null) Fired?.Invoke(release.Value.Action, ActivationType.Release);
|
||||
|
|
@ -585,6 +599,35 @@ public sealed class InputDispatcher : IDisposable
|
|||
var hold = FindActive(held, ActivationType.Hold);
|
||||
if (hold is not null) Fired?.Invoke(hold.Value.Action, ActivationType.Release);
|
||||
}
|
||||
|
||||
if (wasClickCandidate
|
||||
&& !_mouse.WantCaptureMouse
|
||||
&& travel <= ClickDragThresholdPixels
|
||||
&& FindActive(chord, ActivationType.Click) is { } click)
|
||||
{
|
||||
Fired?.Invoke(click.Action, ActivationType.Click);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnMouseMove(float dx, float dy)
|
||||
{
|
||||
if (Volatile.Read(ref _active) == 0 || _mouseClickTravel.Count == 0)
|
||||
return;
|
||||
|
||||
if (_mouse.WantCaptureMouse)
|
||||
{
|
||||
_mouseClickTravel.Clear();
|
||||
return;
|
||||
}
|
||||
|
||||
float distance = MathF.Sqrt((dx * dx) + (dy * dy));
|
||||
foreach (MouseButton button in _mouseClickTravel.Keys.ToArray())
|
||||
{
|
||||
if (_mouse.IsHeld(button))
|
||||
_mouseClickTravel[button] += distance;
|
||||
else
|
||||
_mouseClickTravel.Remove(button);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnScroll(float delta)
|
||||
|
|
@ -632,7 +675,8 @@ public sealed class InputDispatcher : IDisposable
|
|||
case 1: _keyboard.KeyUp -= OnKeyUp; break;
|
||||
case 2: _mouse.MouseDown -= OnMouseDown; break;
|
||||
case 3: _mouse.MouseUp -= OnMouseUp; break;
|
||||
case 4: _mouse.Scroll -= OnScroll; break;
|
||||
case 4: _mouse.MouseMove -= OnMouseMove; break;
|
||||
case 5: _mouse.Scroll -= OnScroll; break;
|
||||
default: throw new ArgumentOutOfRangeException(nameof(index));
|
||||
}
|
||||
_sourceAttached[index] = false;
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ namespace AcDream.UI.Abstractions.Input;
|
|||
/// </summary>
|
||||
public sealed class KeyBindings
|
||||
{
|
||||
private const int CurrentSchemaVersion = 4;
|
||||
private const int CurrentSchemaVersion = 5;
|
||||
|
||||
private readonly List<Binding> _bindings = new();
|
||||
|
||||
|
|
@ -324,7 +324,8 @@ public sealed class KeyBindings
|
|||
InputAction.SelectLeft));
|
||||
b.Add(new(
|
||||
new KeyChord(InputDispatcher.MouseButtonToKey(MouseButton.Right), ModifierMask.None, Device: 1),
|
||||
InputAction.SelectRight));
|
||||
InputAction.SelectRight,
|
||||
ActivationType.Click));
|
||||
b.Add(new(
|
||||
new KeyChord(InputDispatcher.MouseButtonToKey(MouseButton.Middle), ModifierMask.None, Device: 1),
|
||||
InputAction.SelectMid));
|
||||
|
|
@ -367,9 +368,9 @@ public sealed class KeyBindings
|
|||
InputAction.AcdreamToggleFlyMode));
|
||||
|
||||
// K-fix1 (2026-04-26): RMB-hold camera orbit. Coexists with the
|
||||
// SelectRight Press binding above — Press fires on click,
|
||||
// AcdreamRmbOrbitHold fires on hold/release transitions so the
|
||||
// chase camera can free-orbit while the user drags the mouse.
|
||||
// SelectRight Click binding above resolves only after a stationary
|
||||
// release. AcdreamRmbOrbitHold fires on hold/release transitions so
|
||||
// the chase camera can free-orbit while the user drags the mouse.
|
||||
// Without this, RMB-orbit silently broke when K.1c flipped the
|
||||
// default keymap from AcdreamCurrentDefaults to RetailDefaults.
|
||||
b.Add(new(
|
||||
|
|
@ -435,6 +436,7 @@ public sealed class KeyBindings
|
|||
var chord = new KeyChord(silkKey, mods, device);
|
||||
action = MigrateLegacyQuickSlotIntent(version, action, chord, activation);
|
||||
activation = MigrateCombatAttackActivation(version, action, activation);
|
||||
activation = MigrateSelectRightActivation(version, action, activation);
|
||||
InputScope scope = defaults.ForAction(action)
|
||||
.Select(binding => binding.Scope)
|
||||
.DefaultIfEmpty(InputScope.Game)
|
||||
|
|
@ -562,6 +564,21 @@ public sealed class KeyBindings
|
|||
: activation;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Schema v4 fired SelectRight on mouse-down. Retail's SmartBox resolves
|
||||
/// right-click examination on mouse-up only when the pointer did not turn
|
||||
/// into a drag, so preserve the user's chord while upgrading its gesture.
|
||||
/// </summary>
|
||||
private static ActivationType MigrateSelectRightActivation(
|
||||
int version,
|
||||
InputAction action,
|
||||
ActivationType activation)
|
||||
=> version < 5
|
||||
&& action == InputAction.SelectRight
|
||||
&& activation == ActivationType.Press
|
||||
? ActivationType.Click
|
||||
: activation;
|
||||
|
||||
private static ModifierMask ParseModifiers(JsonElement bindingEl)
|
||||
{
|
||||
if (!bindingEl.TryGetProperty("mod", out var modEl)) return ModifierMask.None;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue