Files
VR-Mess/scripts/MenuScreen.cs
T

677 lines
24 KiB
C#

using Godot;
using System;
// A thin 3D panel showing Godot Control UI rendered into a SubViewport (the project's ONE
// SubViewport screen, see AGENTS.md), plus the touch-first hybrid cursor that drives it.
// Input-agnostic: callers feed per-hand rays (origin + forward dir), trigger and thumbstick;
// the screen projects them onto its plane and injects InputEventMouseMotion/MouseButton into
// the SubViewport via Viewport.PushInput(event, in_local_coords: true).
//
// Cursor rules per hand (panel-local frame: X right, Y up, +Z = front normal, origin = center):
// TOUCH — the stylus tip (origin + dir * TipLength) is within HoverDepth in front of the plane
// (or pushed up to PushThroughDepth behind it) and over the panel: the tip's
// projection is the cursor. Crossing ContactDepth from the front presses, backing out
// past ReleaseDepth releases (hysteresis; entering from the side/behind never presses).
// LASER — otherwise, the ray hits the panel from the front: the hit is the cursor and the
// 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
{
// 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 ContactDepth = 0.006f;
public const float ReleaseDepth = 0.018f;
public const float PushThroughDepth = 0.08f;
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 }
public struct PointerInput
{
public bool Valid;
public Vector3 Origin;
public Vector3 Direction;
public bool Trigger;
public Vector2 Stick;
}
private struct HandState
{
public PointerMode Mode;
public Vector3 Local;
public Vector2 Pixel;
public float Depth;
public float RayDistance;
public bool Armed;
public bool TriggerPrev;
public bool WantDown;
}
public Vector2 SizeMeters { get; }
public Vector2I SizePixels { get; }
public SubViewport Viewport { get; private set; }
public StaticBody3D Body { get; private set; }
// Fired when a hand presses down on the UI (touch contact or trigger) — for haptics.
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 MeshInstance3D[] _dots = new MeshInstance3D[2];
private readonly StandardMaterial3D[] _dotMaterials = new StandardMaterial3D[2];
private int _owner = -1;
private bool _down;
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 Control _hoverControl;
private Godot.Range _dragRange;
private double _dragRangeTicked;
private float _swipeTickFrom;
private StandardMaterial3D _screenMaterial;
public MenuScreen() : this(new Vector2(0.36f, 0.24f), new Vector2I(960, 640)) { }
public MenuScreen(Vector2 sizeMeters, Vector2I sizePixels)
{
SizeMeters = sizeMeters;
SizePixels = sizePixels;
Build();
}
public override void _Ready()
{
// ViewportTexture is fetched once the viewport is inside the tree (docs: may return
// incorrect data before the scene root is ready).
_screenMaterial.AlbedoTexture = Viewport.GetTexture();
}
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)
{
distance = _hands[hand].RayDistance;
return Visible && _hands[hand].Mode == PointerMode.Laser;
}
public Vector2 LocalToPixel(Vector3 local) => new(
(local.X / SizeMeters.X + 0.5f) * SizePixels.X,
(0.5f - local.Y / SizeMeters.Y) * SizePixels.Y);
public Vector3 PixelToLocal(Vector2 pixel) => new(
(pixel.X / SizePixels.X - 0.5f) * SizeMeters.X,
(0.5f - pixel.Y / SizePixels.Y) * SizeMeters.Y,
0f);
public Vector3 PixelToGlobal(Vector2 pixel) => GlobalTransform * PixelToLocal(pixel);
public void SetContent(Control content)
{
content.SetAnchorsAndOffsetsPreset(Control.LayoutPreset.FullRect);
Viewport.AddChild(content);
}
public void SetActive(bool active)
{
if (!active)
ReleaseAll();
Visible = active;
Viewport.RenderTargetUpdateMode = active ? SubViewport.UpdateMode.Always : SubViewport.UpdateMode.Disabled;
foreach (Node child in Body.GetChildren())
if (child is CollisionShape3D shape)
shape.SetDeferred(CollisionShape3D.PropertyName.Disabled, !active);
}
public void ReleaseAll()
{
if (_owner >= 0 && _down && !_pending)
SendButton(_lastSent, false);
_down = false;
_pending = false;
_flingTarget = null;
_dragRange = null;
_hoverControl = null;
_owner = -1;
if (_mouseInside)
{
Viewport.NotifyMouseExited();
_mouseInside = false;
}
_scrollAccum = 0f;
for (int i = 0; i < 2; i++)
{
// TriggerPrev survives: a trigger still held across a reset must not re-click.
_hands[i] = new HandState { TriggerPrev = _hands[i].TriggerPrev };
if (_dots[i] != null)
_dots[i].Visible = false;
}
}
private void Build()
{
Viewport = new SubViewport
{
Name = "MenuViewport",
Size = SizePixels,
Disable3D = true,
TransparentBg = false,
GuiEmbedSubwindows = true,
HandleInputLocally = true,
Msaa2D = Godot.Viewport.Msaa.Msaa4X,
RenderTargetUpdateMode = SubViewport.UpdateMode.Disabled,
};
AddChild(Viewport);
// Screen quad with explicit UVs so the pixel mapping above is ours, not QuadMesh's.
float hx = SizeMeters.X * 0.5f, hy = SizeMeters.Y * 0.5f;
var st = new SurfaceTool();
st.Begin(Mesh.PrimitiveType.Triangles);
Vector3[] corners = { new(-hx, hy, 0), new(hx, hy, 0), new(hx, -hy, 0), new(-hx, -hy, 0) };
Vector2[] uvs = { new(0, 0), new(1, 0), new(1, 1), new(0, 1) };
int[] order = { 0, 1, 2, 0, 2, 3 };
foreach (int i in order)
{
st.SetNormal(Vector3.Back);
st.SetUV(uvs[i]);
st.AddVertex(corners[i]);
}
_screenMaterial = new StandardMaterial3D
{
ShadingMode = BaseMaterial3D.ShadingModeEnum.Unshaded,
TextureFilter = BaseMaterial3D.TextureFilterEnum.Linear,
CullMode = BaseMaterial3D.CullModeEnum.Disabled,
};
MeshInstance3D screen = new()
{
Name = "Screen",
Mesh = st.Commit(),
CastShadow = GeometryInstance3D.ShadowCastingSetting.Off,
};
screen.SetSurfaceOverrideMaterial(0, _screenMaterial);
AddChild(screen);
// Thin backing frame (hides the mirrored back side, gives the panel an edge).
StandardMaterial3D frameMaterial = new() { AlbedoColor = new Color(0.05f, 0.06f, 0.08f) };
MeshInstance3D frame = new()
{
Name = "Frame",
Mesh = new BoxMesh { Size = new Vector3(SizeMeters.X + 0.012f, SizeMeters.Y + 0.012f, 0.008f) },
Position = new Vector3(0, 0, -0.0045f),
CastShadow = GeometryInstance3D.ShadowCastingSetting.Off,
};
frame.SetSurfaceOverrideMaterial(0, frameMaterial);
AddChild(frame);
// UI layer 2, mask 0: lasers/raycasts see it, props pass through it.
Body = new StaticBody3D { Name = "PanelBody", CollisionLayer = 2, CollisionMask = 0 };
Body.AddChild(new CollisionShape3D
{
Shape = new BoxShape3D { Size = new Vector3(SizeMeters.X, SizeMeters.Y, 0.02f) },
Position = new Vector3(0, 0, -0.005f),
});
AddChild(Body);
for (int i = 0; i < 2; i++)
{
_dotMaterials[i] = new StandardMaterial3D
{
ShadingMode = BaseMaterial3D.ShadingModeEnum.Unshaded,
AlbedoColor = Colors.White,
NoDepthTest = true,
RenderPriority = 1,
};
_dots[i] = new MeshInstance3D
{
Name = $"Cursor{i}",
Mesh = new SphereMesh { Radius = 0.004f, Height = 0.008f, RadialSegments = 12, Rings = 6 },
CastShadow = GeometryInstance3D.ShadowCastingSetting.Off,
Visible = false,
};
_dots[i].SetSurfaceOverrideMaterial(0, _dotMaterials[i]);
AddChild(_dots[i]);
}
}
// Call once per frame with both hands' input (index 0 = left, 1 = right).
public void UpdatePointers(PointerInput[] inputs, double delta)
{
if (!Visible)
{
// Keep trigger edges current while hidden so a held trigger can't click on summon.
for (int hand = 0; hand < 2 && hand < inputs.Length; hand++)
_hands[hand].TriggerPrev = inputs[hand].Trigger;
return;
}
for (int hand = 0; hand < 2; hand++)
Classify(hand, hand < inputs.Length ? inputs[hand] : default);
int owner = PickOwner();
if (owner != _owner)
{
if (_owner >= 0 && _down)
{
if (!_pending)
SendButton(_lastSent, false);
_down = false;
_pending = false;
}
_owner = owner;
_scrollAccum = 0f;
}
if (_owner < 0)
{
if (_mouseInside)
{
Viewport.NotifyMouseExited();
_mouseInside = false;
}
}
else
{
HandState h = _hands[_owner];
if (!_mouseInside)
{
Viewport.NotifyMouseEntered();
_mouseInside = true;
}
if (h.Pixel != _lastSent)
SendMotion(h.Pixel);
int current = _owner;
UpdateHover(current);
if (h.WantDown != _down)
{
_down = h.WantDown;
if (_down)
{
_flingTarget = null;
if (h.Mode == PointerMode.Touch && FindSwipeTarget() is ScrollContainer target)
BeginPending(target, h.Pixel);
else
{
_dragRange = Interactable(Viewport.GuiGetHoveredControl()) as Godot.Range;
if (_dragRange != null)
_dragRangeTicked = _dragRange.Value;
SendButton(h.Pixel, true);
}
PointerPressed?.Invoke(current, h.Mode);
}
else if (_pending)
EndPending();
else
{
SendButton(h.Pixel, false);
_dragRange = null;
Haptic?.Invoke(current, ReleaseTick, 0.015f);
}
}
else if (_down && _pending)
UpdateSwipe(h.Pixel, (float)delta);
else if (_down)
RangeDetents(current);
// 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 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
// 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;
_swipeTickFrom = _swipeScroll;
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
Detent(_swipeScroll);
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);
Haptic?.Invoke(_owner, ReleaseTick, 0.015f);
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];
PointerMode mode = PointerMode.None;
bool wasOwnerDown = _owner == hand && _down;
if (input.Valid && input.Direction.LengthSquared() > 1e-6f)
{
Transform3D inv = GlobalTransform.AffineInverse();
Vector3 from = inv * input.Origin;
Vector3 dir = (inv.Basis * input.Direction).Normalized();
Vector3 tip = from + dir * TipLength;
float hx = SizeMeters.X * 0.5f, hy = SizeMeters.Y * 0.5f;
bool tipOver = Mathf.Abs(tip.X) <= hx + TouchEdgeMargin && Mathf.Abs(tip.Y) <= hy + TouchEdgeMargin;
if (from.Z > 0f && tipOver && tip.Z <= HoverDepth && tip.Z >= -PushThroughDepth)
{
mode = PointerMode.Touch;
h.Local = new Vector3(Mathf.Clamp(tip.X, -hx, hx), Mathf.Clamp(tip.Y, -hy, hy), 0f);
h.Depth = tip.Z;
h.RayDistance = 0f;
}
else if (from.Z > 0f && dir.Z < -1e-4f)
{
float t = -from.Z / dir.Z;
Vector3 hit = from + dir * t;
if (Mathf.Abs(hit.X) <= hx && Mathf.Abs(hit.Y) <= hy)
{
mode = PointerMode.Laser;
h.Local = hit;
h.Depth = float.MaxValue;
h.RayDistance = t;
}
}
}
h.Mode = mode;
if (mode != PointerMode.None)
{
Vector2 px = LocalToPixel(h.Local);
h.Pixel = new Vector2(
Mathf.Clamp(px.X, 0f, SizePixels.X - 1),
Mathf.Clamp(px.Y, 0f, SizePixels.Y - 1));
}
switch (mode)
{
case PointerMode.Touch:
h.WantDown = wasOwnerDown ? h.Depth <= ReleaseDepth : h.Armed && h.Depth <= ContactDepth;
break;
case PointerMode.Laser:
h.WantDown = wasOwnerDown ? input.Trigger : input.Trigger && !h.TriggerPrev;
break;
default:
h.WantDown = false;
break;
}
h.Armed = mode == PointerMode.Touch && h.Depth > ContactDepth;
h.TriggerPrev = input.Trigger;
_hands[hand] = h;
}
private int PickOwner()
{
if (_owner >= 0 && _down && _hands[_owner].Mode != PointerMode.None)
return _owner;
int best = -1;
for (int hand = 0; hand < 2; hand++)
{
if (_hands[hand].Mode != PointerMode.Touch)
continue;
if (best < 0 || _hands[hand].Depth < _hands[best].Depth)
best = hand;
}
if (best >= 0)
return best;
if (_owner >= 0 && _hands[_owner].Mode == PointerMode.Laser)
return _owner;
for (int hand = 0; hand < 2; hand++)
if (_hands[hand].Mode == PointerMode.Laser)
return hand;
return -1;
}
private void Scroll(Vector2 stick, Vector2 pixel, float delta)
{
if (Mathf.Abs(stick.Y) < ScrollDeadzone)
{
_scrollAccum = 0f;
return;
}
_scrollAccum += stick.Y * ScrollNotchesPerSecond * delta;
while (Mathf.Abs(_scrollAccum) >= 1f)
{
bool up = _scrollAccum > 0f;
_scrollAccum -= up ? 1f : -1f;
SendWheel(pixel, up ? MouseButton.WheelUp : MouseButton.WheelDown);
if (_owner >= 0)
Haptic?.Invoke(_owner, DetentTick * 0.75f, 0.006f);
}
}
public void SendWheel(Vector2 pixel, MouseButton wheel)
{
foreach (bool pressed in new[] { true, false })
{
Viewport.PushInput(new InputEventMouseButton
{
Position = pixel,
GlobalPosition = pixel,
ButtonIndex = wheel,
Pressed = pressed,
Factor = 1f,
ButtonMask = _down && !_pending ? MouseButtonMask.Left : 0,
}, true);
}
}
private void SendMotion(Vector2 pixel)
{
Vector2 relative = _lastSent.X < 0 ? Vector2.Zero : pixel - _lastSent;
Viewport.PushInput(new InputEventMouseMotion
{
Position = pixel,
GlobalPosition = pixel,
Relative = relative,
ButtonMask = _down && !_pending ? MouseButtonMask.Left : 0,
}, true);
_lastSent = pixel;
}
private void SendButton(Vector2 pixel, bool pressed)
{
Viewport.PushInput(new InputEventMouseButton
{
Position = pixel,
GlobalPosition = pixel,
ButtonIndex = MouseButton.Left,
Pressed = pressed,
ButtonMask = pressed ? MouseButtonMask.Left : 0,
}, true);
_lastSent = pixel;
}
private void UpdateDots()
{
for (int hand = 0; hand < 2; hand++)
{
HandState h = _hands[hand];
MeshInstance3D dot = _dots[hand];
dot.Visible = h.Mode != PointerMode.None;
if (!dot.Visible)
continue;
bool pressing = _owner == hand && _down;
float scale;
Color color;
if (h.Mode == PointerMode.Touch)
{
// Shrinks as the tip approaches the surface: a proximity cue for touch.
float k = Mathf.Clamp(h.Depth / HoverDepth, 0f, 1f);
scale = Mathf.Lerp(0.6f, 1.6f, k);
color = pressing ? new Color(0.5f, 1f, 0.6f) : new Color(0.55f, 0.85f, 1f);
}
else
{
scale = 1f;
color = pressing ? new Color(1f, 0.85f, 0.4f) : new Color(1f, 0.55f, 0.15f);
}
if (_owner != hand)
color.A = 0.5f;
_dotMaterials[hand].AlbedoColor = color;
_dotMaterials[hand].Transparency = color.A < 1f
? BaseMaterial3D.TransparencyEnum.Alpha
: BaseMaterial3D.TransparencyEnum.Disabled;
dot.Position = new Vector3(h.Local.X, h.Local.Y, 0.002f);
dot.Scale = Vector3.One * scale;
}
}
}