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
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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(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!);
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue