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:
1 parent
2f2fcd0aa2
commit
d0b50fbc41
6 files changed
+95
-6
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 += () =>
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in new issue
Block a user