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:
Erik 2026-07-24 05:42:28 +02:00
parent 6718ee45a0
commit 5ad32d5753
16 changed files with 394 additions and 13 deletions

View file

@ -262,7 +262,8 @@ internal sealed class GameplayInputActionRouter : IDisposable
return;
if (activation is not ActivationType.Press
and not ActivationType.DoubleClick)
and not ActivationType.DoubleClick
and not ActivationType.Click)
{
return;
}

View file

@ -73,9 +73,16 @@ internal sealed class SelectionInteractionController
case InputAction.SelectLeft:
PickAndStoreSelection(useImmediately: false);
return true;
case InputAction.SelectRight:
PickSelectAndExamine();
return true;
case InputAction.SelectDblLeft:
PickAndStoreSelection(useImmediately: true);
return true;
case InputAction.SelectionExamine:
_items.ExamineSelectedOrEnterMode(
_selection.SelectedObjectId ?? 0u);
return true;
case InputAction.UseSelected:
UseCurrentSelection();
return true;
@ -181,6 +188,25 @@ internal sealed class SelectionInteractionController
});
}
/// <summary>
/// Retail SmartBox right-click path:
/// <c>UIElement_SmartBoxWrapper::MouseUp @ 0x004E5820</c> chooses
/// <c>sr_Examine</c>, then
/// <c>RecvNotice_SmartBoxObjectFound @ 0x004E5AD0</c> pulses, selects,
/// and calls <c>ClientUISystem::ExamineObject</c>. Empty space is a no-op,
/// and this path does not consume a left-click target mode.
/// </summary>
public void PickSelectAndExamine()
{
uint? picked = _query.PickAtCursor(includeSelf: false);
if (picked is not uint guid)
return;
_query.BeginLightingPulse(guid);
_selection.Select(guid, SelectionChangeSource.World);
_items.ExamineSelectedOrEnterMode(guid);
}
public void UseCurrentSelection()
{
if (_selection.SelectedObjectId is not uint selected)

View file

@ -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,

View file

@ -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;

View file

@ -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;