diff --git a/AGENTS.md b/AGENTS.md index 5a9050e..e40c2d1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -147,32 +147,45 @@ device only; the editor has an XR simulator for dev. Trackers/action map: see `x (bool actions unreliable across runtimes). Engine logs land in `~/.local/share/godot/app_userdata/VR Messs/logs/godot.log` — agents: read this after user playtests instead of guessing. -- **UI direction — physical prop controls first**: buttons, knobs, sliders as real 3D objects - (touch interaction, later Box3D for feel). Godot has NO native world-space widget system — - `Control`s are screen-space; world UI is always Control-rendered-into-3D via `SubViewport` - (optionally `SubViewportCompositionLayer` for XR-native sharpness/perf). Use at most one - SubViewport/composition-layer screen for dense content (file browser, settings) if needed; - everything else is props. World-space text is native and cheap: `Label3D` (SDF, crisp at - distance) and `TextMesh` (extruded) — no SubViewport required. -- **`WorldPanel.cs` is the first prop-UI prototype**: summoned/dismissed via `ax_button` (A/X — +- **UI direction — physical prop controls first, ONE dense screen**: buttons, knobs, sliders + as real 3D objects where they live in the world (touch interaction, later Box3D for feel). + Godot has NO native world-space widget system — `Control`s are screen-space; world UI is + always Control-rendered-into-3D via `SubViewport` (optionally `SubViewportCompositionLayer` + for XR-native sharpness/perf). At most ONE SubViewport/composition-layer screen for dense + content — **that screen is now the in-VR menu (`WorldPanel`, below)**; everything else is + props. World-space text is native and cheap: `Label3D` (SDF, crisp at distance) and + `TextMesh` (extruded) — no SubViewport required. +- **`WorldPanel.cs` = the in-VR menu (2026-10-02, replaced the chunky prop-knob panel)**: + real Godot Control UI (`MainMenu.cs`: tabs Player / World / System, normal-sized buttons, + slider, scrollable log, FPS) rendered by `MenuScreen.cs` into a 960×640 SubViewport shown on + a thin 0.36×0.24 m panel (~2.7 px/mm ≈ 1:1 with headset pixels at 45 cm — viewport textures + have no mipmaps, so don't oversample). Summoned/dismissed via `ax_button` (A/X — menu_button belongs to the runtime). It is PARENTED UNDER `XROrigin3D` — Koikatsu-VR-style "fixed in the player's room": IRL movement doesn't move it relative to you, game locomotion carries it. If that feel is wrong, reverting = reparenting to `Main` and fixing the `_Ready` - node paths. Floats in front of the summoning hand facing the player (full 3D facing — works - from lying poses), draggable directly or via laser at fixed ray distance while gripped - (continuously re-orients toward the head). The Spawn - knob also recalls the panel in front of the head. The pointer - tip is a protruding stylus cone (apex ~12 cm ahead of the controller) and is THE cursor: - knobs hover-highlight and PRESS ON CONTACT (tip within 4.5 cm, with cooldown) — physical - buttons, no trigger needed; trigger still presses laser-hovered knobs (knobs are - laser-targetable). Presses play a synthesized click (code-generated `AudioStreamWav` PCM - — no asset). Spawn/Ping/Close knobs. Locomotion defers + node paths. Appears 0.3 m in front of the summoning hand facing the head (full 3D facing — + works from lying poses), draggable by grip directly or via laser at fixed ray distance + (continuously re-orients toward the head); "Return to spawn" recalls it in front of the head. + **Input = the touch-first hybrid cursor, injected as mouse events**: each frame both + 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 + 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 + 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 + 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 grab/teleport to the panel when a hand is near it (`WorldPanel.IsBlocking`). Laser lines are - owned by `Locomotion.UpdatePointer`: shown only when aiming at something interactive (marker - or panel) AND beyond `LaserMinDistance` (0.35 m) — near targets are touched via the pointer - tip instead, which is the touch-first hybrid cursor in miniature. Grab deltas and turn - pivots are computed in WORLD space (tracking-space math made two-hand turns orbit the map - center — do not regress this). + owned by `Locomotion.UpdatePointer`: for the menu it asks `WorldPanel.TryGetLaserDistance` + (beam only while that hand is in laser mode, beyond `LaserMinDistance` 0.35 m); markers keep + the raycast path. Regression: `scenes/menu_spike.tscn` "MENU: overall PASS". Grab deltas + and turn pivots are computed in WORLD space (tracking-space math made two-hand turns orbit + the map center — do not regress this). - **Control scheme is deliberate**: decide what lives on controllers vs. what lives in menus before stacking bindings — controller button budget is scarce, menus carry the rest. @@ -189,8 +202,9 @@ device only; the editor has an XR simulator for dev. Trackers/action map: see `x movement weird, teleport + lasers broken (owner report 2026-10-01; experiments, not current issues). Fix-or-replace deferred — owner's candidate replacement is physics thumbstick movement if the body-physics track lands. -4. ~~UI: first prop panel~~ — **done** (`WorldPanel.cs`); SubViewport/composition-layer screen - deferred until dense content (file browser) is actually needed. +4. ~~UI: first prop panel~~ — **done** (`WorldPanel.cs`), then REPLACED (2026-10-02, owner: + knobs "incredibly chonky") by the SubViewport menu screen (`MenuScreen` + `MainMenu`) — + the one allowed dense screen. Awaiting owner playtest. 5. ~~Box3D validation spike~~ — **done, PASS** (drop+contact, velocity-chase vs wall, headless via `scenes/physics_spike.tscn`). 6. **Physics hands & props sandbox — grab v5.3 = SOLVER-OWNED RIGID WELD (patched addon).** @@ -223,8 +237,8 @@ device only; the editor has an XR simulator for dev. Trackers/action map: see `x (Nimso/VRChat precedent); props/floor/table mask 1|3|4|5, hands 4/1|4, chest 3/1|3, arm segments 5/1; panel stays 2/0 (props pass through even when shown); layers live on - CollisionObject3D, NOT CollisionShape3D. Panel = prop-UI prototype (ax_button summon, - contact-press knobs, Reset). Laser/distant grab REMOVED (user never knew it existed); + CollisionObject3D, NOT CollisionShape3D. Panel = the in-VR menu (ax_button summon, + touch/laser Control UI, Reset props). Laser/distant grab REMOVED (user never knew it existed); grab = palm-proximity only, per-hand palm-face offset (left +X, right −X — taste). PinJoint quirks (anchors captured at creation; joint node position READ every tick; break measures the prop-local anchor vs the palm point) apply to the v5.0–5.2 PIN path diff --git a/HANDOFF.md b/HANDOFF.md index 577fa1c..402372f 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -39,7 +39,11 @@ TERMINOLOGY (owner-corrected — use precisely): `physics_spike` "SPIKE: overall PASS", `weld_spike` "WELD: …", `grab_spike` "GRAB: …", `body_spike` "BODY: …", `collapse_spike` "COLLAPSE: …", `arm_spike` "ARM: overall PASS", `sixdof_spike` "6DOF: overall PASS" (run FIRST with `weld_spike` whenever the addon binary changes), - `truebody_spike` "TRUEBODY: overall PASS" (no physics; plain `--headless`). All eight PASS 3× as of this handoff. Headless asset import: + `truebody_spike` "TRUEBODY: overall PASS" (no physics; plain `--headless`), `menu_spike` "MENU: overall PASS" + (in-VR menu input injection, no XR). All nine PASS as of this handoff. + **Fresh checkout / new worktree: run `godot-mono --headless --import` FIRST** (it may segfault at + exit — harmless) — without `.godot/extension_list.cfg` the Box3D extension silently doesn't load, + Godot falls back to its default physics and weld/grab/arm/6dof spikes report CHECK. Headless asset import: `godot-mono --headless --import`. - Engine logs: `~/.local/share/godot/app_userdata/VR Messs/logs/godot.log` — **read after user playtests instead of guessing**. @@ -64,8 +68,11 @@ TERMINOLOGY (owner-corrected — use precisely): Teleport: aim + trigger edge, fade to black 0.12 s in / 0.25 s out, near markers within `MarkerNearDistance` (1.2 m) hide fully (disc/beam/label) and are not targetable. Lasers via `UpdatePointer`: shown only when aiming at something interactive (marker or panel) AND beyond - `LaserMinDistance` (0.35 m). Also builds the stylus pointer tip per controller. `ReturnToSpawn()` - is public (panel Spawn knob calls it). + `LaserMinDistance` (0.35 m); for the menu the beam length comes from + `WorldPanel.TryGetLaserDistance` (beam only while that hand is in LASER mode — a hand touching the + menu shows no beam); the beam starts at the stylus tip (`LaserStart` 0.12 m) and ends at the hit. + Also builds the stylus pointer tip per controller. `ReturnToSpawn()` is public (menu "Return to + spawn" calls it). - **AnchorMoveMode.cs** — grab-world movement, ANCHOR semantics. One-hand grip moves the player OPPOSITE to hand motion (full 3D); both grips = anchor translation + in-place yaw turn around the head. Grip is suppressed while that hand prop-grabs (`PropGrabber.IsGrabbing`) or the panel is @@ -115,17 +122,55 @@ TERMINOLOGY (owner-corrected — use precisely): its controller beyond `PhysicsHands.SnapDistance` (2 m) → "grip dragged off" instant break (heavy props can drag the hand since PhysicsHands' snap is suspended while gripping). - **PropShop.cs** — builds table (StaticBody3D) + spawns prop table from `Specs[]` (ping pong 2.7 g - → anvil 50 kg), Label3D captions, CCD for < 0.1 kg, group "prop". `Respawn()` = Reset knob. -- **WorldPanel.cs** — prop-UI panel, PARENTED UNDER `XROrigin3D` (fixed in player's room; revert = - reparent to Main + fix node paths). Summoned/dismissed via `ax_button` (A/X; menu_button belongs - to the runtime). Knobs: Spawn (return to spawn + recall panel in front of head), Ping, Reset - (PropShop.Respawn), Close. Panel/knob bodies: **CollisionLayer 2, CollisionMask 0** (props pass - through); all CollisionShapes disabled while hidden (`SetShapesEnabled`, deferred). Pointer tip is - a 12 cm stylus cone; knobs hover-highlight and PRESS ON CONTACT (tip within 4.5 cm, 400 ms - cooldown) — physical buttons; trigger still presses laser-hovered knobs. Click = code-generated - `AudioStreamWav` PCM (no asset). Draggable direct or via laser at fixed ray distance, re-orients - toward head while gripped. `IsBlocking(controller)` (within 0.45 m) makes Locomotion defer - grab/teleport; `IsPanelCollider(node)` identifies interactive colliders for lasers. + → anvil 50 kg), Label3D captions, CCD for < 0.1 kg, group "prop". `Respawn()` = menu "Reset props". +- **WorldPanel.cs** — the in-VR MENU (2026-10-02; replaced the chunky 3D knob panel). XR glue only: + PARENTED UNDER `XROrigin3D` (fixed in player's room; revert = reparent to Main + fix node paths). + Summoned/dismissed via `ax_button` (A/X; menu_button belongs to the runtime), appears + `SummonDistance` 0.3 m in front of the summoning hand facing the head; grip-drag direct (hand + within `DirectGrabRadius` 0.45 m) or along the laser at fixed ray distance, re-orients toward the + head while gripped; "Return to spawn" recalls it `HeadRecallDistance` 0.45 m ahead / 0.12 m below + the head. Feeds both controllers (ray = `-Basis.Z`, `trigger`, `primary` stick; a hand dragging + the panel doesn't point) into MenuScreen every frame, wires MainMenu events (deferred) to + `Locomotion.ReturnToSpawn`, `PropShop.Respawn`, ping tone, close; synthesized click + ping + (`AudioStreamWav` PCM, no asset), haptic tick (`"haptic"` action) on press. `IsBlocking(controller)` + (hand within 0.45 m of the shown panel) makes Locomotion/AnchorMoveMode defer grab/teleport; + `IsPanelCollider(node)` = the screen's StaticBody (**layer 2, mask 0**, props pass through; shapes + disabled while hidden); `TryGetLaserDistance(controller)` drives the laser beam. +- **MenuScreen.cs** — generic "Control UI on a 3D panel" + the touch-first hybrid cursor, + input-agnostic (spike drives it without XR). SubViewport 960×640 (`Disable3D`, MSAA 2D 4×, + update Always while shown / Disabled while hidden) on an explicit-UV quad 0.36×0.24 m (unshaded, + linear filter — viewport textures have no mipmaps, resolution chosen ≈ 1:1 with headset pixels at + ~45 cm) + backing frame + StaticBody. Per hand, in panel-local space (analytic plane math, no + raycast): TOUCH if the stylus tip (`TipLength` 0.12 m ahead) is over the panel (+1.5 cm edge + margin) between `HoverDepth` 6 cm in front and `PushThroughDepth` 8 cm behind, controller in + front — press when the tip crosses `ContactDepth` 0.6 cm FROM ABOVE (armed only by hovering in + front: sideways/behind entry never presses), release past `ReleaseDepth` 1.8 cm; else LASER if + the ray hits the panel from the front — trigger rising edge presses, release on trigger up + (trigger state tracked even while hidden/reset, so a held trigger never clicks). One mouse per + viewport → one OWNER hand: the hand holding a press keeps it; else the touching hand (smallest + depth); else the previous laser owner; else any laser hand. Owner change / leaving the panel + releases the button at the last position (Godot then cancels the click, like dragging off a + button). Injection: `Viewport.PushInput(InputEventMouseMotion{Position, Relative, ButtonMask}, + true)` on cursor move, `InputEventMouseButton{Left}` on press/release, `NotifyMouseEntered/Exited` + 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. +- **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), + **World** (Reset props, Ping sound), **System** (click volume slider, Close menu, scrollable log). + **Calibration contract**: "Recalibrate body" calls `Call("Recalibrate")` on + `GetTree().GetFirstNodeInGroup("calibration")` if it `HasMethod("Recalibrate")`; otherwise the + button is disabled with "Not available" (re-checked on summon and at 4 Hz). The menu never creates + that node — `Calibration.cs` (main.tscn) provides it. Button actions are C# events invoked via `CallDeferred` (never from inside the + injected input event). `Log(line)` appends to the System log (last 40). +- **MenuSpike.cs** + `scenes/menu_spike.tscn` — headless menu regression without XR: MenuScreen + + MainMenu on a tilted panel; asserts pixel mapping round trip, touch path (hover, contact press, + 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 + 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. - **PhysicsTorso.cs** — the player's PHYSICAL CHEST (body-physics Stage 1). Implemented @@ -253,7 +298,7 @@ plan-B if the patched-wrapper path is ever abandoned (owner pointed at it; adopt leaving the standard RigidBody3D stack). Scene wiring (`scenes/main.tscn`): Main → WorldEnvironment, Sun, Floor, XROrigin3D (XRCamera3D, -LeftHand, RightHand, WorldPanel), SpawnPoint, 3 TeleportMarkers, Locomotion, AnchorMoveMode, +LeftHand, RightHand, WorldPanel — builds its MenuScreen/SubViewport/MainMenu children in code), SpawnPoint, 3 TeleportMarkers, Locomotion, AnchorMoveMode, PropShop, PropGrabber, PhysicsHands, PhysicsTorso, PhysicsArms, BodyRig, FadeLayer/Fade (physics order matters: hands → chest → arms, so the arm IK reads this tick's bodies). Action map: `openxr_action_map.tres`. Floor StaticBody mask 1|3|4|5 (collision_mask 29). @@ -280,7 +325,7 @@ Floor StaticBody mask 1|3|4|5 (collision_mask 29). patch, and rebuild recipe). Pin/hinge/slider paths untouched — grab v5.2.1 remains runnable as fallback (git history). Stink-O's box3d-godot (custom components, more joint types) is the plan-B. -- **Collision layers**: 1 = world, 2 = UI. Panel/knob bodies are layer 2 mask 0 (props pass through +- **Collision layers**: 1 = world, 2 = UI. The menu panel body is layer 2 mask 0 (props pass through the menu); **1 = world+props, 2 = UI, 3 = player body (chest), 4 = player hands, 5 = player arms** — no self-collision between any own body parts (props/table/floor mask 1|3|4|5, hands 4/1|4, chest 3/1|3, arm segments 5/1). Layers/masks live on `CollisionObject3D`, never `CollisionShape3D`. The @@ -344,8 +389,15 @@ Floor StaticBody mask 1|3|4|5 (collision_mask 29). about the locomotion decision; don't over-invest in anchor-loco until that's decided. - **Teleport + lasers are BROKEN** — owner report; both were experiments anyway and are NOT current issues. Locomotion.cs's marker/laser machinery may be resurrected or deleted - when the locomotion direction is decided; WorldPanel's laser-hover knobs still reference - the laser system, so a deletion needs that decoupling first. + when the locomotion direction is decided. The menu's laser cursor itself lives in MenuScreen + (analytic, no raycast); only the drawn BEAM is in `Locomotion.UpdatePointer` — move the beam + drawing along if that file is deleted. +- **In-VR menu (MenuScreen/MainMenu, 2026-10-02) — awaiting owner playtest.** Things to check + 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, + 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 class of solutions). Anchor-grab locomotion was a scene-viewer-era design; the fix-or- @@ -422,8 +474,15 @@ Floor StaticBody mask 1|3|4|5 (collision_mask 29). | `LaserMinDistance` | Locomotion.cs | 0.35 m | laser hidden nearer than this (tip touch instead) | | `MarkerNearDistance` | Locomotion.cs | 1.2 m | near markers hide + become untargetable | | Fade times | Locomotion.cs | 0.12 s in / 0.25 s out | teleport fade | -| `PressContactDistance` / `PressCooldownMs` | WorldPanel.cs | 0.045 m / 400 ms | knob press on contact | -| `DirectGrabRadius` / `TipHoverRadius` / `TipLength` | WorldPanel.cs | 0.45 / 0.07 / 0.12 m | panel grab / tip hover / stylus reach | +| `LaserStart` | Locomotion.cs | 0.12 m | beam starts at the stylus tip; beam length = hit distance − this | +| `DirectGrabRadius` | WorldPanel.cs | 0.45 m | direct grip-drag range + `IsBlocking` range | +| `SummonDistance` / `HeadRecallDistance` / `HeadRecallDrop` | WorldPanel.cs | 0.3 / 0.45 / 0.12 m | menu placement on summon (ahead of hand) / on Return to spawn (ahead of + below head) | +| `SizeMeters` / `SizePixels` | MenuScreen.cs | 0.36×0.24 m / 960×640 | menu panel size / SubViewport resolution (~2.7 px/mm) | +| `TipLength` | MenuScreen.cs | 0.12 m | stylus tip ahead of the controller = touch cursor | +| `HoverDepth` / `PushThroughDepth` / `TouchEdgeMargin` | MenuScreen.cs | 0.06 / 0.08 / 0.015 m | touch-cursor zone in front of / behind / around the panel | +| `ContactDepth` / `ReleaseDepth` | MenuScreen.cs | 0.006 / 0.018 m | touch press / release hysteresis | +| `ScrollDeadzone` / `ScrollNotchesPerSecond` | MenuScreen.cs | 0.3 / 14 | thumbstick → wheel scrolling | +| `FontSize` / `ButtonHeight` | MainMenu.cs | 26 px / 64 px | menu text and button size | | `MaximumRefreshRate` | Main.cs | 90 | physics tick = chosen refresh rate | ## 7. Next steps (in order) @@ -437,8 +496,8 @@ Floor StaticBody mask 1|3|4|5 (collision_mask 29). 2-anchor based), simultaneous two-hand movement acts weird, teleport + lasers broken (experiments, not current issues). Owner's candidate replacement: physics thumbstick movement if the body track lands. Fix-or-replace rides on item 1's outcome; teleport/ - laser machinery gets resurrected-or-deleted with it (WorldPanel's laser-hover knobs - must decouple first if deleted). + laser machinery gets resurrected-or-deleted with it (the menu's laser cursor is + self-contained in MenuScreen; only its drawn beam lives in `Locomotion.UpdatePointer`). 3. **Grab points on special objects** — grab is palm-proximity to the prop origin today; special objects may want authored grab anchors (see `research/vr-grabbing.md` taxonomy). 4. **Body Stage 2b (feel-driven)** — after the owner feels the 2a arms: couple obstruction @@ -449,8 +508,9 @@ Floor StaticBody mask 1|3|4|5 (collision_mask 29). 6. **Neck/head look system** — `LookAtModifier3D` head→neck distribution (needs a real skeleton). 7. **EMBODYING** — view-follow + avatar remapping (chest calibration transform); experiments only after head/neck look override exists. Theory until proven. -8. **UI milestone** — deliberate full action-map pass + SubViewport/composition-layer decision for - dense screens (at most ONE such screen; everything else stays props). +8. **UI milestone** — the ONE SubViewport screen now exists (in-VR menu, `MenuScreen`); remaining: + owner playtest of it, deliberate full action-map pass, fingertip (hand tracking) feeding the same + `MenuScreen.PointerInput` as the stylus, `SubViewportCompositionLayer` only if sharpness demands. ## 8. Working agreements diff --git a/scenes/menu_spike.tscn b/scenes/menu_spike.tscn new file mode 100644 index 0000000..69d818f --- /dev/null +++ b/scenes/menu_spike.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://scripts/MenuSpike.cs" id="1_spike"] + +[node name="MenuSpike" type="Node3D"] +script = ExtResource("1_spike") diff --git a/scripts/Locomotion.cs b/scripts/Locomotion.cs index 3cab08c..02e6041 100644 --- a/scripts/Locomotion.cs +++ b/scripts/Locomotion.cs @@ -7,6 +7,7 @@ public partial class Locomotion : Node private const float RayLength = 50f; private const float LaserMinDistance = 0.35f; private const float MarkerNearDistance = 1.2f; + private const float LaserStart = 0.12f; private XROrigin3D _origin; private XRCamera3D _camera; @@ -78,7 +79,7 @@ public partial class Locomotion : Node Node3D pivot = new() { Visible = false }; pivot.AddChild(beam); Basis rot = Basis.FromEuler(new Vector3(-Mathf.Pi / 2f, 0, 0)); - pivot.Transform = new Transform3D(rot, new Vector3(0, 0, -0.12f)); + pivot.Transform = new Transform3D(rot, new Vector3(0, 0, -LaserStart)); controller.AddChild(pivot); return pivot; } @@ -127,13 +128,19 @@ public partial class Locomotion : Node bool show = false; float dist = 0f; - if (hit.TryGetValue("collider", out Variant collider) && collider.As() is Node node) + if (WorldPanel.TryGetLaserDistance(controller, out float panelDist)) + { + // The menu decides touch vs laser itself (tip near the surface = touch, no beam). + dist = panelDist; + show = dist > LaserMinDistance; + } + else if (hit.TryGetValue("collider", out Variant collider) && collider.As() is Node node) { bool interactive; if (node.GetParent() is TeleportMarker marker) interactive = !marker.IsNear; else - interactive = WorldPanel.IsPanelCollider(node); + interactive = false; // panel hit but the menu isn't in laser mode for this hand if (interactive && hit.TryGetValue("position", out Variant posVariant)) { dist = from.DistanceTo(posVariant.As()); @@ -142,8 +149,8 @@ public partial class Locomotion : Node } laser.Visible = show; - if (show) - laser.Scale = new Vector3(1, dist, 1); + if (show) // beam starts at the stylus tip (LaserStart ahead of the controller) + laser.Scale = new Vector3(1, Mathf.Max(0.01f, dist - LaserStart), 1); } private void CheckTeleport(XRController3D controller, ref bool wasPressed) diff --git a/scripts/MainMenu.cs b/scripts/MainMenu.cs new file mode 100644 index 0000000..c201426 --- /dev/null +++ b/scripts/MainMenu.cs @@ -0,0 +1,288 @@ +using Godot; +using System; + +// The in-VR menu content: plain Godot Controls, rendered by MenuScreen into its SubViewport. +// Built in code (no .tscn) so the spike can instantiate it without XR. Actions are C# events; +// WorldPanel wires them to the game. Sized for a 960x640 viewport on a 0.36x0.24 m panel +// (~2.7 px/mm): 26 px body text ≈ 1.2° at 45 cm, 64 px buttons ≈ 24 mm touch targets. +public partial class MainMenu : PanelContainer +{ + public const string CalibrationGroup = "calibration"; + + private const int FontSize = 26; + private const int ButtonHeight = 64; + private const int MaxLogLines = 40; + + public event Action ReturnToSpawnRequested; + public event Action ResetPropsRequested; + public event Action PingRequested; + public event Action CloseRequested; + public event Action Clicked; + + public TabContainer Tabs { get; private set; } + public Button ReturnToSpawnButton { get; private set; } + public Button RecalibrateButton { get; private set; } + public Button ResetPropsButton { get; private set; } + public Button PingButton { get; private set; } + public Button CloseButton { get; private set; } + public Button HeaderCloseButton { get; private set; } + public HSlider ClickVolumeSlider { get; private set; } + public ScrollContainer LogScroll { get; private set; } + + private Label _fpsLabel; + private Label _calibrationStatus; + private VBoxContainer _logLines; + private double _refreshTimer; + + public MainMenu() + { + Theme = BuildTheme(); + BuildLayout(); + } + + public override void _Ready() => RefreshCalibration(); + + public override void _Process(double delta) + { + _refreshTimer -= delta; + if (_refreshTimer > 0) + return; + _refreshTimer = 0.25; + _fpsLabel.Text = $"{Engine.GetFramesPerSecond():0} fps"; + RefreshCalibration(); + } + + // Contract with the body-calibration work: a node in group "calibration" exposing + // Recalibrate(). Absent → button disabled. We never create that node here. + public Node FindCalibration() + { + if (!IsInsideTree()) + return null; + Node node = GetTree().GetFirstNodeInGroup(CalibrationGroup); + return node != null && node.HasMethod("Recalibrate") ? node : null; + } + + public void RefreshCalibration() + { + bool available = FindCalibration() != null; + RecalibrateButton.Disabled = !available; + _calibrationStatus.Text = available ? "" : "Not available"; + } + + public void Log(string line) + { + _logLines.AddChild(new Label { Text = line, AutowrapMode = TextServer.AutowrapMode.WordSmart }); + while (_logLines.GetChildCount() > MaxLogLines) + { + Node oldest = _logLines.GetChild(0); + _logLines.RemoveChild(oldest); + oldest.QueueFree(); + } + } + + private void BuildLayout() + { + VBoxContainer root = new(); + root.AddThemeConstantOverride("separation", 10); + AddChild(root); + + HBoxContainer header = new(); + header.AddThemeConstantOverride("separation", 16); + root.AddChild(header); + Label title = new() { Text = "VR Messs", SizeFlagsHorizontal = SizeFlags.ExpandFill, VerticalAlignment = VerticalAlignment.Center }; + title.AddThemeFontSizeOverride("font_size", 30); + header.AddChild(title); + _fpsLabel = new Label { Text = "-- fps", VerticalAlignment = VerticalAlignment.Center }; + _fpsLabel.AddThemeColorOverride("font_color", new Color(0.6f, 0.68f, 0.8f)); + header.AddChild(_fpsLabel); + HeaderCloseButton = MakeButton("Close", () => CloseRequested?.Invoke()); + HeaderCloseButton.CustomMinimumSize = new Vector2(120, 52); + header.AddChild(HeaderCloseButton); + + Tabs = new TabContainer { SizeFlagsVertical = SizeFlags.ExpandFill, TabFocusMode = FocusModeEnum.None }; + Tabs.TabChanged += _ => Clicked?.Invoke(); + root.AddChild(Tabs); + + // Player + VBoxContainer player = MakePage("Player"); + ReturnToSpawnButton = MakeButton("Return to spawn", () => ReturnToSpawnRequested?.Invoke()); + player.AddChild(ReturnToSpawnButton); + HBoxContainer calibRow = new(); + calibRow.AddThemeConstantOverride("separation", 16); + RecalibrateButton = MakeButton("Recalibrate body", Recalibrate); + RecalibrateButton.SizeFlagsHorizontal = SizeFlags.ExpandFill; + calibRow.AddChild(RecalibrateButton); + _calibrationStatus = new Label { VerticalAlignment = VerticalAlignment.Center }; + _calibrationStatus.AddThemeColorOverride("font_color", new Color(0.6f, 0.6f, 0.65f)); + calibRow.AddChild(_calibrationStatus); + player.AddChild(calibRow); + Tabs.AddChild(player); + + // World + VBoxContainer world = MakePage("World"); + ResetPropsButton = MakeButton("Reset props", () => ResetPropsRequested?.Invoke()); + world.AddChild(ResetPropsButton); + PingButton = MakeButton("Ping sound", () => PingRequested?.Invoke()); + world.AddChild(PingButton); + Tabs.AddChild(world); + + // System + VBoxContainer system = MakePage("System"); + HBoxContainer volumeRow = new(); + volumeRow.AddThemeConstantOverride("separation", 20); + volumeRow.AddChild(new Label { Text = "Click volume", VerticalAlignment = VerticalAlignment.Center, CustomMinimumSize = new Vector2(200, 0) }); + ClickVolumeSlider = new HSlider + { + MinValue = 0, + MaxValue = 100, + Step = 1, + Value = 70, + SizeFlagsHorizontal = SizeFlags.ExpandFill, + SizeFlagsVertical = SizeFlags.ShrinkCenter, + CustomMinimumSize = new Vector2(0, 48), + FocusMode = FocusModeEnum.None, + }; + volumeRow.AddChild(ClickVolumeSlider); + system.AddChild(volumeRow); + CloseButton = MakeButton("Close menu", () => CloseRequested?.Invoke()); + system.AddChild(CloseButton); + Label logTitle = new() { Text = "Log (thumbstick scrolls)" }; + logTitle.AddThemeColorOverride("font_color", new Color(0.6f, 0.68f, 0.8f)); + system.AddChild(logTitle); + LogScroll = new ScrollContainer + { + SizeFlagsVertical = SizeFlags.ExpandFill, + HorizontalScrollMode = ScrollContainer.ScrollMode.Disabled, + }; + _logLines = new VBoxContainer { SizeFlagsHorizontal = SizeFlags.ExpandFill }; + _logLines.AddThemeConstantOverride("separation", 2); + LogScroll.AddChild(_logLines); + system.AddChild(LogScroll); + Tabs.AddChild(system); + } + + private void Recalibrate() + { + Node calibration = FindCalibration(); + if (calibration == null) + { + RefreshCalibration(); + return; + } + GD.Print("MainMenu: Recalibrate body → calibration.Recalibrate()"); + calibration.Call("Recalibrate"); + } + + private static VBoxContainer MakePage(string title) + { + VBoxContainer page = new() { Name = title }; + page.AddThemeConstantOverride("separation", 14); + return page; + } + + private Button MakeButton(string text, Action action) + { + Button button = new() + { + Text = text, + CustomMinimumSize = new Vector2(0, ButtonHeight), + FocusMode = FocusModeEnum.None, + }; + button.Pressed += () => + { + Clicked?.Invoke(); + // Deferred: actions may hide/move the panel; never do that from inside the + // input event MenuScreen is injecting. + Callable.From(action).CallDeferred(); + }; + return button; + } + + private static StyleBoxFlat Box(Color bg, int radius, float marginX, float marginY, Color? border = null, int borderWidth = 0) + { + StyleBoxFlat box = new() { BgColor = bg }; + box.SetCornerRadiusAll(radius); + box.ContentMarginLeft = marginX; + box.ContentMarginRight = marginX; + box.ContentMarginTop = marginY; + box.ContentMarginBottom = marginY; + if (border is Color c) + { + box.BorderColor = c; + box.SetBorderWidthAll(borderWidth); + } + return box; + } + + private static Theme BuildTheme() + { + Color accent = new(0.35f, 0.6f, 0.95f); + Theme theme = new() { DefaultFontSize = FontSize }; + + theme.SetStylebox("panel", "PanelContainer", Box(new Color(0.07f, 0.08f, 0.11f), 0, 18, 14)); + + theme.SetStylebox("normal", "Button", Box(new Color(0.17f, 0.2f, 0.27f), 10, 20, 8)); + theme.SetStylebox("hover", "Button", Box(new Color(0.24f, 0.31f, 0.44f), 10, 20, 8, accent, 3)); + theme.SetStylebox("pressed", "Button", Box(new Color(0.33f, 0.5f, 0.78f), 10, 20, 8, new Color(0.7f, 0.85f, 1f), 3)); + theme.SetStylebox("hover_pressed", "Button", Box(new Color(0.33f, 0.5f, 0.78f), 10, 20, 8, new Color(0.7f, 0.85f, 1f), 3)); + theme.SetStylebox("disabled", "Button", Box(new Color(0.11f, 0.12f, 0.15f), 10, 20, 8)); + theme.SetStylebox("focus", "Button", new StyleBoxEmpty()); + theme.SetColor("font_color", "Button", new Color(0.92f, 0.94f, 0.98f)); + theme.SetColor("font_hover_color", "Button", Colors.White); + theme.SetColor("font_pressed_color", "Button", Colors.White); + theme.SetColor("font_disabled_color", "Button", new Color(0.45f, 0.47f, 0.52f)); + + theme.SetStylebox("panel", "TabContainer", Box(new Color(0.1f, 0.12f, 0.16f), 10, 18, 16)); + StyleBoxFlat tabSelected = Box(new Color(0.1f, 0.12f, 0.16f), 0, 28, 12); + tabSelected.CornerRadiusTopLeft = 10; + tabSelected.CornerRadiusTopRight = 10; + tabSelected.BorderColor = accent; + tabSelected.BorderWidthTop = 4; + theme.SetStylebox("tab_selected", "TabContainer", tabSelected); + StyleBoxFlat tabUnselected = Box(new Color(0.13f, 0.15f, 0.2f), 0, 28, 12); + tabUnselected.CornerRadiusTopLeft = 10; + tabUnselected.CornerRadiusTopRight = 10; + theme.SetStylebox("tab_unselected", "TabContainer", tabUnselected); + StyleBoxFlat tabHovered = Box(new Color(0.2f, 0.26f, 0.37f), 0, 28, 12); + tabHovered.CornerRadiusTopLeft = 10; + tabHovered.CornerRadiusTopRight = 10; + theme.SetStylebox("tab_hovered", "TabContainer", tabHovered); + theme.SetStylebox("tab_focus", "TabContainer", new StyleBoxEmpty()); + theme.SetConstant("tab_separation", "TabContainer", 6); + theme.SetFontSize("font_size", "TabContainer", FontSize); + + // Slider: thick track, big round grabber (default icons are ~16 px — too small in VR). + StyleBoxFlat track = Box(new Color(0.17f, 0.2f, 0.27f), 6, 0, 6); + theme.SetStylebox("slider", "HSlider", track); + theme.SetStylebox("grabber_area", "HSlider", Box(accent, 6, 0, 6)); + theme.SetStylebox("grabber_area_highlight", "HSlider", Box(accent.Lightened(0.2f), 6, 0, 6)); + theme.SetIcon("grabber", "HSlider", Circle(36, new Color(0.9f, 0.94f, 1f))); + theme.SetIcon("grabber_highlight", "HSlider", Circle(36, Colors.White)); + 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.SetColor("font_color", "Label", new Color(0.88f, 0.9f, 0.95f)); + return theme; + } + + private static ImageTexture Circle(int size, Color color) + { + Image image = Image.CreateEmpty(size, size, false, Image.Format.Rgba8); + float r = size * 0.5f; + for (int y = 0; y < size; y++) + { + for (int x = 0; x < size; x++) + { + float d = new Vector2(x + 0.5f - r, y + 0.5f - r).Length(); + float a = Mathf.Clamp(r - d, 0f, 1f); // 1 px anti-aliased edge + image.SetPixel(x, y, new Color(color, a)); + } + } + return ImageTexture.CreateFromImage(image); + } +} diff --git a/scripts/MainMenu.cs.uid b/scripts/MainMenu.cs.uid new file mode 100644 index 0000000..e5dd86e --- /dev/null +++ b/scripts/MainMenu.cs.uid @@ -0,0 +1 @@ +uid://dv7mp0go4oxof diff --git a/scripts/MenuScreen.cs b/scripts/MenuScreen.cs new file mode 100644 index 0000000..3a2e967 --- /dev/null +++ b/scripts/MenuScreen.cs @@ -0,0 +1,467 @@ +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. +public partial class MenuScreen : Node3D +{ + public const float TipLength = 0.12f; + 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 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 PointerPressed; + + 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; + 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 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) + SendButton(_lastSent, false); + _down = false; + _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) + { + SendButton(_lastSent, false); + _down = 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; + if (h.WantDown != _down) + { + _down = h.WantDown; + SendButton(h.Pixel, _down); + if (_down) + PointerPressed?.Invoke(current, h.Mode); + } + // 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); + } + + UpdateDots(); + } + + 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); + } + } + + 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 ? 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 ? 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; + } + } +} diff --git a/scripts/MenuScreen.cs.uid b/scripts/MenuScreen.cs.uid new file mode 100644 index 0000000..f9e1fec --- /dev/null +++ b/scripts/MenuScreen.cs.uid @@ -0,0 +1 @@ +uid://cnjnswvcuecyf diff --git a/scripts/MenuSpike.cs b/scripts/MenuSpike.cs new file mode 100644 index 0000000..803075f --- /dev/null +++ b/scripts/MenuSpike.cs @@ -0,0 +1,258 @@ +using Godot; +using System; +using System.Threading.Tasks; + +// Headless regression for the in-VR menu (MenuScreen + MainMenu), no XR: drives simulated +// controller rays at known controls and asserts the Godot UI reacted. Run: +// godot-mono --headless --fixed-fps 90 --path . scenes/menu_spike.tscn +// Must print "MENU: overall PASS". +public partial class MenuSpike : Node3D +{ + private partial class CalibrationStub : Node + { + public int Calls; + public void Recalibrate() => Calls++; + } + + private MenuScreen _screen; + private MainMenu _menu; + private readonly MenuScreen.PointerInput[] _inputs = new MenuScreen.PointerInput[2]; + private int _spawn, _reset, _ping, _close, _clicks, _pressedEvents; + private bool _allPass = true; + + public override void _Ready() + { + _screen = new MenuScreen(); + AddChild(_screen); + // Arbitrary, non-axis-aligned pose to exercise the plane math. + _screen.GlobalTransform = new Transform3D( + Basis.FromEuler(new Vector3(Mathf.DegToRad(-15f), Mathf.DegToRad(30f), 0f)), + new Vector3(0.3f, 1.3f, -0.5f)); + _menu = new MainMenu(); + _screen.SetContent(_menu); + _screen.SetActive(true); + _screen.PointerPressed += (_, _) => _pressedEvents++; + _menu.ReturnToSpawnRequested += () => _spawn++; + _menu.ResetPropsRequested += () => _reset++; + _menu.PingRequested += () => _ping++; + _menu.CloseRequested += () => _close++; + _menu.Clicked += () => _clicks++; + GetTree().CreateTimer(30.0).Timeout += () => + { + GD.Print("MENU: timeout — overall FAIL"); + GetTree().Quit(1); + }; + Run(); + } + + private void Check(string name, bool ok, string detail = "") + { + GD.Print($"MENU: {name} {detail} → {(ok ? "PASS" : "FAIL")}"); + _allPass &= ok; + } + + private async Task Frame(int count = 1) + { + for (int i = 0; i < count; i++) + { + _screen.UpdatePointers(_inputs, 1.0 / 90.0); + await ToSignal(GetTree(), SceneTree.SignalName.ProcessFrame); + } + } + + private Vector2 Center(Control c) => c.GetGlobalRect().GetCenter(); + private Vector3 Normal => _screen.GlobalBasis.Z.Normalized(); + + private void SetHand(int hand, Vector3 origin, Vector3 dir, bool trigger = false, Vector2 stick = default) => + _inputs[hand] = new MenuScreen.PointerInput { Valid = true, Origin = origin, Direction = dir.Normalized(), Trigger = trigger, Stick = stick }; + + private void ClearHand(int hand) => _inputs[hand] = default; + + // Stylus straight at the panel: tip depth d in front of the pixel (negative = pushed through). + private void TipAt(int hand, Vector2 pixel, float depth) + { + Vector3 p = _screen.PixelToGlobal(pixel); + SetHand(hand, p + Normal * (MenuScreen.TipLength + depth), -Normal); + } + + private async Task TouchPress(int hand, Vector2 pixel) + { + for (float d = 0.10f; d > -0.012f; d -= 0.004f) + { + TipAt(hand, pixel, d); + await Frame(); + } + for (float d = -0.012f; d < 0.08f; d += 0.006f) + { + TipAt(hand, pixel, 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) + { + Vector3 p = _screen.PixelToGlobal(pixel); + Vector3 origin = p + Normal * 0.7f + _screen.GlobalBasis.X * 0.25f + Vector3.Up * 0.2f; + SetHand(hand, origin, p - origin, trigger, stick); + } + + private async Task LaserClick(int hand, Vector2 pixel) + { + LaserAt(hand, pixel, false); + await Frame(4); + LaserAt(hand, pixel, true); + await Frame(4); + LaserAt(hand, pixel, false); + await Frame(4); + ClearHand(hand); + await Frame(3); + } + + private Vector2 TabCenter(int index) + { + TabBar bar = _menu.Tabs.GetTabBar(); + Rect2 r = bar.GetTabRect(index); + return bar.GetGlobalRect().Position + r.GetCenter(); + } + + private async void Run() + { + await Frame(10); // layout + + // Mapping sanity: pixel → world → local → pixel round trip. + Vector2 probe = new(123f, 456f); + Vector2 back = _screen.LocalToPixel(_screen.GlobalTransform.AffineInverse() * _screen.PixelToGlobal(probe)); + Check("pixel mapping round trip", back.DistanceTo(probe) < 0.01f, $"({back})"); + + // 1) Touch path, with the other hand idling as a laser on the panel (arbitration: + // touch beats laser). + Vector2 spawnPx = Center(_menu.ReturnToSpawnButton); + LaserAt(0, new Vector2(800, 600), false); + await Frame(3); + Check("idle laser is laser mode", _screen.GetMode(0) == MenuScreen.PointerMode.Laser); + bool sawHover = false, sawTouch = false; + for (float d = 0.10f; d > -0.012f; d -= 0.004f) + { + TipAt(1, spawnPx, d); + await Frame(); + sawTouch |= _screen.GetMode(1) == MenuScreen.PointerMode.Touch; + sawHover |= _menu.ReturnToSpawnButton.IsHovered(); + } + bool pressedWhileIn = _screen.IsPressing(1); + for (float d = -0.012f; d < 0.08f; d += 0.006f) + { + TipAt(1, spawnPx, d); + await Frame(); + } + ClearHand(0); + ClearHand(1); + await Frame(3); + Check("touch: tip mode + hover", sawTouch && sawHover, $"(touch={sawTouch}, hover={sawHover})"); + Check("touch: contact holds press", pressedWhileIn); + Check("touch: Return to spawn fired once", _spawn == 1, $"(count={_spawn}, pressedEvents={_pressedEvents})"); + + // 2) Tip sweeping in sideways already behind the surface must NOT press. + int before = _spawn; + Vector2 offPanel = new(-60f, spawnPx.Y); + for (int i = 0; i <= 30; i++) + { + Vector2 px = offPanel.Lerp(spawnPx, i / 30f); + Vector3 p = _screen.GlobalTransform * new Vector3( + (px.X / _screen.SizePixels.X - 0.5f) * _screen.SizeMeters.X, + (0.5f - px.Y / _screen.SizePixels.Y) * _screen.SizeMeters.Y, 0f); + SetHand(1, p + Normal * (MenuScreen.TipLength - 0.01f), -Normal); + await Frame(); + } + TipAt(1, spawnPx, 0.08f); + await Frame(3); + ClearHand(1); + await Frame(3); + Check("touch: side entry behind surface does not press", _spawn == before, $"(count={_spawn})"); + + // 3) Laser + trigger: switch to the World tab, then Reset props. + await LaserClick(0, TabCenter(1)); + Check("laser: World tab selected", _menu.Tabs.CurrentTab == 1, $"(tab={_menu.Tabs.CurrentTab})"); + await Frame(2); + Vector2 resetPx = Center(_menu.ResetPropsButton); + LaserAt(0, resetPx, false); + await Frame(2); + bool laserDist = _screen.TryGetLaserDistance(0, out float dist) && dist > 0.5f && dist < 1.0f; + Check("laser: distance reported", laserDist, $"({dist:F3} m)"); + await LaserClick(0, resetPx); + Check("laser: Reset props fired once", _reset == 1, $"(count={_reset})"); + + // 4) Trigger already held when the laser arrives must not click. + LaserAt(0, new Vector2(-200, -200), true); // off panel + await Frame(3); + LaserAt(0, Center(_menu.PingButton), true); + await Frame(5); + LaserAt(0, Center(_menu.PingButton), false); + await Frame(3); + ClearHand(0); + await Frame(2); + Check("laser: held trigger arriving does not click", _ping == 0, $"(count={_ping})"); + await LaserClick(0, Center(_menu.PingButton)); + Check("laser: Ping fired once", _ping == 1, $"(count={_ping})"); + + // 5) Touch a tab, then drag the slider with the laser (trigger held). + await TouchPress(1, TabCenter(2)); + Check("touch: System tab selected", _menu.Tabs.CurrentTab == 2, $"(tab={_menu.Tabs.CurrentTab})"); + await Frame(2); + Rect2 sr = _menu.ClickVolumeSlider.GetGlobalRect(); + double v0 = _menu.ClickVolumeSlider.Value; + Vector2 grab = new(sr.Position.X + sr.Size.X * (float)(v0 / 100.0), sr.GetCenter().Y); + LaserAt(0, grab, false); + await Frame(3); + LaserAt(0, grab, true); + await Frame(3); + for (int i = 1; i <= 20; i++) + { + LaserAt(0, grab.Lerp(new Vector2(sr.Position.X + 4f, grab.Y), i / 20f), true); + await Frame(); + } + LaserAt(0, new Vector2(sr.Position.X + 4f, grab.Y), false); + await Frame(3); + ClearHand(0); + await Frame(2); + double v1 = _menu.ClickVolumeSlider.Value; + Check("laser: slider drag", v1 < v0 - 30, $"({v0} → {v1})"); + + // 6) Thumbstick scrolls the log while hovering it. + for (int i = 0; i < 60; i++) + _menu.Log($"log line {i}"); + await Frame(5); + Vector2 logPx = Center(_menu.LogScroll); + LaserAt(0, logPx, false, new Vector2(0, -1)); + await Frame(30); + int scrolled = _menu.LogScroll.ScrollVertical; + LaserAt(0, logPx, false, new Vector2(0, 1)); + await Frame(60); + int scrolledBack = _menu.LogScroll.ScrollVertical; + ClearHand(0); + await Frame(2); + Check("stick: scroll down then up", scrolled > 50 && scrolledBack < scrolled, $"({scrolled} → {scrolledBack})"); + + // 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); + await TouchPress(1, Center(_menu.RecalibrateButton)); + CalibrationStub stub = new(); + AddChild(stub); + stub.AddToGroup(MainMenu.CalibrationGroup); + await Frame(40); // refresh runs at 4 Hz + Check("Recalibrate enabled with group node", !_menu.RecalibrateButton.Disabled); + await TouchPress(1, Center(_menu.RecalibrateButton)); + Check("touch: Recalibrate called once", stub.Calls == 1, $"(calls={stub.Calls})"); + + // 8) Header Close via touch. + await TouchPress(1, Center(_menu.HeaderCloseButton)); + Check("touch: Close fired once", _close == 1, $"(count={_close})"); + Check("click feedback fired", _clicks >= 8, $"(clicks={_clicks})"); + + GD.Print($"MENU: overall {(_allPass ? "PASS" : "FAIL")}"); + GetTree().Quit(_allPass ? 0 : 1); + } +} diff --git a/scripts/MenuSpike.cs.uid b/scripts/MenuSpike.cs.uid new file mode 100644 index 0000000..4867a17 --- /dev/null +++ b/scripts/MenuSpike.cs.uid @@ -0,0 +1 @@ +uid://bybf7naa4ron3 diff --git a/scripts/WorldPanel.cs b/scripts/WorldPanel.cs index 5af8dcb..1b22271 100644 --- a/scripts/WorldPanel.cs +++ b/scripts/WorldPanel.cs @@ -1,16 +1,18 @@ using Godot; -using System.Collections.Generic; +// The in-VR menu: a MenuScreen (Godot Control UI in a SubViewport on a thin 0.36x0.24 m +// panel) showing MainMenu. This node is the XR glue: summon/dismiss with ax_button (A/X), +// grip-drag (direct or along the laser), re-orient toward the head, feed both controllers' +// pointer rays into the screen, click sound + haptics. PARENTED UNDER XROrigin3D (fixed in the +// player's room; game locomotion carries it). public partial class WorldPanel : Node3D { private const float GripThreshold = 0.6f; private const float TriggerThreshold = 0.6f; private const float DirectGrabRadius = 0.45f; - private const float TipHoverRadius = 0.07f; - private const float PressContactDistance = 0.045f; - private const ulong PressCooldownMs = 400; - private const float TipLength = 0.12f; - private const float RayLength = 50f; + private const float SummonDistance = 0.3f; + private const float HeadRecallDistance = 0.45f; + private const float HeadRecallDrop = 0.12f; private static WorldPanel _instance; @@ -20,29 +22,17 @@ public partial class WorldPanel : Node3D private Locomotion _locomotion; private PropShop _propShop; private AudioStreamPlayer3D _click; + private AudioStreamPlayer3D _ping; - private StaticBody3D _panelBody; - - private struct Knob - { - public string Name; - public Node3D Root; - public MeshInstance3D Mesh; - public StandardMaterial3D Material; - public Callable Action; - public ulong LastPressMs; - } - - private readonly List _knobs = new(); + private MenuScreen _screen; + private MainMenu _menu; private readonly bool[] _dragging = new bool[2]; private readonly bool[] _directDrag = new bool[2]; private readonly float[] _laserDist = new float[2]; private readonly Vector3[] _directOffset = new Vector3[2]; - private readonly bool[] _triggerPrev = new bool[2]; private readonly bool[] _summonPrev = new bool[2]; - private readonly int[] _hover = new int[2] { -1, -1 }; - private readonly bool[] _hoverPanel = new bool[2]; + private readonly MenuScreen.PointerInput[] _inputs = new MenuScreen.PointerInput[2]; public override void _Ready() { @@ -52,9 +42,38 @@ public partial class WorldPanel : Node3D _camera = GetNode("../XRCamera3D"); _locomotion = GetNode("../../Locomotion"); _propShop = GetNode("../../PropShop"); - Visible = false; - BuildPanel(); - BuildClickSound(); + + _screen = new MenuScreen { Name = "Screen" }; + AddChild(_screen); + _menu = new MainMenu { Name = "MainMenu" }; + _screen.SetContent(_menu); + _screen.PointerPressed += OnPointerPressed; + + _menu.ReturnToSpawnRequested += () => + { + _locomotion.ReturnToSpawn(); + ResetInteractionState(); + PlaceInFrontOfHead(); + _menu.Log("Returned to spawn"); + }; + _menu.ResetPropsRequested += () => + { + _propShop.Respawn(); + _menu.Log("Props reset"); + }; + _menu.PingRequested += () => + { + GD.Print("WorldPanel: ping"); + _ping.Play(); + _menu.Log("Ping"); + }; + _menu.CloseRequested += () => SetPanelActive(false); + _menu.Clicked += () => _click.Play(); + + _click = MakeTone(900f, 25, 0.35f, 1f); + _ping = MakeTone(1320f, 4, 0.3f, 3f); + _menu.ClickVolumeSlider.ValueChanged += v => _click.VolumeDb = Mathf.LinearToDb((float)v / 70f); + SetPanelActive(false); } @@ -64,16 +83,48 @@ public partial class WorldPanel : Node3D public static bool IsPanelCollider(Node node) => _instance != null && _instance.Visible && node != null && - (node.HasMeta("knob_index") || node == _instance._panelBody || node.GetParent() == _instance._panelBody); + (node == _instance._screen.Body || node.GetParent() == _instance._screen.Body); + + // Laser length to the menu for this controller, if the controller is currently using its + // laser (not touch) on the menu. Locomotion.UpdatePointer draws the beam from this. + public static bool TryGetLaserDistance(XRController3D controller, out float distance) + { + distance = 0f; + if (_instance == null || !_instance.Visible) + return false; + int hand = controller == _instance._left ? 0 : controller == _instance._right ? 1 : -1; + return hand >= 0 && _instance._screen.TryGetLaserDistance(hand, out distance); + } public override void _Process(double delta) { PollSummon(_left, 0); PollSummon(_right, 1); - if (!Visible) - return; - UpdateHand(_left, 0); - UpdateHand(_right, 1); + if (Visible) + { + UpdateDrag(_left, 0); + UpdateDrag(_right, 1); + } + _inputs[0] = ReadPointer(_left, 0); + _inputs[1] = ReadPointer(_right, 1); + _screen.UpdatePointers(_inputs, delta); + } + + private MenuScreen.PointerInput ReadPointer(XRController3D controller, int hand) => new() + { + // A hand dragging the panel doesn't also point at it. + Valid = !_dragging[hand] && controller.GetIsActive(), + Origin = controller.GlobalPosition, + Direction = -controller.GlobalTransform.Basis.Z, + Trigger = controller.GetFloat("trigger") > TriggerThreshold, + Stick = controller.GetVector2("primary"), + }; + + private void OnPointerPressed(int hand, MenuScreen.PointerMode mode) + { + XRController3D controller = hand == 0 ? _left : _right; + // Short tick on contact: the "physical press" feel for the touch cursor. + controller.TriggerHapticPulse("haptic", 0.0, mode == MenuScreen.PointerMode.Touch ? 0.35 : 0.2, 0.02, 0.0); } private void PollSummon(XRController3D controller, int hand) @@ -91,173 +142,68 @@ public partial class WorldPanel : Node3D private void SetPanelActive(bool active) { - if (!active) - ResetInteractionState(); + ResetInteractionState(); Visible = active; - SetShapesEnabled(this, active); - } - - private static void SetShapesEnabled(Node root, bool enabled) - { - foreach (Node child in root.GetChildren()) - { - if (child is CollisionShape3D shape) - shape.SetDeferred(CollisionShape3D.PropertyName.Disabled, !enabled); - SetShapesEnabled(child, enabled); - } + _screen.SetActive(active); + if (active) + _menu.RefreshCalibration(); } private void PlaceInFrontOf(XRController3D controller) => - OrientTo(controller.GlobalPosition - controller.GlobalTransform.Basis.Z * 0.5f); + OrientTo(controller.GlobalPosition - controller.GlobalTransform.Basis.Z * SummonDistance); - private void PlaceInFrontOfHead() => - OrientTo(_camera.GlobalPosition - _camera.GlobalTransform.Basis.Z * 0.5f); - - private void OrientTo(Vector3 pos) + private void PlaceInFrontOfHead() { - Vector3 look = _camera.GlobalPosition - pos; + Vector3 forward = -_camera.GlobalTransform.Basis.Z; + forward.Y = 0f; + forward = forward.LengthSquared() < 0.0001f ? Vector3.Forward : forward.Normalized(); + OrientTo(_camera.GlobalPosition + forward * HeadRecallDistance + Vector3.Down * HeadRecallDrop); + } + + private static Basis FacingBasis(Vector3 from, Vector3 head, out bool ok) + { + ok = false; + Vector3 look = head - from; if (look.LengthSquared() < 0.0001f) - return; + return Basis.Identity; Vector3 zAxis = look.Normalized(); Vector3 xAxis = Vector3.Up.Cross(zAxis); if (xAxis.LengthSquared() < 0.0001f) - return; + return Basis.Identity; xAxis = xAxis.Normalized(); Vector3 yAxis = zAxis.Cross(xAxis).Normalized(); - GlobalTransform = new Transform3D(new Basis(xAxis, yAxis, zAxis), pos); + ok = true; + return new Basis(xAxis, yAxis, zAxis); + } + + private void OrientTo(Vector3 pos) + { + Basis basis = FacingBasis(pos, _camera.GlobalPosition, out bool ok); + if (ok) + GlobalTransform = new Transform3D(basis, pos); } private void OrientTowardCamera() { - Vector3 look = _camera.GlobalPosition - GlobalPosition; - if (look.LengthSquared() < 0.0001f) - return; - Vector3 zAxis = look.Normalized(); - Vector3 xAxis = Vector3.Up.Cross(zAxis); - if (xAxis.LengthSquared() < 0.0001f) - return; - xAxis = xAxis.Normalized(); - Vector3 yAxis = zAxis.Cross(xAxis).Normalized(); - GlobalBasis = GlobalBasis.Slerp(new Basis(xAxis, yAxis, zAxis), 0.35f).Orthonormalized(); + Basis basis = FacingBasis(GlobalPosition, _camera.GlobalPosition, out bool ok); + if (ok) + GlobalBasis = GlobalBasis.Slerp(basis, 0.35f).Orthonormalized(); } - private void BuildPanel() - { - Node3D plate = new() { Name = "Plate" }; - AddChild(plate); - - StandardMaterial3D plateMaterial = new() - { - AlbedoColor = new Color(0.08f, 0.09f, 0.12f), - EmissionEnabled = true, - Emission = new Color(0.1f, 0.2f, 0.35f), - EmissionEnergyMultiplier = 0.4f, - }; - MeshInstance3D plateMesh = new() { Mesh = new BoxMesh { Size = new Vector3(0.6f, 0.34f, 0.02f) } }; - plateMesh.SetSurfaceOverrideMaterial(0, plateMaterial); - plate.AddChild(plateMesh); - - _panelBody = new StaticBody3D - { - Name = "PanelBody", - CollisionLayer = 2, - CollisionMask = 0, - }; - _panelBody.AddChild(new CollisionShape3D { Shape = new BoxShape3D { Size = new Vector3(0.6f, 0.34f, 0.06f) } }); - plate.AddChild(_panelBody); - - Vector3[] positions = - { - new(-0.21f, 0.06f, 0.03f), - new(-0.07f, 0.06f, 0.03f), - new(0.07f, 0.06f, 0.03f), - new(0.21f, 0.06f, 0.03f), - }; - string[] names = { "Spawn", "Ping", "Reset", "Close" }; - Color[] colors = - { - new(0.25f, 0.6f, 1f), - new(0.95f, 0.75f, 0.25f), - new(0.2f, 0.8f, 0.35f), - new(0.9f, 0.3f, 0.3f), - }; - Callable[] actions = - { - Callable.From(() => - { - _locomotion.ReturnToSpawn(); - ResetInteractionState(); - PlaceInFrontOfHead(); - }), - Callable.From(Ping), - Callable.From(() => _propShop.Respawn()), - Callable.From(Close), - }; - - for (int i = 0; i < names.Length; i++) - { - StandardMaterial3D material = new() - { - AlbedoColor = colors[i], - EmissionEnabled = true, - Emission = colors[i], - EmissionEnergyMultiplier = 1.2f, - }; - MeshInstance3D mesh = new() - { - Mesh = new CylinderMesh { TopRadius = 0.032f, BottomRadius = 0.032f, Height = 0.025f }, - RotationDegrees = new Vector3(90, 0, 0), - }; - mesh.SetSurfaceOverrideMaterial(0, material); - - StaticBody3D body = new() - { - CollisionLayer = 2, - CollisionMask = 0, - }; - body.SetMeta("knob_index", i); - body.AddChild(new CollisionShape3D { Shape = new BoxShape3D { Size = new Vector3(0.09f, 0.09f, 0.06f) } }); - - Node3D root = new() { Position = positions[i] }; - root.AddChild(mesh); - root.AddChild(body); - - Label3D label = new() - { - Text = names[i], - FontSize = 96, - PixelSize = 0.00025f, - OutlineSize = 6, - Position = new Vector3(0, -0.08f, 0.015f), - }; - root.AddChild(label); - - plate.AddChild(root); - _knobs.Add(new Knob - { - Name = names[i], - Root = root, - Mesh = mesh, - Material = material, - Action = actions[i], - }); - } - } - - private void BuildClickSound() + private AudioStreamPlayer3D MakeTone(float frequency, int durationDivisor, float amplitude, float decayPower) { const int rate = 44100; - const int samples = rate / 25; + int samples = rate / durationDivisor; byte[] data = new byte[samples * 2]; for (int i = 0; i < samples; i++) { - float envelope = 1f - (float)i / samples; - float value = Mathf.Sin(Mathf.Tau * 900f * i / rate) * envelope * 0.35f; + float envelope = Mathf.Pow(1f - (float)i / samples, decayPower); + float value = Mathf.Sin(Mathf.Tau * frequency * i / rate) * envelope * amplitude; short sample = (short)(value * short.MaxValue); data[i * 2] = (byte)sample; data[i * 2 + 1] = (byte)(sample >> 8); } - _click = new AudioStreamPlayer3D + AudioStreamPlayer3D player = new() { Stream = new AudioStreamWav { @@ -267,23 +213,22 @@ public partial class WorldPanel : Node3D Data = data, }, }; - AddChild(_click); + AddChild(player); + return player; } - private void UpdateHand(XRController3D controller, int hand) + private void UpdateDrag(XRController3D controller, int hand) { Vector3 from = controller.GlobalPosition; Vector3 dir = -controller.GlobalTransform.Basis.Z; - Vector3 tip = from + dir * TipLength; - - UpdateHover(controller, hand, from, dir, tip); + bool laserOnPanel = _screen.GetMode(hand) == MenuScreen.PointerMode.Laser; bool grip = controller.GetFloat("grip") > GripThreshold && !PropGrabber.IsGrabbing(controller); - if (grip && !_dragging[hand] && (_hoverPanel[hand] || GlobalPosition.DistanceTo(from) < DirectGrabRadius)) + if (grip && !_dragging[hand] && (laserOnPanel || GlobalPosition.DistanceTo(from) < DirectGrabRadius)) { _dragging[hand] = true; - _directDrag[hand] = !_hoverPanel[hand]; + _directDrag[hand] = !laserOnPanel; if (_directDrag[hand]) _directOffset[hand] = GlobalPosition - from; else @@ -303,94 +248,12 @@ public partial class WorldPanel : Node3D : from + dir * _laserDist[hand]; OrientTowardCamera(); } - - bool trigger = controller.GetFloat("trigger") > TriggerThreshold; - if (trigger && !_triggerPrev[hand] && _hover[hand] >= 0) - Press(_hover[hand]); - _triggerPrev[hand] = trigger; - - if (_hover[hand] >= 0) - { - int hovered = _hover[hand]; - Knob knob = _knobs[hovered]; - if (knob.Root.GlobalPosition.DistanceTo(tip) < PressContactDistance) - { - ulong now = Time.GetTicksMsec(); - if (now - knob.LastPressMs > PressCooldownMs) - { - knob.LastPressMs = now; - _knobs[hovered] = knob; - Press(hovered); - } - } - } } - private void UpdateHover(XRController3D controller, int hand, Vector3 from, Vector3 dir, Vector3 tip) - { - int knob = -1; - bool panel = false; - - var query = PhysicsRayQueryParameters3D.Create(from, from + dir * RayLength); - var hit = GetViewport().GetWorld3D().DirectSpaceState.IntersectRay(query); - if (hit.TryGetValue("collider", out Variant collider) && collider.As() is Node node) - { - if (node.HasMeta("knob_index")) - knob = (int)node.GetMeta("knob_index"); - else - panel = IsPanelCollider(node) || node.GetParent() == _panelBody; - } - - if (knob < 0) - { - for (int i = 0; i < _knobs.Count; i++) - { - if (_knobs[i].Root.GlobalPosition.DistanceTo(tip) < TipHoverRadius) - { - knob = i; - break; - } - } - } - - if (knob != _hover[hand]) - { - if (_hover[hand] >= 0) - _knobs[_hover[hand]].Material.EmissionEnergyMultiplier = 1.2f; - _hover[hand] = knob; - if (knob >= 0) - _knobs[knob].Material.EmissionEnergyMultiplier = 2.8f; - } - _hoverPanel[hand] = panel; - } - - private void Press(int index) - { - Knob knob = _knobs[index]; - GD.Print($"WorldPanel: pressed '{knob.Name}'"); - _click.Play(); - Tween tween = CreateTween(); - tween.TweenProperty(knob.Mesh, "position:z", -0.014f, 0.05); - tween.Parallel().TweenProperty(knob.Material, "emission_energy_multiplier", 4.0f, 0.05); - tween.TweenProperty(knob.Mesh, "position:z", 0f, 0.12); - tween.Parallel().TweenProperty(knob.Material, "emission_energy_multiplier", 1.2f, 0.3); - knob.Action.Call(); - } - - private void Ping() => GD.Print("WorldPanel: ping"); - - private void Close() => SetPanelActive(false); - private void ResetInteractionState() { for (int hand = 0; hand < 2; hand++) - { _dragging[hand] = false; - _hoverPanel[hand] = false; - _triggerPrev[hand] = false; - if (_hover[hand] >= 0) - _knobs[_hover[hand]].Material.EmissionEnergyMultiplier = 1.2f; - _hover[hand] = -1; - } + _screen?.ReleaseAll(); } }