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:
1 parent
b5e7241ac1
commit
35b7e2a44f
5 files changed
+205
-12
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user