diff --git a/AGENTS.md b/AGENTS.md index f9a9660..ae65c78 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -170,13 +170,13 @@ device only; the editor has an XR simulator for dev. Trackers/action map: see `x controller rays are projected onto the panel plane (analytic, no physics query), converted to SubViewport pixels and pushed with `Viewport.PushInput(event, true)` (`InputEventMouseMotion`/`InputEventMouseButton`, `NotifyMouseEntered/Exited`). The stylus - tip (12 cm ahead of the controller) within 6 cm of the surface = TOUCH cursor: crossing + tip (11 cm ahead of the controller = the drawn stylus tip, which sticks out of the FRONT of the hand box from 6 cm; owner: it used to read as mid-palm) within 6 cm of the surface = TOUCH cursor: crossing 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); 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 + (shrinks as the tip nears = proximity cue), haptic tick on press + light ticks on hovering a new control, click release, slider steps (1/20 range), scroll detents (`MenuScreen.Haptic`), synthesized click (code-generated `AudioStreamWav`, no asset). Menu actions run DEFERRED (never from inside the injected event). "Recalibrate body" calls `Recalibrate()` on the first node in group `"calibration"` (disabled when absent — the menu never creates it; `Calibration.cs` provides it). Locomotion defers diff --git a/HANDOFF.md b/HANDOFF.md index 4bebb07..b750dab 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -162,6 +162,11 @@ TERMINOLOGY (owner-corrected — use precisely): 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. + **Stylus**: touch point = `TipLength` 0.11 m along the aim ray, drawn by `Locomotion.MakeTip` as + a cone from `StylusBase` 0.06 (front of the 12 cm hand box) to exactly that tip. + **Haptics** (`MenuScreen.Haptic(hand, amplitude, seconds)` → `TriggerHapticPulse`): press + (PointerPressed), hover onto a new enabled button/slider/tab bar 0.12, click release 0.25, + slider every 1/20 of range, swipe every 48 px, wheel notch. - **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), diff --git a/scripts/Locomotion.cs b/scripts/Locomotion.cs index 02e6041..ffed2b5 100644 --- a/scripts/Locomotion.cs +++ b/scripts/Locomotion.cs @@ -96,12 +96,14 @@ public partial class Locomotion : Node }; MeshInstance3D tip = new() { - Mesh = new CylinderMesh { TopRadius = 0f, BottomRadius = 0.009f, Height = 0.09f }, + Mesh = new CylinderMesh { TopRadius = 0.0015f, BottomRadius = 0.008f, Height = MenuScreen.TipLength - MenuScreen.StylusBase }, CastShadow = GeometryInstance3D.ShadowCastingSetting.Off, }; tip.SetSurfaceOverrideMaterial(0, material); Basis rot = Basis.FromEuler(new Vector3(-Mathf.Pi / 2f, 0, 0)); - tip.Transform = new Transform3D(rot, rot * new Vector3(0, 0.045f, 0)); + // From the front of the hand box to exactly the menu's touch point. + float mid = (MenuScreen.TipLength + MenuScreen.StylusBase) * 0.5f; + tip.Transform = new Transform3D(rot, rot * new Vector3(0, mid, 0)); controller.AddChild(tip); } diff --git a/scripts/MenuScreen.cs b/scripts/MenuScreen.cs index 791bf83..c7abb34 100644 --- a/scripts/MenuScreen.cs +++ b/scripts/MenuScreen.cs @@ -22,7 +22,12 @@ using System; // 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; + // 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; @@ -64,6 +69,14 @@ public partial class MenuScreen : Node3D // Fired when a hand presses down on the UI (touch contact or trigger) — for haptics. public event Action 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 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]; @@ -83,6 +96,10 @@ public partial class MenuScreen : Node3D 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)) { } @@ -147,6 +164,8 @@ public partial class MenuScreen : Node3D _down = false; _pending = false; _flingTarget = null; + _dragRange = null; + _hoverControl = null; _owner = -1; if (_mouseInside) { @@ -295,6 +314,7 @@ public partial class MenuScreen : Node3D if (h.Pixel != _lastSent) SendMotion(h.Pixel); int current = _owner; + UpdateHover(current); if (h.WantDown != _down) { _down = h.WantDown; @@ -304,16 +324,27 @@ public partial class MenuScreen : Node3D 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); @@ -323,6 +354,48 @@ public partial class MenuScreen : Node3D 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() @@ -361,6 +434,7 @@ public partial class MenuScreen : Node3D _swiping = true; _swipeLastY = pixel.Y; _swipeScroll = _swipeTarget.ScrollVertical; + _swipeTickFrom = _swipeScroll; return; } // Content follows the tip: dragging up reveals what is below. @@ -369,6 +443,7 @@ public partial class MenuScreen : Node3D _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)); } @@ -389,6 +464,7 @@ public partial class MenuScreen : Node3D SendMotion(_pressPixel); SendButton(_pressPixel, true); SendButton(_pressPixel, false); + Haptic?.Invoke(_owner, ReleaseTick, 0.015f); if (back != _pressPixel) SendMotion(back); } @@ -516,6 +592,8 @@ public partial class MenuScreen : Node3D 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); } } diff --git a/scripts/MenuSpike.cs b/scripts/MenuSpike.cs index dbfb35e..17aaf9b 100644 --- a/scripts/MenuSpike.cs +++ b/scripts/MenuSpike.cs @@ -17,7 +17,7 @@ public partial class MenuSpike : Node3D private MenuScreen _screen; private MainMenu _menu; private readonly MenuScreen.PointerInput[] _inputs = new MenuScreen.PointerInput[2]; - private int _spawn, _reset, _ping, _close, _clicks, _pressedEvents; + private int _spawn, _reset, _ping, _close, _clicks, _pressedEvents, _hapticTicks; private bool _allPass = true; public override void _Ready() @@ -32,6 +32,7 @@ public partial class MenuSpike : Node3D _screen.SetContent(_menu); _screen.SetActive(true); _screen.PointerPressed += (_, _) => _pressedEvents++; + _screen.Haptic += (_, _, _) => _hapticTicks++; _menu.ReturnToSpawnRequested += () => _spawn++; _menu.ResetPropsRequested += () => _reset++; _menu.PingRequested += () => _ping++; @@ -305,6 +306,7 @@ public partial class MenuSpike : Node3D await TouchPress(1, Center(_menu.HeaderCloseButton)); Check("touch: Close fired once", _close == 1, $"(count={_close})"); Check("click feedback fired", _clicks >= 8, $"(clicks={_clicks})"); + Check("haptic ticks fired (hover/release/detents)", _hapticTicks >= 30, $"(ticks={_hapticTicks})"); GD.Print($"MENU: overall {(_allPass ? "PASS" : "FAIL")}"); GetTree().Quit(_allPass ? 0 : 1); diff --git a/scripts/WorldPanel.cs b/scripts/WorldPanel.cs index 1b22271..666ade6 100644 --- a/scripts/WorldPanel.cs +++ b/scripts/WorldPanel.cs @@ -48,6 +48,8 @@ public partial class WorldPanel : Node3D _menu = new MainMenu { Name = "MainMenu" }; _screen.SetContent(_menu); _screen.PointerPressed += OnPointerPressed; + _screen.Haptic += (hand, amplitude, seconds) => + (hand == 0 ? _left : _right).TriggerHapticPulse("haptic", 0.0, amplitude, seconds, 0.0); _menu.ReturnToSpawnRequested += () => {