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
|
|
@ -142,6 +142,12 @@ DAT resolver prefixes it exactly once. Empty append fragments now preserve
|
||||||
retail's intentional blank section rows, including the blank after
|
retail's intentional blank section rows, including the blank after
|
||||||
Workmanship and the literal leading newline before Armor Level.
|
Workmanship and the literal leading newline before Armor Level.
|
||||||
|
|
||||||
|
The adjacent world-input follow-up also completes retail right-click
|
||||||
|
assessment. `SelectRight` is now a release-completed configurable click with
|
||||||
|
the shared three-pixel drag threshold, then pulses, selects, and appraises the
|
||||||
|
picked world object through the same owners as the magnifying glass and E key.
|
||||||
|
RMB camera drags and empty-world releases do not issue appraisal requests.
|
||||||
|
|
||||||
**Files:** `src/AcDream.Core.Net/Messages/AppraiseInfoParser.cs`;
|
**Files:** `src/AcDream.Core.Net/Messages/AppraiseInfoParser.cs`;
|
||||||
`src/AcDream.App/UI/RetailUiRuntime.cs`;
|
`src/AcDream.App/UI/RetailUiRuntime.cs`;
|
||||||
`src/AcDream.App/UI/Layout/AppraisalUiController.cs`;
|
`src/AcDream.App/UI/Layout/AppraisalUiController.cs`;
|
||||||
|
|
@ -162,6 +168,8 @@ animated target remains above row chrome but below text; changing selection
|
||||||
updates the open window without another magnifier click. Item text begins at
|
updates the open window without another magnifier click. Item text begins at
|
||||||
the top and assessed melee/missile/armor/magic items show their retail-ordered
|
the top and assessed melee/missile/armor/magic items show their retail-ordered
|
||||||
stats and full DAT spell descriptions.
|
stats and full DAT spell descriptions.
|
||||||
|
Right-clicking a visible world object opens the same examination window;
|
||||||
|
right-dragging the camera does not.
|
||||||
The Black Phyntos Hive specifically reads `Value: ???`, `Burden: Unknown`,
|
The Black Phyntos Hive specifically reads `Value: ???`, `Burden: Unknown`,
|
||||||
then its description after one retail paragraph break, without the bogus
|
then its description after one retail paragraph break, without the bogus
|
||||||
255-item/255-container line.
|
255-item/255-container line.
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,10 @@ ports the optional public shared-cooldown metadata, the authoritative
|
||||||
cooldown-enchantment lookup, the exact ten-step bucket formula, and DAT
|
cooldown-enchantment lookup, the exact ten-step bucket formula, and DAT
|
||||||
sprites `0x060067CF..0x060067D8` through the shared retained `UiItemSlot`
|
sprites `0x060067CF..0x060067D8` through the shared retained `UiItemSlot`
|
||||||
presentation; its connected gate passed. The existing magnifying-glass Assess
|
presentation; its connected gate passed. The existing magnifying-glass Assess
|
||||||
command was already retail-faithful. Slice 3's first connected gate exposed
|
command was already retail-faithful. World right-click assessment now follows
|
||||||
|
the configurable `SelectRight` action's retail release-completed gesture,
|
||||||
|
including drag cancellation, then reuses the canonical
|
||||||
|
picker/selection/appraisal path. Slice 3's first connected gate exposed
|
||||||
three deeper defects, now corrected: the response enum mislabeled ACE's
|
three deeper defects, now corrected: the response enum mislabeled ACE's
|
||||||
creature bit `0x0100` and dropped monster packets while retaining the busy
|
creature bit `0x0100` and dropped monster packets while retaining the busy
|
||||||
cursor; the imported 310 x 400 examination layout was incorrectly hosted as
|
cursor; the imported 310 x 400 examination layout was incorrectly hosted as
|
||||||
|
|
|
||||||
|
|
@ -98,6 +98,9 @@ adjacent cooldown follow-up now preserves the item's shared group/duration,
|
||||||
projects the authoritative cooldown-enchantment bucket, and draws retail's ten
|
projects the authoritative cooldown-enchantment bucket, and draws retail's ten
|
||||||
DAT-authored radial steps across all item-slot surfaces; its connected gate
|
DAT-authored radial steps across all item-slot surfaces; its connected gate
|
||||||
passed. The pre-existing Assess command has been reconciled against retail.
|
passed. The pre-existing Assess command has been reconciled against retail.
|
||||||
|
The same slice now includes retail world right-click assessment through the
|
||||||
|
configurable `SelectRight` complete-click gesture; RMB orbit drags cancel
|
||||||
|
instead of appraising the release point.
|
||||||
Slice 3's first connected gate found and corrected the real remaining defects:
|
Slice 3's first connected gate found and corrected the real remaining defects:
|
||||||
ACE creature flag `0x0100` was mislabeled as a weapon profile and left the
|
ACE creature flag `0x0100` was mislabeled as a weapon profile and left the
|
||||||
appraisal busy reference held, retail's examination UI had been mounted as a
|
appraisal busy reference held, retail's examination UI had been mounted as a
|
||||||
|
|
|
||||||
|
|
@ -226,6 +226,17 @@ title, and boots-layout fixtures protect the full path. The Release solution
|
||||||
build and 3,533 App tests / 3 skips plus 7,956 complete-solution tests /
|
build and 3,533 App tests / 3 skips plus 7,956 complete-solution tests /
|
||||||
5 skips pass; the connected boots comparison is the remaining visual gate.
|
5 skips pass; the connected boots comparison is the remaining visual gate.
|
||||||
|
|
||||||
|
The world right-click follow-up ports
|
||||||
|
`UIElement_SmartBoxWrapper::MouseUp @ 0x004E5820` and the
|
||||||
|
`sr_Examine` branch of `RecvNotice_SmartBoxObjectFound @ 0x004E5AD0`.
|
||||||
|
The configurable `SelectRight` binding now completes on release, cancels when
|
||||||
|
pointer travel crosses the retail-observed three-pixel drag threshold, and
|
||||||
|
routes through the existing world picker, lighting pulse, canonical
|
||||||
|
`SelectionState`, and appraisal request owner. Empty world space remains a
|
||||||
|
no-op, right-drag camera orbit does not appraise its release point, and the
|
||||||
|
independent configurable `SelectionExamine` action now reaches the same
|
||||||
|
request/target-mode path.
|
||||||
|
|
||||||
## Slice 1 — spell-bar overflow arrows
|
## Slice 1 — spell-bar overflow arrows
|
||||||
|
|
||||||
### Retail oracle
|
### Retail oracle
|
||||||
|
|
|
||||||
|
|
@ -190,6 +190,37 @@ procedural arrow. The indicator is persistent selection presentation. Retail
|
||||||
also has a separate, short-lived click confirmation owned by SmartBox; it is not
|
also has a separate, short-lived click confirmation owned by SmartBox; it is not
|
||||||
implemented in `CPhysicsPart::Draw` or `ACCWeenieObject::SetSelected`.
|
implemented in `CPhysicsPart::Draw` or `ACCWeenieObject::SetSelected`.
|
||||||
|
|
||||||
|
## Right-click selection and examination
|
||||||
|
|
||||||
|
`UIElement_SmartBoxWrapper::MouseUp @ 0x004E5820`
|
||||||
|
|
||||||
|
```text
|
||||||
|
on right mouse up:
|
||||||
|
if mouse movement owns the gesture:
|
||||||
|
finish mouse movement and do not examine
|
||||||
|
else if instant mouse-look owns the gesture:
|
||||||
|
leave mouse-look and do not examine
|
||||||
|
else if no higher-priority SmartBox search is pending:
|
||||||
|
searchReason = sr_Examine
|
||||||
|
FindObject(releaseX, releaseY)
|
||||||
|
```
|
||||||
|
|
||||||
|
`UIElement_SmartBoxWrapper::RecvNotice_SmartBoxObjectFound @ 0x004E5AD0`
|
||||||
|
|
||||||
|
```text
|
||||||
|
if a valid object was found for sr_Examine:
|
||||||
|
begin the normal SmartBox lighting pulse
|
||||||
|
SetSelectedObject(foundObjectId)
|
||||||
|
ClientUISystem.ExamineObject(foundObjectId)
|
||||||
|
else:
|
||||||
|
do nothing
|
||||||
|
```
|
||||||
|
|
||||||
|
The action is release-completed rather than mouse-down-completed so an RMB
|
||||||
|
camera drag does not appraise the object under the release point. Acdream maps
|
||||||
|
the configurable `SelectRight` action to the same complete-click gesture and
|
||||||
|
keeps `SelectionExamine` independently configurable (retail default E).
|
||||||
|
|
||||||
## SmartBox click lighting pulse
|
## SmartBox click lighting pulse
|
||||||
|
|
||||||
`UIElement_SmartBoxWrapper::RecvNotice_SmartBoxObjectFound @ 0x004E5AD0`
|
`UIElement_SmartBoxWrapper::RecvNotice_SmartBoxObjectFound @ 0x004E5AD0`
|
||||||
|
|
|
||||||
|
|
@ -262,7 +262,8 @@ internal sealed class GameplayInputActionRouter : IDisposable
|
||||||
return;
|
return;
|
||||||
|
|
||||||
if (activation is not ActivationType.Press
|
if (activation is not ActivationType.Press
|
||||||
and not ActivationType.DoubleClick)
|
and not ActivationType.DoubleClick
|
||||||
|
and not ActivationType.Click)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -73,9 +73,16 @@ internal sealed class SelectionInteractionController
|
||||||
case InputAction.SelectLeft:
|
case InputAction.SelectLeft:
|
||||||
PickAndStoreSelection(useImmediately: false);
|
PickAndStoreSelection(useImmediately: false);
|
||||||
return true;
|
return true;
|
||||||
|
case InputAction.SelectRight:
|
||||||
|
PickSelectAndExamine();
|
||||||
|
return true;
|
||||||
case InputAction.SelectDblLeft:
|
case InputAction.SelectDblLeft:
|
||||||
PickAndStoreSelection(useImmediately: true);
|
PickAndStoreSelection(useImmediately: true);
|
||||||
return true;
|
return true;
|
||||||
|
case InputAction.SelectionExamine:
|
||||||
|
_items.ExamineSelectedOrEnterMode(
|
||||||
|
_selection.SelectedObjectId ?? 0u);
|
||||||
|
return true;
|
||||||
case InputAction.UseSelected:
|
case InputAction.UseSelected:
|
||||||
UseCurrentSelection();
|
UseCurrentSelection();
|
||||||
return true;
|
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()
|
public void UseCurrentSelection()
|
||||||
{
|
{
|
||||||
if (_selection.SelectedObjectId is not uint selected)
|
if (_selection.SelectedObjectId is not uint selected)
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,12 @@ public enum ActivationType
|
||||||
Hold,
|
Hold,
|
||||||
/// <summary>Mouse-button double-click within retail's chord window.</summary>
|
/// <summary>Mouse-button double-click within retail's chord window.</summary>
|
||||||
DoubleClick,
|
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
|
/// <summary>Mouse axis or other analog input. Reserved for future
|
||||||
/// rebindable mouse-look — K.1a does not emit this.</summary>
|
/// rebindable mouse-look — K.1a does not emit this.</summary>
|
||||||
Analog,
|
Analog,
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,8 @@ public sealed class InputDispatcher : IDisposable
|
||||||
private InputScope? _combatScope;
|
private InputScope? _combatScope;
|
||||||
private readonly HashSet<KeyChord> _heldHoldChords = new();
|
private readonly HashSet<KeyChord> _heldHoldChords = new();
|
||||||
private readonly HashSet<InputAction> _automationHeldActions = 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 bool _attachStarted;
|
||||||
private int _disposeRequested;
|
private int _disposeRequested;
|
||||||
private int _active;
|
private int _active;
|
||||||
|
|
@ -51,13 +52,15 @@ public sealed class InputDispatcher : IDisposable
|
||||||
private MouseButton? _lastMouseDownButton;
|
private MouseButton? _lastMouseDownButton;
|
||||||
private long _lastMouseDownTickMs;
|
private long _lastMouseDownTickMs;
|
||||||
private const long DoubleClickThresholdMs = 500;
|
private const long DoubleClickThresholdMs = 500;
|
||||||
|
private const float ClickDragThresholdPixels = 3f;
|
||||||
|
|
||||||
/// <summary>K.3 modal-rebind hook: when non-null, the next non-modifier
|
/// <summary>K.3 modal-rebind hook: when non-null, the next non-modifier
|
||||||
/// chord is reported via this callback INSTEAD of firing actions. Esc
|
/// chord is reported via this callback INSTEAD of firing actions. Esc
|
||||||
/// cancels (callback receives <c>default(KeyChord)</c>).</summary>
|
/// cancels (callback receives <c>default(KeyChord)</c>).</summary>
|
||||||
private Action<KeyChord>? _captureCallback;
|
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>
|
/// Multicast — every subscriber gets every event in subscription order.</summary>
|
||||||
public event Action<InputAction, ActivationType>? Fired;
|
public event Action<InputAction, ActivationType>? Fired;
|
||||||
|
|
||||||
|
|
@ -110,6 +113,8 @@ public sealed class InputDispatcher : IDisposable
|
||||||
_sourceAttached[3] = true;
|
_sourceAttached[3] = true;
|
||||||
_mouse.MouseUp += OnMouseUp;
|
_mouse.MouseUp += OnMouseUp;
|
||||||
_sourceAttached[4] = true;
|
_sourceAttached[4] = true;
|
||||||
|
_mouse.MouseMove += OnMouseMove;
|
||||||
|
_sourceAttached[5] = true;
|
||||||
_mouse.Scroll += OnScroll;
|
_mouse.Scroll += OnScroll;
|
||||||
Volatile.Write(ref _active, 1);
|
Volatile.Write(ref _active, 1);
|
||||||
}
|
}
|
||||||
|
|
@ -140,6 +145,7 @@ public sealed class InputDispatcher : IDisposable
|
||||||
_captureCallback = null;
|
_captureCallback = null;
|
||||||
_heldHoldChords.Clear();
|
_heldHoldChords.Clear();
|
||||||
_automationHeldActions.Clear();
|
_automationHeldActions.Clear();
|
||||||
|
_mouseClickTravel.Clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
|
|
@ -479,6 +485,9 @@ public sealed class InputDispatcher : IDisposable
|
||||||
var press = FindActive(chord, ActivationType.Press);
|
var press = FindActive(chord, ActivationType.Press);
|
||||||
if (press is not null) Fired?.Invoke(press.Value.Action, 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);
|
var hold = FindActive(chord, ActivationType.Hold);
|
||||||
if (hold is not null)
|
if (hold is not null)
|
||||||
{
|
{
|
||||||
|
|
@ -532,6 +541,7 @@ public sealed class InputDispatcher : IDisposable
|
||||||
private void OnMouseDown(MouseButton button, ModifierMask mods)
|
private void OnMouseDown(MouseButton button, ModifierMask mods)
|
||||||
{
|
{
|
||||||
if (Volatile.Read(ref _active) == 0) return;
|
if (Volatile.Read(ref _active) == 0) return;
|
||||||
|
_mouseClickTravel.Remove(button);
|
||||||
if (_mouse.WantCaptureMouse) return;
|
if (_mouse.WantCaptureMouse) return;
|
||||||
var chord = new KeyChord(MouseButtonToKey(button), mods, Device: 1);
|
var chord = new KeyChord(MouseButtonToKey(button), mods, Device: 1);
|
||||||
|
|
||||||
|
|
@ -545,6 +555,9 @@ public sealed class InputDispatcher : IDisposable
|
||||||
_heldHoldChords.Add(chord);
|
_heldHoldChords.Add(chord);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (FindActive(chord, ActivationType.Click) is not null)
|
||||||
|
_mouseClickTravel[button] = 0f;
|
||||||
|
|
||||||
// Double-click recognition. Same button within DoubleClickThresholdMs
|
// Double-click recognition. Same button within DoubleClickThresholdMs
|
||||||
// -> additionally fire ActivationType.DoubleClick for any matching
|
// -> additionally fire ActivationType.DoubleClick for any matching
|
||||||
// binding. Press has already fired for the second click (same as a
|
// 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;
|
if (Volatile.Read(ref _active) == 0) return;
|
||||||
var chord = new KeyChord(MouseButtonToKey(button), mods, Device: 1);
|
var chord = new KeyChord(MouseButtonToKey(button), mods, Device: 1);
|
||||||
|
bool wasClickCandidate = _mouseClickTravel.Remove(button, out float travel);
|
||||||
|
|
||||||
var release = FindActive(chord, ActivationType.Release);
|
var release = FindActive(chord, ActivationType.Release);
|
||||||
if (release is not null) Fired?.Invoke(release.Value.Action, 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);
|
var hold = FindActive(held, ActivationType.Hold);
|
||||||
if (hold is not null) Fired?.Invoke(hold.Value.Action, ActivationType.Release);
|
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)
|
private void OnScroll(float delta)
|
||||||
|
|
@ -632,7 +675,8 @@ public sealed class InputDispatcher : IDisposable
|
||||||
case 1: _keyboard.KeyUp -= OnKeyUp; break;
|
case 1: _keyboard.KeyUp -= OnKeyUp; break;
|
||||||
case 2: _mouse.MouseDown -= OnMouseDown; break;
|
case 2: _mouse.MouseDown -= OnMouseDown; break;
|
||||||
case 3: _mouse.MouseUp -= OnMouseUp; 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));
|
default: throw new ArgumentOutOfRangeException(nameof(index));
|
||||||
}
|
}
|
||||||
_sourceAttached[index] = false;
|
_sourceAttached[index] = false;
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ namespace AcDream.UI.Abstractions.Input;
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class KeyBindings
|
public sealed class KeyBindings
|
||||||
{
|
{
|
||||||
private const int CurrentSchemaVersion = 4;
|
private const int CurrentSchemaVersion = 5;
|
||||||
|
|
||||||
private readonly List<Binding> _bindings = new();
|
private readonly List<Binding> _bindings = new();
|
||||||
|
|
||||||
|
|
@ -324,7 +324,8 @@ public sealed class KeyBindings
|
||||||
InputAction.SelectLeft));
|
InputAction.SelectLeft));
|
||||||
b.Add(new(
|
b.Add(new(
|
||||||
new KeyChord(InputDispatcher.MouseButtonToKey(MouseButton.Right), ModifierMask.None, Device: 1),
|
new KeyChord(InputDispatcher.MouseButtonToKey(MouseButton.Right), ModifierMask.None, Device: 1),
|
||||||
InputAction.SelectRight));
|
InputAction.SelectRight,
|
||||||
|
ActivationType.Click));
|
||||||
b.Add(new(
|
b.Add(new(
|
||||||
new KeyChord(InputDispatcher.MouseButtonToKey(MouseButton.Middle), ModifierMask.None, Device: 1),
|
new KeyChord(InputDispatcher.MouseButtonToKey(MouseButton.Middle), ModifierMask.None, Device: 1),
|
||||||
InputAction.SelectMid));
|
InputAction.SelectMid));
|
||||||
|
|
@ -367,9 +368,9 @@ public sealed class KeyBindings
|
||||||
InputAction.AcdreamToggleFlyMode));
|
InputAction.AcdreamToggleFlyMode));
|
||||||
|
|
||||||
// K-fix1 (2026-04-26): RMB-hold camera orbit. Coexists with the
|
// K-fix1 (2026-04-26): RMB-hold camera orbit. Coexists with the
|
||||||
// SelectRight Press binding above — Press fires on click,
|
// SelectRight Click binding above resolves only after a stationary
|
||||||
// AcdreamRmbOrbitHold fires on hold/release transitions so the
|
// release. AcdreamRmbOrbitHold fires on hold/release transitions so
|
||||||
// chase camera can free-orbit while the user drags the mouse.
|
// the chase camera can free-orbit while the user drags the mouse.
|
||||||
// Without this, RMB-orbit silently broke when K.1c flipped the
|
// Without this, RMB-orbit silently broke when K.1c flipped the
|
||||||
// default keymap from AcdreamCurrentDefaults to RetailDefaults.
|
// default keymap from AcdreamCurrentDefaults to RetailDefaults.
|
||||||
b.Add(new(
|
b.Add(new(
|
||||||
|
|
@ -435,6 +436,7 @@ public sealed class KeyBindings
|
||||||
var chord = new KeyChord(silkKey, mods, device);
|
var chord = new KeyChord(silkKey, mods, device);
|
||||||
action = MigrateLegacyQuickSlotIntent(version, action, chord, activation);
|
action = MigrateLegacyQuickSlotIntent(version, action, chord, activation);
|
||||||
activation = MigrateCombatAttackActivation(version, action, activation);
|
activation = MigrateCombatAttackActivation(version, action, activation);
|
||||||
|
activation = MigrateSelectRightActivation(version, action, activation);
|
||||||
InputScope scope = defaults.ForAction(action)
|
InputScope scope = defaults.ForAction(action)
|
||||||
.Select(binding => binding.Scope)
|
.Select(binding => binding.Scope)
|
||||||
.DefaultIfEmpty(InputScope.Game)
|
.DefaultIfEmpty(InputScope.Game)
|
||||||
|
|
@ -562,6 +564,21 @@ public sealed class KeyBindings
|
||||||
: activation;
|
: 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)
|
private static ModifierMask ParseModifiers(JsonElement bindingEl)
|
||||||
{
|
{
|
||||||
if (!bindingEl.TryGetProperty("mod", out var modEl)) return ModifierMask.None;
|
if (!bindingEl.TryGetProperty("mod", out var modEl)) return ModifierMask.None;
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,21 @@ public sealed class GameplayInputActionRouterTests
|
||||||
harness.Targets.Calls);
|
harness.Targets.Calls);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Click_PassesGateToSelection()
|
||||||
|
{
|
||||||
|
var harness = new Harness("selection");
|
||||||
|
harness.Router.Attach();
|
||||||
|
|
||||||
|
harness.Actions.Raise(
|
||||||
|
InputAction.SelectRight,
|
||||||
|
ActivationType.Click);
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
["pointer", "combat", "retained", "selection"],
|
||||||
|
harness.Targets.Calls);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Attach_SeedsAndTracksCombatScopes()
|
public void Attach_SeedsAndTracksCombatScopes()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -223,6 +223,65 @@ public sealed class SelectionInteractionControllerTests
|
||||||
Assert.Null(h.Selection.SelectedObjectId);
|
Assert.Null(h.Selection.SelectedObjectId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RightClickPulsesSelectsAndExaminesPickedWorldObject()
|
||||||
|
{
|
||||||
|
var h = new Harness();
|
||||||
|
h.Query.Picked = Target;
|
||||||
|
|
||||||
|
Assert.True(h.Controller.HandleInputAction(InputAction.SelectRight));
|
||||||
|
|
||||||
|
Assert.False(h.Query.LastIncludeSelf);
|
||||||
|
Assert.Equal(Target, h.Selection.SelectedObjectId);
|
||||||
|
Assert.Equal(new[] { "pick", "pulse", "examine" }, h.Query.Events);
|
||||||
|
Assert.Equal(new[] { Target }, h.Examines);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RightClickEmptyWorldIsANoOp()
|
||||||
|
{
|
||||||
|
var h = new Harness();
|
||||||
|
|
||||||
|
Assert.True(h.Controller.HandleInputAction(InputAction.SelectRight));
|
||||||
|
|
||||||
|
Assert.Equal(new[] { "pick" }, h.Query.Events);
|
||||||
|
Assert.Null(h.Selection.SelectedObjectId);
|
||||||
|
Assert.Empty(h.Examines);
|
||||||
|
Assert.Empty(h.Toasts);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RightClickExaminesWithoutConsumingLeftClickTargetMode()
|
||||||
|
{
|
||||||
|
var h = new Harness();
|
||||||
|
h.Query.Picked = Target;
|
||||||
|
h.Items.InteractionState.EnterUse();
|
||||||
|
|
||||||
|
h.Controller.HandleInputAction(InputAction.SelectRight);
|
||||||
|
|
||||||
|
Assert.Equal(InteractionModeKind.Use, h.Items.InteractionState.Current.Kind);
|
||||||
|
Assert.Equal(Target, h.Selection.SelectedObjectId);
|
||||||
|
Assert.Equal(new[] { Target }, h.Examines);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ExamineActionUsesSelectionOrEntersTargetMode()
|
||||||
|
{
|
||||||
|
var selected = new Harness();
|
||||||
|
selected.Selection.Select(Target, SelectionChangeSource.World);
|
||||||
|
|
||||||
|
Assert.True(selected.Controller.HandleInputAction(
|
||||||
|
InputAction.SelectionExamine));
|
||||||
|
Assert.Equal(new[] { Target }, selected.Examines);
|
||||||
|
|
||||||
|
var empty = new Harness();
|
||||||
|
Assert.True(empty.Controller.HandleInputAction(
|
||||||
|
InputAction.SelectionExamine));
|
||||||
|
Assert.Equal(
|
||||||
|
InteractionModeKind.Examine,
|
||||||
|
empty.Items.InteractionState.Current.Kind);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ClosestTargetInputMutatesSelectionOnce()
|
public void ClosestTargetInputMutatesSelectionOnce()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,109 @@
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using AcDream.UI.Abstractions.Input;
|
||||||
|
using Silk.NET.Input;
|
||||||
|
|
||||||
|
namespace AcDream.UI.Abstractions.Tests.Input;
|
||||||
|
|
||||||
|
public sealed class InputDispatcherClickTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void MouseClickFiresOnReleaseAfterStationaryPress()
|
||||||
|
{
|
||||||
|
var (dispatcher, mouse, fired) = BuildMouse();
|
||||||
|
|
||||||
|
mouse.EmitMouseDown(MouseButton.Right, ModifierMask.None);
|
||||||
|
Assert.Empty(fired);
|
||||||
|
|
||||||
|
mouse.EmitMouseUp(MouseButton.Right, ModifierMask.None);
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
[(InputAction.SelectRight, ActivationType.Click)],
|
||||||
|
fired);
|
||||||
|
dispatcher.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MouseClickDoesNotFireAfterCrossingRetailDragThreshold()
|
||||||
|
{
|
||||||
|
var (dispatcher, mouse, fired) = BuildMouse();
|
||||||
|
|
||||||
|
mouse.EmitMouseDown(MouseButton.Right, ModifierMask.None);
|
||||||
|
mouse.EmitMouseMove(4f, 0f);
|
||||||
|
mouse.EmitMouseUp(MouseButton.Right, ModifierMask.None);
|
||||||
|
|
||||||
|
Assert.Empty(fired);
|
||||||
|
dispatcher.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MouseClickAllowsMovementAtRetailDragThreshold()
|
||||||
|
{
|
||||||
|
var (dispatcher, mouse, fired) = BuildMouse();
|
||||||
|
|
||||||
|
mouse.EmitMouseDown(MouseButton.Right, ModifierMask.None);
|
||||||
|
mouse.EmitMouseMove(3f, 0f);
|
||||||
|
mouse.EmitMouseUp(MouseButton.Right, ModifierMask.None);
|
||||||
|
|
||||||
|
Assert.Single(fired);
|
||||||
|
dispatcher.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CapturedMouseNeverStartsWorldClick()
|
||||||
|
{
|
||||||
|
var (dispatcher, mouse, fired) = BuildMouse();
|
||||||
|
mouse.WantCaptureMouse = true;
|
||||||
|
|
||||||
|
mouse.EmitMouseDown(MouseButton.Right, ModifierMask.None);
|
||||||
|
mouse.WantCaptureMouse = false;
|
||||||
|
mouse.EmitMouseUp(MouseButton.Right, ModifierMask.None);
|
||||||
|
|
||||||
|
Assert.Empty(fired);
|
||||||
|
dispatcher.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void KeyboardRebindOfClickActionFiresOnKeyDown()
|
||||||
|
{
|
||||||
|
var keyboard = new FakeKeyboardSource();
|
||||||
|
var mouse = new FakeMouseSource();
|
||||||
|
var bindings = new KeyBindings();
|
||||||
|
bindings.Add(new Binding(
|
||||||
|
new KeyChord(Key.Q, ModifierMask.None),
|
||||||
|
InputAction.SelectRight,
|
||||||
|
ActivationType.Click));
|
||||||
|
var dispatcher = InputDispatcher.CreateDetached(keyboard, mouse, bindings);
|
||||||
|
var fired = new List<(InputAction, ActivationType)>();
|
||||||
|
dispatcher.Fired += (action, activation) => fired.Add((action, activation));
|
||||||
|
dispatcher.Attach();
|
||||||
|
|
||||||
|
keyboard.EmitKeyDown(Key.Q, ModifierMask.None);
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
[(InputAction.SelectRight, ActivationType.Click)],
|
||||||
|
fired);
|
||||||
|
dispatcher.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (
|
||||||
|
InputDispatcher Dispatcher,
|
||||||
|
FakeMouseSource Mouse,
|
||||||
|
List<(InputAction, ActivationType)> Fired) BuildMouse()
|
||||||
|
{
|
||||||
|
var keyboard = new FakeKeyboardSource();
|
||||||
|
var mouse = new FakeMouseSource();
|
||||||
|
var bindings = new KeyBindings();
|
||||||
|
bindings.Add(new Binding(
|
||||||
|
new KeyChord(
|
||||||
|
InputDispatcher.MouseButtonToKey(MouseButton.Right),
|
||||||
|
ModifierMask.None,
|
||||||
|
Device: 1),
|
||||||
|
InputAction.SelectRight,
|
||||||
|
ActivationType.Click));
|
||||||
|
var dispatcher = InputDispatcher.CreateDetached(keyboard, mouse, bindings);
|
||||||
|
var fired = new List<(InputAction, ActivationType)>();
|
||||||
|
dispatcher.Fired += (action, activation) => fired.Add((action, activation));
|
||||||
|
dispatcher.Attach();
|
||||||
|
return (dispatcher, mouse, fired);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -11,6 +11,7 @@ public sealed class InputDispatcherLifetimeTests
|
||||||
[InlineData(3)]
|
[InlineData(3)]
|
||||||
[InlineData(4)]
|
[InlineData(4)]
|
||||||
[InlineData(5)]
|
[InlineData(5)]
|
||||||
|
[InlineData(6)]
|
||||||
public void AttachFailureRollsBackEveryPossiblyAcquiredPrefix(int failAdd)
|
public void AttachFailureRollsBackEveryPossiblyAcquiredPrefix(int failAdd)
|
||||||
{
|
{
|
||||||
var source = new ThrowingSources { FailAdd = failAdd };
|
var source = new ThrowingSources { FailAdd = failAdd };
|
||||||
|
|
@ -112,6 +113,7 @@ public sealed class InputDispatcherLifetimeTests
|
||||||
private Action<Key, ModifierMask>? _keyUp;
|
private Action<Key, ModifierMask>? _keyUp;
|
||||||
private Action<MouseButton, ModifierMask>? _mouseDown;
|
private Action<MouseButton, ModifierMask>? _mouseDown;
|
||||||
private Action<MouseButton, ModifierMask>? _mouseUp;
|
private Action<MouseButton, ModifierMask>? _mouseUp;
|
||||||
|
private Action<float, float>? _mouseMove;
|
||||||
private Action<float>? _scroll;
|
private Action<float>? _scroll;
|
||||||
|
|
||||||
public int FailAdd { get; init; }
|
public int FailAdd { get; init; }
|
||||||
|
|
@ -153,9 +155,11 @@ public sealed class InputDispatcherLifetimeTests
|
||||||
add => Add(ref _mouseUp, value!);
|
add => Add(ref _mouseUp, value!);
|
||||||
remove => Remove(ref _mouseUp, value!);
|
remove => Remove(ref _mouseUp, value!);
|
||||||
}
|
}
|
||||||
#pragma warning disable CS0067
|
public event Action<float, float>? MouseMove
|
||||||
public event Action<float, float>? MouseMove;
|
{
|
||||||
#pragma warning restore CS0067
|
add => Add(ref _mouseMove, value!);
|
||||||
|
remove => Remove(ref _mouseMove, value!);
|
||||||
|
}
|
||||||
public event Action<float>? Scroll
|
public event Action<float>? Scroll
|
||||||
{
|
{
|
||||||
add => Add(ref _scroll, value!);
|
add => Add(ref _scroll, value!);
|
||||||
|
|
|
||||||
|
|
@ -258,6 +258,37 @@ public class KeyBindingsJsonTests
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LoadOrDefault_migratesV4SelectRightPressBindingToClick()
|
||||||
|
{
|
||||||
|
var path = TempFile();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
const string json = """
|
||||||
|
{
|
||||||
|
"version": 4,
|
||||||
|
"actions": {
|
||||||
|
"SelectRight": [
|
||||||
|
{ "key": "-1002", "device": 1 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
File.WriteAllText(path, json);
|
||||||
|
|
||||||
|
var loaded = KeyBindings.LoadOrDefault(path);
|
||||||
|
Binding binding = Assert.Single(
|
||||||
|
loaded.ForAction(InputAction.SelectRight));
|
||||||
|
|
||||||
|
Assert.Equal((Key)(-1002), binding.Chord.Key);
|
||||||
|
Assert.Equal(ActivationType.Click, binding.Activation);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (File.Exists(path)) File.Delete(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void LoadOrDefault_unknown_action_name_skipped_silently()
|
public void LoadOrDefault_unknown_action_name_skipped_silently()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -108,6 +108,19 @@ public class KeyBindingsRetailTests
|
||||||
Assert.Equal(InputAction.EscapeKey, hit!.Value.Action);
|
Assert.Equal(InputAction.EscapeKey, hit!.Value.Action);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SelectRightIsAReleaseCompletedClick()
|
||||||
|
{
|
||||||
|
var bindings = KeyBindings.RetailDefaults();
|
||||||
|
Binding binding = Assert.Single(bindings.ForAction(InputAction.SelectRight));
|
||||||
|
|
||||||
|
Assert.Equal(1, binding.Chord.Device);
|
||||||
|
Assert.Equal(
|
||||||
|
InputDispatcher.MouseButtonToKey(MouseButton.Right),
|
||||||
|
binding.Chord.Key);
|
||||||
|
Assert.Equal(ActivationType.Click, binding.Activation);
|
||||||
|
}
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData(Key.Delete, InputAction.CombatLowAttack)]
|
[InlineData(Key.Delete, InputAction.CombatLowAttack)]
|
||||||
[InlineData(Key.End, InputAction.CombatMediumAttack)]
|
[InlineData(Key.End, InputAction.CombatMediumAttack)]
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue