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
SubViewport pixels and pushed with `Viewport.PushInput(event, true)`
(`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
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
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
(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
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
+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
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.
**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,
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),
+4 -2
View File
@@ -96,12 +96,14 @@ public partial class Locomotion : Node
};
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,
};
tip.SetSurfaceOverrideMaterial(0, material);
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);
}
+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.
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 ContactDepth = 0.006f;
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.
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 MeshInstance3D[] _dots = new MeshInstance3D[2];
@@ -83,6 +96,10 @@ public partial class MenuScreen : Node3D
private float _swipeVelocity;
private ScrollContainer _flingTarget;
private float _flingVelocity;
private Control _hoverControl;
private Godot.Range _dragRange;
private double _dragRangeTicked;
private float _swipeTickFrom;
private StandardMaterial3D _screenMaterial;
public MenuScreen() : this(new Vector2(0.36f, 0.24f), new Vector2I(960, 640)) { }
@@ -147,6 +164,8 @@ public partial class MenuScreen : Node3D
_down = false;
_pending = false;
_flingTarget = null;
_dragRange = null;
_hoverControl = null;
_owner = -1;
if (_mouseInside)
{
@@ -295,6 +314,7 @@ public partial class MenuScreen : Node3D
if (h.Pixel != _lastSent)
SendMotion(h.Pixel);
int current = _owner;
UpdateHover(current);
if (h.WantDown != _down)
{
_down = h.WantDown;
@@ -304,16 +324,27 @@ public partial class MenuScreen : Node3D
if (h.Mode == PointerMode.Touch && FindSwipeTarget() is ScrollContainer target)
BeginPending(target, h.Pixel);
else
{
_dragRange = Interactable(Viewport.GuiGetHoveredControl()) as Godot.Range;
if (_dragRange != null)
_dragRangeTicked = _dragRange.Value;
SendButton(h.Pixel, true);
}
PointerPressed?.Invoke(current, h.Mode);
}
else if (_pending)
EndPending();
else
{
SendButton(h.Pixel, false);
_dragRange = null;
Haptic?.Invoke(current, ReleaseTick, 0.015f);
}
}
else if (_down && _pending)
UpdateSwipe(h.Pixel, (float)delta);
else if (_down)
RangeDetents(current);
// A UI handler may have reset us (ReleaseAll) during the injected event.
if (_owner == current && current < inputs.Length)
Scroll(inputs[current].Stick, h.Pixel, (float)delta);
@@ -323,6 +354,48 @@ public partial class MenuScreen : Node3D
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
// hovered control; a Range (slider, scrollbar) on the way keeps the normal immediate press.
private ScrollContainer FindSwipeTarget()
@@ -361,6 +434,7 @@ public partial class MenuScreen : Node3D
_swiping = true;
_swipeLastY = pixel.Y;
_swipeScroll = _swipeTarget.ScrollVertical;
_swipeTickFrom = _swipeScroll;
return;
}
// Content follows the tip: dragging up reveals what is below.
@@ -369,6 +443,7 @@ public partial class MenuScreen : Node3D
_swipeScroll -= dy;
if (!SetScroll(_swipeTarget))
_swipeScroll = _swipeTarget.ScrollVertical; // at an end: no stored overscroll
Detent(_swipeScroll);
if (delta > 0f)
_swipeVelocity = Mathf.Lerp(_swipeVelocity, -dy / delta, 1f - Mathf.Exp(-delta * 30f));
}
@@ -389,6 +464,7 @@ public partial class MenuScreen : Node3D
SendMotion(_pressPixel);
SendButton(_pressPixel, true);
SendButton(_pressPixel, false);
Haptic?.Invoke(_owner, ReleaseTick, 0.015f);
if (back != _pressPixel)
SendMotion(back);
}
@@ -516,6 +592,8 @@ public partial class MenuScreen : Node3D
bool up = _scrollAccum > 0f;
_scrollAccum -= up ? 1f : -1f;
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 MainMenu _menu;
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;
public override void _Ready()
@@ -32,6 +32,7 @@ public partial class MenuSpike : Node3D
_screen.SetContent(_menu);
_screen.SetActive(true);
_screen.PointerPressed += (_, _) => _pressedEvents++;
_screen.Haptic += (_, _, _) => _hapticTicks++;
_menu.ReturnToSpawnRequested += () => _spawn++;
_menu.ResetPropsRequested += () => _reset++;
_menu.PingRequested += () => _ping++;
@@ -305,6 +306,7 @@ public partial class MenuSpike : Node3D
await TouchPress(1, Center(_menu.HeaderCloseButton));
Check("touch: Close fired once", _close == 1, $"(count={_close})");
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")}");
GetTree().Quit(_allPass ? 0 : 1);
+2
View File
@@ -48,6 +48,8 @@ public partial class WorldPanel : Node3D
_menu = new MainMenu { Name = "MainMenu" };
_screen.SetContent(_menu);
_screen.PointerPressed += OnPointerPressed;
_screen.Haptic += (hand, amplitude, seconds) =>
(hand == 0 ? _left : _right).TriggerHapticPulse("haptic", 0.0, amplitude, seconds, 0.0);
_menu.ReturnToSpawnRequested += () =>
{