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
+206 -13

No files matched your search

+136 -5
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)
{
SendButton(_lastSent, false);
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;
}