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

@ -70,6 +70,21 @@ public sealed class GameplayInputActionRouterTests
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]
public void Attach_SeedsAndTracksCombatScopes()
{

View file

@ -223,6 +223,65 @@ public sealed class SelectionInteractionControllerTests
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]
public void ClosestTargetInputMutatesSelectionOnce()
{

View file

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

View file

@ -11,6 +11,7 @@ public sealed class InputDispatcherLifetimeTests
[InlineData(3)]
[InlineData(4)]
[InlineData(5)]
[InlineData(6)]
public void AttachFailureRollsBackEveryPossiblyAcquiredPrefix(int failAdd)
{
var source = new ThrowingSources { FailAdd = failAdd };
@ -112,6 +113,7 @@ public sealed class InputDispatcherLifetimeTests
private Action<Key, ModifierMask>? _keyUp;
private Action<MouseButton, ModifierMask>? _mouseDown;
private Action<MouseButton, ModifierMask>? _mouseUp;
private Action<float, float>? _mouseMove;
private Action<float>? _scroll;
public int FailAdd { get; init; }
@ -153,9 +155,11 @@ public sealed class InputDispatcherLifetimeTests
add => Add(ref _mouseUp, value!);
remove => Remove(ref _mouseUp, value!);
}
#pragma warning disable CS0067
public event Action<float, float>? MouseMove;
#pragma warning restore CS0067
public event Action<float, float>? MouseMove
{
add => Add(ref _mouseMove, value!);
remove => Remove(ref _mouseMove, value!);
}
public event Action<float>? Scroll
{
add => Add(ref _scroll, value!);

View file

@ -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]
public void LoadOrDefault_unknown_action_name_skipped_silently()
{

View file

@ -108,6 +108,19 @@ public class KeyBindingsRetailTests
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]
[InlineData(Key.Delete, InputAction.CombatLowAttack)]
[InlineData(Key.End, InputAction.CombatMediumAttack)]