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
+206
-13
No files matched your search
+136
-5
@@ -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;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user