Menu: touch swipe-to-scroll with tap disambiguation, fling, wider scrollbar

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
FoxyzandClaude Opus 5.5 committed 2026-10-02 06:05:40 +03:00
1 parent b5e7241ac1
commit 35b7e2a44f
5 files changed
+205 -12

No files matched your search

+1 -1
View File
@@ -174,7 +174,7 @@ device only; the editor has an XR simulator for dev. Trackers/action map: see `x
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). 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
(shrinks as the tip nears = proximity cue), haptic tick on press, synthesized click
(code-generated `AudioStreamWav`, no asset). Menu actions run DEFERRED (never from inside
+10 -2
View File
@@ -155,6 +155,13 @@ TERMINOLOGY (owner-corrected — use precisely):
on owner gain/loss; owner's stick Y beyond 0.3 → wheel notches at 14/s (`WheelUp/Down`, press +
release). Cursor dot per hand (touch: blue, shrinks as the tip nears; laser: orange; non-owner
half-transparent). Buttons fire on release (Godot default) = tap semantics.
**Swipe vs tap (owner: scrollbar too small to grab, 2026-10-02)**: a touch landing on a
vertically scrollable `ScrollContainer` (found by walking up from `GuiGetHoveredControl()`; a
`Range` — slider/scrollbar — on the way keeps the immediate press) is held back. Tip travel
> `SwipeThreshold` 16 px (~6 mm) before release = swipe: `ScrollVertical` follows the tip,
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.
- **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),
@@ -169,7 +176,8 @@ TERMINOLOGY (owner-corrected — use precisely):
Return-to-spawn fires once while the other hand idles as a laser = arbitration), side entry
behind the surface does NOT press, laser + trigger (tab switch, Reset props, laser distance),
held trigger arriving does NOT click, touch tab switch, laser slider drag, thumbstick scroll of
the log, Recalibrate disabled without / enabled + called with a stub "calibration" node, header
the log, touch swipe scrolls with no click, fast swipe flings, a jittery tap on a button inside
the log still clicks, the scrollbar itself still drags, Recalibrate disabled without / enabled + called with a stub "calibration" node, header
Close by touch. Must print "MENU: overall PASS".
- **TeleportMarker.cs** — `scenes/teleport_marker.tscn`: disc + beam + Label3D, group
"teleport_marker", StaticBody pick box. `SetNear` hides visuals, `SetHighlighted` swaps colors.
@@ -396,7 +404,7 @@ Floor StaticBody mask 1|3|4|5 (collision_mask 29).
in-headset: text sharpness/shimmer at normal distance (no mipmaps — if it shimmers when far,
lower the resolution or try `SubViewportCompositionLayer`; if blurry up close, raise
`SizePixels`), touch press depth/feel (`ContactDepth`/`ReleaseDepth`), accidental presses when
reaching through the panel, laser/touch hand-over near the panel, thumbstick scroll direction,
reaching through the panel, laser/touch hand-over near the panel, thumbstick scroll direction, swipe threshold/fling feel,
the stylus tip vs the drawn cursor dot alignment, and whether 0.3 m summon distance is in reach.
- **Locomotion direction undecided** — owner's lean: full physics thumbstick movement if
the body track goes the Nimso way (stick pushes the physics body; sway/jog detection
+5 -5
View File
@@ -146,7 +146,7 @@ public partial class MainMenu : PanelContainer
system.AddChild(volumeRow);
CloseButton = MakeButton("Close menu", () => CloseRequested?.Invoke());
system.AddChild(CloseButton);
Label logTitle = new() { Text = "Log (thumbstick scrolls)" };
Label logTitle = new() { Text = "Log (swipe or thumbstick to scroll)" };
logTitle.AddThemeColorOverride("font_color", new Color(0.6f, 0.68f, 0.8f));
system.AddChild(logTitle);
LogScroll = new ScrollContainer
@@ -261,10 +261,10 @@ public partial class MainMenu : PanelContainer
theme.SetIcon("grabber_disabled", "HSlider", Circle(36, new Color(0.4f, 0.4f, 0.45f)));
// Wide scrollbar so it is touchable.
theme.SetStylebox("scroll", "VScrollBar", Box(new Color(0.13f, 0.15f, 0.2f), 6, 9, 0));
theme.SetStylebox("grabber", "VScrollBar", Box(new Color(0.3f, 0.36f, 0.48f), 6, 9, 0));
theme.SetStylebox("grabber_highlight", "VScrollBar", Box(new Color(0.4f, 0.5f, 0.68f), 6, 9, 0));
theme.SetStylebox("grabber_pressed", "VScrollBar", Box(accent, 6, 9, 0));
theme.SetStylebox("scroll", "VScrollBar", Box(new Color(0.13f, 0.15f, 0.2f), 8, 14, 0));
theme.SetStylebox("grabber", "VScrollBar", Box(new Color(0.3f, 0.36f, 0.48f), 8, 14, 0));
theme.SetStylebox("grabber_highlight", "VScrollBar", Box(new Color(0.4f, 0.5f, 0.68f), 8, 14, 0));
theme.SetStylebox("grabber_pressed", "VScrollBar", Box(accent, 8, 14, 0));
theme.SetColor("font_color", "Label", new Color(0.88f, 0.9f, 0.95f));
return theme;
+135 -4
View File
@@ -16,6 +16,10 @@ using System;
// trigger (edge) clicks; holding the trigger drags (sliders).
// One Godot mouse exists per viewport, so one hand owns it: the hand holding a press keeps it,
// else a touching hand beats a laser hand, else the previous owner is preferred.
// SWIPE vs TAP: a touch landing on a vertically scrollable ScrollContainer (not on a slider or
// scrollbar inside it) holds its press back. Travel beyond SwipeThreshold before release = a
// swipe: the content follows the tip (and flings on release), no click is ever sent. Released
// 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;
@@ -26,6 +30,9 @@ public partial class MenuScreen : Node3D
public const float TouchEdgeMargin = 0.015f;
public const float ScrollDeadzone = 0.3f;
public const float ScrollNotchesPerSecond = 14f;
public const float SwipeThreshold = 16f; // px (~6 mm) of tip travel before a touch is a swipe
public const float FlingDecay = 5f; // 1/s
public const float FlingMinSpeed = 30f; // px/s
public enum PointerMode { None, Touch, Laser }
@@ -66,6 +73,16 @@ public partial class MenuScreen : Node3D
private bool _mouseInside;
private Vector2 _lastSent = new(-1, -1);
private float _scrollAccum;
// Touch held back for swipe-vs-tap (only while _down).
private bool _pending;
private bool _swiping;
private ScrollContainer _swipeTarget;
private Vector2 _pressPixel;
private float _swipeLastY;
private float _swipeScroll;
private float _swipeVelocity;
private ScrollContainer _flingTarget;
private float _flingVelocity;
private StandardMaterial3D _screenMaterial;
public MenuScreen() : this(new Vector2(0.36f, 0.24f), new Vector2I(960, 640)) { }
@@ -86,6 +103,7 @@ public partial class MenuScreen : Node3D
public PointerMode GetMode(int hand) => _hands[hand].Mode;
public bool IsPressing(int hand) => _owner == hand && _down;
public bool IsSwiping => _down && _pending && _swiping;
public Vector2 GetPixel(int hand) => _hands[hand].Pixel;
public bool TryGetLaserDistance(int hand, out float distance)
@@ -124,9 +142,11 @@ public partial class MenuScreen : Node3D
public void ReleaseAll()
{
if (_owner >= 0 && _down)
if (_owner >= 0 && _down && !_pending)
SendButton(_lastSent, false);
_down = false;
_pending = false;
_flingTarget = null;
_owner = -1;
if (_mouseInside)
{
@@ -247,8 +267,10 @@ public partial class MenuScreen : Node3D
{
if (_owner >= 0 && _down)
{
if (!_pending)
SendButton(_lastSent, false);
_down = false;
_pending = false;
}
_owner = owner;
_scrollAccum = 0f;
@@ -276,18 +298,127 @@ public partial class MenuScreen : Node3D
if (h.WantDown != _down)
{
_down = h.WantDown;
SendButton(h.Pixel, _down);
if (_down)
{
_flingTarget = null;
if (h.Mode == PointerMode.Touch && FindSwipeTarget() is ScrollContainer target)
BeginPending(target, h.Pixel);
else
SendButton(h.Pixel, true);
PointerPressed?.Invoke(current, h.Mode);
}
else if (_pending)
EndPending();
else
SendButton(h.Pixel, false);
}
else if (_down && _pending)
UpdateSwipe(h.Pixel, (float)delta);
// 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);
}
Fling((float)delta);
UpdateDots();
}
// 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()
{
for (Node n = Viewport.GuiGetHoveredControl(); n is Control c; n = n.GetParent())
{
if (c is Godot.Range)
return null;
if (c is ScrollContainer sc)
{
VScrollBar bar = sc.GetVScrollBar();
if (bar.MaxValue - bar.Page > 0.5)
return sc;
}
}
return null;
}
private void BeginPending(ScrollContainer target, Vector2 pixel)
{
_pending = true;
_swiping = false;
_swipeTarget = target;
_pressPixel = pixel;
_swipeVelocity = 0f;
}
private void UpdateSwipe(Vector2 pixel, float delta)
{
if (!IsInstanceValid(_swipeTarget))
return;
if (!_swiping)
{
if (pixel.DistanceTo(_pressPixel) < SwipeThreshold)
return;
_swiping = true;
_swipeLastY = pixel.Y;
_swipeScroll = _swipeTarget.ScrollVertical;
return;
}
// Content follows the tip: dragging up reveals what is below.
float dy = pixel.Y - _swipeLastY;
_swipeLastY = pixel.Y;
_swipeScroll -= dy;
if (!SetScroll(_swipeTarget))
_swipeScroll = _swipeTarget.ScrollVertical; // at an end: no stored overscroll
if (delta > 0f)
_swipeVelocity = Mathf.Lerp(_swipeVelocity, -dy / delta, 1f - Mathf.Exp(-delta * 30f));
}
private void EndPending()
{
_pending = false;
if (_swiping)
{
_flingTarget = _swipeTarget;
_flingVelocity = _swipeVelocity;
_swipeScroll = _flingTarget.ScrollVertical;
}
else
{
// A tap: the whole click at the contact point (buttons fire on release anyway).
Vector2 back = _hands[_owner].Pixel;
SendMotion(_pressPixel);
SendButton(_pressPixel, true);
SendButton(_pressPixel, false);
if (back != _pressPixel)
SendMotion(back);
}
_swiping = false;
_swipeTarget = null;
}
private void Fling(float delta)
{
if (_flingTarget == null)
return;
if (!IsInstanceValid(_flingTarget) || Mathf.Abs(_flingVelocity) < FlingMinSpeed)
{
_flingTarget = null;
return;
}
_swipeScroll += _flingVelocity * delta;
if (!SetScroll(_flingTarget))
_flingTarget = null; // hit an end
_flingVelocity *= Mathf.Exp(-FlingDecay * delta);
}
// Applies _swipeScroll; false when the container clamped it (an end was reached).
private bool SetScroll(ScrollContainer target)
{
int want = Mathf.RoundToInt(_swipeScroll);
target.ScrollVertical = want;
return target.ScrollVertical == want;
}
private void Classify(int hand, PointerInput input)
{
HandState h = _hands[hand];
@@ -399,7 +530,7 @@ public partial class MenuScreen : Node3D
ButtonIndex = wheel,
Pressed = pressed,
Factor = 1f,
ButtonMask = _down ? MouseButtonMask.Left : 0,
ButtonMask = _down && !_pending ? MouseButtonMask.Left : 0,
}, true);
}
}
@@ -412,7 +543,7 @@ public partial class MenuScreen : Node3D
Position = pixel,
GlobalPosition = pixel,
Relative = relative,
ButtonMask = _down ? MouseButtonMask.Left : 0,
ButtonMask = _down && !_pending ? MouseButtonMask.Left : 0,
}, true);
_lastSent = pixel;
}
+54
View File
@@ -91,6 +91,32 @@ public partial class MenuSpike : Node3D
await Frame(3);
}
// Touch down at `from`, slide to `to` while in contact, lift. fling = a fast 4-frame slide.
private async Task TouchDrag(int hand, Vector2 from, Vector2 to, bool fling = false)
{
for (float d = 0.06f; d > -0.004f; d -= 0.004f)
{
TipAt(hand, from, d);
await Frame();
}
// A fling lifts while still moving (as a real flick does); a slow drag stops, then lifts.
int steps = fling ? 8 : 30;
for (int i = 1; i <= steps; i++)
{
float lift = fling ? Mathf.Max(0f, i - 4) * 0.008f : 0f;
TipAt(hand, from.Lerp(to, i / (float)steps), -0.004f + lift);
await Frame();
}
float start = fling ? -0.004f + (steps - 4) * 0.008f : -0.004f;
for (float d = start; d < 0.08f; d += 0.006f)
{
TipAt(hand, to, d);
await Frame();
}
ClearHand(hand);
await Frame(3);
}
// Laser from 0.8 m, off-axis, aimed at the pixel.
private void LaserAt(int hand, Vector2 pixel, bool trigger, Vector2 stick = default)
{
@@ -235,6 +261,34 @@ public partial class MenuSpike : Node3D
await Frame(2);
Check("stick: scroll down then up", scrolled > 50 && scrolledBack < scrolled, $"({scrolled} → {scrolledBack})");
// 6b) Touch swipe vs tap inside the scrollable log.
Button logButton = new() { Text = "in-log button", CustomMinimumSize = new Vector2(0, 48) };
int logButtonPresses = 0;
logButton.Pressed += () => logButtonPresses++;
_menu.LogScroll.GetChild(0).AddChild(logButton);
_menu.LogScroll.GetChild(0).MoveChild(logButton, 0);
_menu.LogScroll.ScrollVertical = 0;
await Frame(5);
int clicksBefore = _clicks;
await TouchDrag(1, logPx, logPx + new Vector2(0, -150));
int swiped = _menu.LogScroll.ScrollVertical;
Check("touch: swipe up scrolls the log", swiped >= 120, $"(scroll 0 → {swiped})");
Check("touch: swipe sends no click", _clicks == clicksBefore && logButtonPresses == 0, $"(clicks +{_clicks - clicksBefore})");
await TouchDrag(1, logPx, logPx + new Vector2(0, 80), fling: true);
await Frame(60);
int flung = _menu.LogScroll.ScrollVertical;
Check("touch: fast swipe down flings past the finger", flung < swiped - 60, $"({swiped} → {flung})");
_menu.LogScroll.ScrollVertical = 0;
await Frame(5);
Vector2 btnPx = Center(logButton);
await TouchDrag(1, btnPx, btnPx + new Vector2(6, 5));
Check("touch: small-jitter tap on in-log button clicks", logButtonPresses == 1 && _menu.LogScroll.ScrollVertical == 0,
$"(presses={logButtonPresses}, scroll={_menu.LogScroll.ScrollVertical})");
VScrollBar vbar = _menu.LogScroll.GetVScrollBar();
Rect2 br = vbar.GetGlobalRect();
await TouchDrag(1, new Vector2(br.GetCenter().X, br.Position.Y + 10), new Vector2(br.GetCenter().X, br.End.Y - 10));
Check("touch: scrollbar still drags directly", _menu.LogScroll.ScrollVertical > 100, $"(scroll={_menu.LogScroll.ScrollVertical})");
// 7) Recalibrate: disabled without a calibration node, enabled + calls it when present.
await TouchPress(1, TabCenter(0));
Check("Recalibrate disabled without group node", _menu.RecalibrateButton.Disabled);