Menu: stylus sticks out of the hand front and matches the touch point; haptic ticks for hover, release, sliders, scrolling

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
FoxyzandClaude Opus 5.5 committed 2026-10-02 06:10:27 +03:00
1 parent 2f2fcd0aa2
commit d0b50fbc41
6 files changed
+95 -6

No files matched your search

+2 -2
View File
@@ -170,13 +170,13 @@ device only; the editor has an XR simulator for dev. Trackers/action map: see `x
controller rays are projected onto the panel plane (analytic, no physics query), converted to controller rays are projected onto the panel plane (analytic, no physics query), converted to
SubViewport pixels and pushed with `Viewport.PushInput(event, true)` SubViewport pixels and pushed with `Viewport.PushInput(event, true)`
(`InputEventMouseMotion`/`InputEventMouseButton`, `NotifyMouseEntered/Exited`). The stylus (`InputEventMouseMotion`/`InputEventMouseButton`, `NotifyMouseEntered/Exited`). The stylus
tip (12 cm ahead of the controller) within 6 cm of the surface = TOUCH cursor: crossing tip (11 cm ahead of the controller = the drawn stylus tip, which sticks out of the FRONT of the hand box from 6 cm; owner: it used to read as mid-palm) within 6 cm of the surface = TOUCH cursor: crossing
0.6 cm presses, backing out past 1.8 cm releases (hysteresis; entering from the side/behind 0.6 cm presses, backing out past 1.8 cm releases (hysteresis; entering from the side/behind
never presses) — buttons fire on release like a real tap, sliders drag while touching. never presses) — buttons fire on release like a real tap, sliders drag while touching.
Farther away = LASER cursor, trigger clicks/drags (edge-triggered: a trigger already held Farther away = LASER cursor, trigger clicks/drags (edge-triggered: a trigger already held
never clicks). Thumbstick Y while hovering = mouse wheel (scroll containers); touch on a scrollable area = swipe-vs-tap (press held back, > 6 mm travel scrolls + flings, else a tap is sent at the contact point). One Godot mouse never clicks). Thumbstick Y while hovering = mouse wheel (scroll containers); touch on a scrollable area = swipe-vs-tap (press held back, > 6 mm travel scrolls + flings, else a tap is sent at the contact point). One Godot mouse
per viewport → one owning hand (press holder ≻ touching hand ≻ laser). Cursor dot per hand per viewport → one owning hand (press holder ≻ touching hand ≻ laser). Cursor dot per hand
(shrinks as the tip nears = proximity cue), haptic tick on press, synthesized click (shrinks as the tip nears = proximity cue), haptic tick on press + light ticks on hovering a new control, click release, slider steps (1/20 range), scroll detents (`MenuScreen.Haptic`), synthesized click
(code-generated `AudioStreamWav`, no asset). Menu actions run DEFERRED (never from inside (code-generated `AudioStreamWav`, no asset). Menu actions run DEFERRED (never from inside
the injected event). "Recalibrate body" calls `Recalibrate()` on the first node in group the injected event). "Recalibrate body" calls `Recalibrate()` on the first node in group
`"calibration"` (disabled when absent — the menu never creates it; `Calibration.cs` provides it). Locomotion defers `"calibration"` (disabled when absent — the menu never creates it; `Calibration.cs` provides it). Locomotion defers
+5
View File
@@ -162,6 +162,11 @@ TERMINOLOGY (owner-corrected — use precisely):
release flings (velocity smoothed at 30/s, decay `FlingDecay` 5/s, stops below 30 px/s or at an release flings (velocity smoothed at 30/s, decay `FlingDecay` 5/s, stops below 30 px/s or at an
end), and NO click is sent. Released sooner = tap: motion + press + release sent together at end), and NO click is sent. Released sooner = tap: motion + press + release sent together at
the contact point. While held back, mouse events carry no button mask. the contact point. While held back, mouse events carry no button mask.
**Stylus**: touch point = `TipLength` 0.11 m along the aim ray, drawn by `Locomotion.MakeTip` as
a cone from `StylusBase` 0.06 (front of the 12 cm hand box) to exactly that tip.
**Haptics** (`MenuScreen.Haptic(hand, amplitude, seconds)` → `TriggerHapticPulse`): press
(PointerPressed), hover onto a new enabled button/slider/tab bar 0.12, click release 0.25,
slider every 1/20 of range, swipe every 48 px, wheel notch.
- **MainMenu.cs** — the menu content (`PanelContainer`, built in code, own `Theme`: 26 px font, - **MainMenu.cs** — the menu content (`PanelContainer`, built in code, own `Theme`: 26 px font,
64 px buttons ≈ 24 mm, round 36 px slider grabber, wide scrollbar, hover/pressed styles, no focus 64 px buttons ≈ 24 mm, round 36 px slider grabber, wide scrollbar, hover/pressed styles, no focus
boxes). Header: title, FPS (4 Hz), Close. Tabs: **Player** (Return to spawn, Recalibrate body), boxes). Header: title, FPS (4 Hz), Close. Tabs: **Player** (Return to spawn, Recalibrate body),
+4 -2
View File
@@ -96,12 +96,14 @@ public partial class Locomotion : Node
}; };
MeshInstance3D tip = new() MeshInstance3D tip = new()
{ {
Mesh = new CylinderMesh { TopRadius = 0f, BottomRadius = 0.009f, Height = 0.09f }, Mesh = new CylinderMesh { TopRadius = 0.0015f, BottomRadius = 0.008f, Height = MenuScreen.TipLength - MenuScreen.StylusBase },
CastShadow = GeometryInstance3D.ShadowCastingSetting.Off, CastShadow = GeometryInstance3D.ShadowCastingSetting.Off,
}; };
tip.SetSurfaceOverrideMaterial(0, material); tip.SetSurfaceOverrideMaterial(0, material);
Basis rot = Basis.FromEuler(new Vector3(-Mathf.Pi / 2f, 0, 0)); Basis rot = Basis.FromEuler(new Vector3(-Mathf.Pi / 2f, 0, 0));
tip.Transform = new Transform3D(rot, rot * new Vector3(0, 0.045f, 0)); // From the front of the hand box to exactly the menu's touch point.
float mid = (MenuScreen.TipLength + MenuScreen.StylusBase) * 0.5f;
tip.Transform = new Transform3D(rot, rot * new Vector3(0, mid, 0));
controller.AddChild(tip); controller.AddChild(tip);
} }
+79 -1
View File
@@ -22,7 +22,12 @@ using System;
// sooner = a tap: press + release are sent together at the contact point. Thumbstick Y = wheel. // sooner = a tap: press + release are sent together at the contact point. Thumbstick Y = wheel.
public partial class MenuScreen : Node3D public partial class MenuScreen : Node3D
{ {
public const float TipLength = 0.12f; // The touch point IS the drawn stylus tip (Locomotion draws the stylus from StylusBase to
// TipLength): a short "finger" sticking out of the front of the 12 cm hand box. It used to
// be a cone from the palm center ending 3 cm short of the real touch point (owner: "the
// stylus is in the middle of the palm").
public const float StylusBase = 0.06f;
public const float TipLength = 0.11f;
public const float HoverDepth = 0.06f; public const float HoverDepth = 0.06f;
public const float ContactDepth = 0.006f; public const float ContactDepth = 0.006f;
public const float ReleaseDepth = 0.018f; public const float ReleaseDepth = 0.018f;
@@ -64,6 +69,14 @@ public partial class MenuScreen : Node3D
// Fired when a hand presses down on the UI (touch contact or trigger) — for haptics. // Fired when a hand presses down on the UI (touch contact or trigger) — for haptics.
public event Action<int, PointerMode> PointerPressed; public event Action<int, PointerMode> PointerPressed;
// Light feedback ticks (hand, amplitude 0..1, seconds): hovering a new control, click
// release, slider steps, scroll detents. Press itself stays on PointerPressed.
public event Action<int, float, float> Haptic;
public const float HoverTick = 0.12f;
public const float ReleaseTick = 0.25f;
public const float DetentTick = 0.08f;
public const float SwipeDetentPixels = 48f;
public const int SliderDetents = 20;
private readonly HandState[] _hands = new HandState[2]; private readonly HandState[] _hands = new HandState[2];
private readonly MeshInstance3D[] _dots = new MeshInstance3D[2]; private readonly MeshInstance3D[] _dots = new MeshInstance3D[2];
@@ -83,6 +96,10 @@ public partial class MenuScreen : Node3D
private float _swipeVelocity; private float _swipeVelocity;
private ScrollContainer _flingTarget; private ScrollContainer _flingTarget;
private float _flingVelocity; private float _flingVelocity;
private Control _hoverControl;
private Godot.Range _dragRange;
private double _dragRangeTicked;
private float _swipeTickFrom;
private StandardMaterial3D _screenMaterial; private StandardMaterial3D _screenMaterial;
public MenuScreen() : this(new Vector2(0.36f, 0.24f), new Vector2I(960, 640)) { } public MenuScreen() : this(new Vector2(0.36f, 0.24f), new Vector2I(960, 640)) { }
@@ -147,6 +164,8 @@ public partial class MenuScreen : Node3D
_down = false; _down = false;
_pending = false; _pending = false;
_flingTarget = null; _flingTarget = null;
_dragRange = null;
_hoverControl = null;
_owner = -1; _owner = -1;
if (_mouseInside) if (_mouseInside)
{ {
@@ -295,6 +314,7 @@ public partial class MenuScreen : Node3D
if (h.Pixel != _lastSent) if (h.Pixel != _lastSent)
SendMotion(h.Pixel); SendMotion(h.Pixel);
int current = _owner; int current = _owner;
UpdateHover(current);
if (h.WantDown != _down) if (h.WantDown != _down)
{ {
_down = h.WantDown; _down = h.WantDown;
@@ -304,16 +324,27 @@ public partial class MenuScreen : Node3D
if (h.Mode == PointerMode.Touch && FindSwipeTarget() is ScrollContainer target) if (h.Mode == PointerMode.Touch && FindSwipeTarget() is ScrollContainer target)
BeginPending(target, h.Pixel); BeginPending(target, h.Pixel);
else else
{
_dragRange = Interactable(Viewport.GuiGetHoveredControl()) as Godot.Range;
if (_dragRange != null)
_dragRangeTicked = _dragRange.Value;
SendButton(h.Pixel, true); SendButton(h.Pixel, true);
}
PointerPressed?.Invoke(current, h.Mode); PointerPressed?.Invoke(current, h.Mode);
} }
else if (_pending) else if (_pending)
EndPending(); EndPending();
else else
{
SendButton(h.Pixel, false); SendButton(h.Pixel, false);
_dragRange = null;
Haptic?.Invoke(current, ReleaseTick, 0.015f);
}
} }
else if (_down && _pending) else if (_down && _pending)
UpdateSwipe(h.Pixel, (float)delta); UpdateSwipe(h.Pixel, (float)delta);
else if (_down)
RangeDetents(current);
// A UI handler may have reset us (ReleaseAll) during the injected event. // A UI handler may have reset us (ReleaseAll) during the injected event.
if (_owner == current && current < inputs.Length) if (_owner == current && current < inputs.Length)
Scroll(inputs[current].Stick, h.Pixel, (float)delta); Scroll(inputs[current].Stick, h.Pixel, (float)delta);
@@ -323,6 +354,48 @@ public partial class MenuScreen : Node3D
UpdateDots(); UpdateDots();
} }
// The nearest control (hovered or an ancestor) that reacts to clicks.
private static Control Interactable(Control c)
{
for (Node n = c; n is Control control; n = n.GetParent())
if (control is BaseButton or Godot.Range or TabBar)
return control;
return null;
}
private void UpdateHover(int hand)
{
Control hovered = Interactable(Viewport.GuiGetHoveredControl());
if (hovered == _hoverControl)
return;
_hoverControl = hovered;
bool enabled = hovered is not BaseButton b || !b.Disabled;
if (hovered != null && enabled && !(_down && _pending && _swiping))
Haptic?.Invoke(hand, HoverTick, 0.01f);
}
// A slider being dragged ticks every 1/SliderDetents of its range.
private void RangeDetents(int hand)
{
if (_dragRange == null || !IsInstanceValid(_dragRange))
return;
double step = (_dragRange.MaxValue - _dragRange.MinValue) / SliderDetents;
if (step > 0 && Math.Abs(_dragRange.Value - _dragRangeTicked) >= step)
{
_dragRangeTicked = _dragRange.Value;
Haptic?.Invoke(hand, DetentTick, 0.008f);
}
}
private void Detent(float scroll)
{
if (Mathf.Abs(scroll - _swipeTickFrom) < SwipeDetentPixels)
return;
_swipeTickFrom = scroll;
if (_owner >= 0)
Haptic?.Invoke(_owner, DetentTick, 0.006f);
}
// The scroll container a touch at the current mouse position would swipe: walk up from the // The scroll container a touch at the current mouse position would swipe: walk up from the
// hovered control; a Range (slider, scrollbar) on the way keeps the normal immediate press. // hovered control; a Range (slider, scrollbar) on the way keeps the normal immediate press.
private ScrollContainer FindSwipeTarget() private ScrollContainer FindSwipeTarget()
@@ -361,6 +434,7 @@ public partial class MenuScreen : Node3D
_swiping = true; _swiping = true;
_swipeLastY = pixel.Y; _swipeLastY = pixel.Y;
_swipeScroll = _swipeTarget.ScrollVertical; _swipeScroll = _swipeTarget.ScrollVertical;
_swipeTickFrom = _swipeScroll;
return; return;
} }
// Content follows the tip: dragging up reveals what is below. // Content follows the tip: dragging up reveals what is below.
@@ -369,6 +443,7 @@ public partial class MenuScreen : Node3D
_swipeScroll -= dy; _swipeScroll -= dy;
if (!SetScroll(_swipeTarget)) if (!SetScroll(_swipeTarget))
_swipeScroll = _swipeTarget.ScrollVertical; // at an end: no stored overscroll _swipeScroll = _swipeTarget.ScrollVertical; // at an end: no stored overscroll
Detent(_swipeScroll);
if (delta > 0f) if (delta > 0f)
_swipeVelocity = Mathf.Lerp(_swipeVelocity, -dy / delta, 1f - Mathf.Exp(-delta * 30f)); _swipeVelocity = Mathf.Lerp(_swipeVelocity, -dy / delta, 1f - Mathf.Exp(-delta * 30f));
} }
@@ -389,6 +464,7 @@ public partial class MenuScreen : Node3D
SendMotion(_pressPixel); SendMotion(_pressPixel);
SendButton(_pressPixel, true); SendButton(_pressPixel, true);
SendButton(_pressPixel, false); SendButton(_pressPixel, false);
Haptic?.Invoke(_owner, ReleaseTick, 0.015f);
if (back != _pressPixel) if (back != _pressPixel)
SendMotion(back); SendMotion(back);
} }
@@ -516,6 +592,8 @@ public partial class MenuScreen : Node3D
bool up = _scrollAccum > 0f; bool up = _scrollAccum > 0f;
_scrollAccum -= up ? 1f : -1f; _scrollAccum -= up ? 1f : -1f;
SendWheel(pixel, up ? MouseButton.WheelUp : MouseButton.WheelDown); SendWheel(pixel, up ? MouseButton.WheelUp : MouseButton.WheelDown);
if (_owner >= 0)
Haptic?.Invoke(_owner, DetentTick * 0.75f, 0.006f);
} }
} }
+3 -1
View File
@@ -17,7 +17,7 @@ public partial class MenuSpike : Node3D
private MenuScreen _screen; private MenuScreen _screen;
private MainMenu _menu; private MainMenu _menu;
private readonly MenuScreen.PointerInput[] _inputs = new MenuScreen.PointerInput[2]; private readonly MenuScreen.PointerInput[] _inputs = new MenuScreen.PointerInput[2];
private int _spawn, _reset, _ping, _close, _clicks, _pressedEvents; private int _spawn, _reset, _ping, _close, _clicks, _pressedEvents, _hapticTicks;
private bool _allPass = true; private bool _allPass = true;
public override void _Ready() public override void _Ready()
@@ -32,6 +32,7 @@ public partial class MenuSpike : Node3D
_screen.SetContent(_menu); _screen.SetContent(_menu);
_screen.SetActive(true); _screen.SetActive(true);
_screen.PointerPressed += (_, _) => _pressedEvents++; _screen.PointerPressed += (_, _) => _pressedEvents++;
_screen.Haptic += (_, _, _) => _hapticTicks++;
_menu.ReturnToSpawnRequested += () => _spawn++; _menu.ReturnToSpawnRequested += () => _spawn++;
_menu.ResetPropsRequested += () => _reset++; _menu.ResetPropsRequested += () => _reset++;
_menu.PingRequested += () => _ping++; _menu.PingRequested += () => _ping++;
@@ -305,6 +306,7 @@ public partial class MenuSpike : Node3D
await TouchPress(1, Center(_menu.HeaderCloseButton)); await TouchPress(1, Center(_menu.HeaderCloseButton));
Check("touch: Close fired once", _close == 1, $"(count={_close})"); Check("touch: Close fired once", _close == 1, $"(count={_close})");
Check("click feedback fired", _clicks >= 8, $"(clicks={_clicks})"); Check("click feedback fired", _clicks >= 8, $"(clicks={_clicks})");
Check("haptic ticks fired (hover/release/detents)", _hapticTicks >= 30, $"(ticks={_hapticTicks})");
GD.Print($"MENU: overall {(_allPass ? "PASS" : "FAIL")}"); GD.Print($"MENU: overall {(_allPass ? "PASS" : "FAIL")}");
GetTree().Quit(_allPass ? 0 : 1); GetTree().Quit(_allPass ? 0 : 1);
+2
View File
@@ -48,6 +48,8 @@ public partial class WorldPanel : Node3D
_menu = new MainMenu { Name = "MainMenu" }; _menu = new MainMenu { Name = "MainMenu" };
_screen.SetContent(_menu); _screen.SetContent(_menu);
_screen.PointerPressed += OnPointerPressed; _screen.PointerPressed += OnPointerPressed;
_screen.Haptic += (hand, amplitude, seconds) =>
(hand == 0 ? _left : _right).TriggerHapticPulse("haptic", 0.0, amplitude, seconds, 0.0);
_menu.ReturnToSpawnRequested += () => _menu.ReturnToSpawnRequested += () =>
{ {