Merge branch 'worktree-agent-a07f1b74ffe22c320'

# Conflicts:
#	HANDOFF.md
This commit is contained in:
Foxyz committed 2026-10-02 05:52:00 +03:00
commit b5e7241ac1
11 files changed
+1281 -315

No files matched your search

+40 -26
View File
@@ -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 (bool actions unreliable across runtimes). Engine logs land in
`~/.local/share/godot/app_userdata/VR Messs/logs/godot.log` — agents: read this after user `~/.local/share/godot/app_userdata/VR Messs/logs/godot.log` — agents: read this after user
playtests instead of guessing. playtests instead of guessing.
- **UI direction — physical prop controls first**: buttons, knobs, sliders as real 3D objects - **UI direction — physical prop controls first, ONE dense screen**: buttons, knobs, sliders
(touch interaction, later Box3D for feel). Godot has NO native world-space widget system — as real 3D objects where they live in the world (touch interaction, later Box3D for feel).
`Control`s are screen-space; world UI is always Control-rendered-into-3D via `SubViewport` Godot has NO native world-space widget system — `Control`s are screen-space; world UI is
(optionally `SubViewportCompositionLayer` for XR-native sharpness/perf). Use at most one always Control-rendered-into-3D via `SubViewport` (optionally `SubViewportCompositionLayer`
SubViewport/composition-layer screen for dense content (file browser, settings) if needed; for XR-native sharpness/perf). At most ONE SubViewport/composition-layer screen for dense
everything else is props. World-space text is native and cheap: `Label3D` (SDF, crisp at content — **that screen is now the in-VR menu (`WorldPanel`, below)**; everything else is
distance) and `TextMesh` (extruded) — no SubViewport required. props. World-space text is native and cheap: `Label3D` (SDF, crisp at distance) and
- **`WorldPanel.cs` is the first prop-UI prototype**: summoned/dismissed via `ax_button` (A/X — `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 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 "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` 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 node paths. Appears 0.3 m in front of the summoning hand facing the head (full 3D facing —
from lying poses), draggable directly or via laser at fixed ray distance while gripped works from lying poses), draggable by grip directly or via laser at fixed ray distance
(continuously re-orients toward the head). The Spawn (continuously re-orients toward the head); "Return to spawn" recalls it in front of the head.
knob also recalls the panel in front of the head. The pointer **Input = the touch-first hybrid cursor, injected as mouse events**: each frame both
tip is a protruding stylus cone (apex ~12 cm ahead of the controller) and is THE cursor: controller rays are projected onto the panel plane (analytic, no physics query), converted to
knobs hover-highlight and PRESS ON CONTACT (tip within 4.5 cm, with cooldown) — physical SubViewport pixels and pushed with `Viewport.PushInput(event, true)`
buttons, no trigger needed; trigger still presses laser-hovered knobs (knobs are (`InputEventMouseMotion`/`InputEventMouseButton`, `NotifyMouseEntered/Exited`). The stylus
laser-targetable). Presses play a synthesized click (code-generated `AudioStreamWav` PCM tip (12 cm ahead of the controller) within 6 cm of the surface = TOUCH cursor: crossing
— no asset). Spawn/Ping/Close knobs. Locomotion defers 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 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 owned by `Locomotion.UpdatePointer`: for the menu it asks `WorldPanel.TryGetLaserDistance`
or panel) AND beyond `LaserMinDistance` (0.35 m) — near targets are touched via the pointer (beam only while that hand is in laser mode, beyond `LaserMinDistance` 0.35 m); markers keep
tip instead, which is the touch-first hybrid cursor in miniature. Grab deltas and turn the raycast path. Regression: `scenes/menu_spike.tscn` "MENU: overall PASS". Grab deltas
pivots are computed in WORLD space (tracking-space math made two-hand turns orbit the map and turn pivots are computed in WORLD space (tracking-space math made two-hand turns orbit
center — do not regress this). the map center — do not regress this).
- **Control scheme is deliberate**: decide what lives on controllers vs. what lives in menus - **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. 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 movement weird, teleport + lasers broken (owner report 2026-10-01; experiments, not
current issues). Fix-or-replace deferred — owner's candidate replacement is physics current issues). Fix-or-replace deferred — owner's candidate replacement is physics
thumbstick movement if the body-physics track lands. thumbstick movement if the body-physics track lands.
4. ~~UI: first prop panel~~ — **done** (`WorldPanel.cs`); SubViewport/composition-layer screen 4. ~~UI: first prop panel~~ — **done** (`WorldPanel.cs`), then REPLACED (2026-10-02, owner:
deferred until dense content (file browser) is actually needed. 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 5. ~~Box3D validation spike~~ — **done, PASS** (drop+contact, velocity-chase vs wall, headless
via `scenes/physics_spike.tscn`). via `scenes/physics_spike.tscn`).
6. **Physics hands & props sandbox — grab v5.3 = SOLVER-OWNED RIGID WELD (patched addon).** 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, (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 arm segments 5/1; panel stays 2/0 (props pass
through even when shown); layers live on through even when shown); layers live on
CollisionObject3D, NOT CollisionShape3D. Panel = prop-UI prototype (ax_button summon, CollisionObject3D, NOT CollisionShape3D. Panel = the in-VR menu (ax_button summon,
contact-press knobs, Reset). Laser/distant grab REMOVED (user never knew it existed); 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). 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; 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 break measures the prop-local anchor vs the palm point) apply to the v5.0–5.2 PIN path
+84 -24
View File
@@ -39,7 +39,11 @@ TERMINOLOGY (owner-corrected — use precisely):
`physics_spike` "SPIKE: overall PASS", `weld_spike` "WELD: …", `grab_spike` "GRAB: …", `physics_spike` "SPIKE: overall PASS", `weld_spike` "WELD: …", `grab_spike` "GRAB: …",
`body_spike` "BODY: …", `collapse_spike` "COLLAPSE: …", `arm_spike` "ARM: overall PASS", `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), `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`. `godot-mono --headless --import`.
- Engine logs: `~/.local/share/godot/app_userdata/VR Messs/logs/godot.log` — **read after user - Engine logs: `~/.local/share/godot/app_userdata/VR Messs/logs/godot.log` — **read after user
playtests instead of guessing**. 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 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 `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 `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()` `LaserMinDistance` (0.35 m); for the menu the beam length comes from
is public (panel Spawn knob calls it). `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 - **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 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 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 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). (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 - **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. → anvil 50 kg), Label3D captions, CCD for < 0.1 kg, group "prop". `Respawn()` = menu "Reset props".
- **WorldPanel.cs** — prop-UI panel, PARENTED UNDER `XROrigin3D` (fixed in player's room; revert = - **WorldPanel.cs** — the in-VR MENU (2026-10-02; replaced the chunky 3D knob panel). XR glue only:
reparent to Main + fix node paths). Summoned/dismissed via `ax_button` (A/X; menu_button belongs PARENTED UNDER `XROrigin3D` (fixed in player's room; revert = reparent to Main + fix node paths).
to the runtime). Knobs: Spawn (return to spawn + recall panel in front of head), Ping, Reset Summoned/dismissed via `ax_button` (A/X; menu_button belongs to the runtime), appears
(PropShop.Respawn), Close. Panel/knob bodies: **CollisionLayer 2, CollisionMask 0** (props pass `SummonDistance` 0.3 m in front of the summoning hand facing the head; grip-drag direct (hand
through); all CollisionShapes disabled while hidden (`SetShapesEnabled`, deferred). Pointer tip is within `DirectGrabRadius` 0.45 m) or along the laser at fixed ray distance, re-orients toward the
a 12 cm stylus cone; knobs hover-highlight and PRESS ON CONTACT (tip within 4.5 cm, 400 ms head while gripped; "Return to spawn" recalls it `HeadRecallDistance` 0.45 m ahead / 0.12 m below
cooldown) — physical buttons; trigger still presses laser-hovered knobs. Click = code-generated the head. Feeds both controllers (ray = `-Basis.Z`, `trigger`, `primary` stick; a hand dragging
`AudioStreamWav` PCM (no asset). Draggable direct or via laser at fixed ray distance, re-orients the panel doesn't point) into MenuScreen every frame, wires MainMenu events (deferred) to
toward head while gripped. `IsBlocking(controller)` (within 0.45 m) makes Locomotion defer `Locomotion.ReturnToSpawn`, `PropShop.Respawn`, ping tone, close; synthesized click + ping
grab/teleport; `IsPanelCollider(node)` identifies interactive colliders for lasers. (`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 - **TeleportMarker.cs** — `scenes/teleport_marker.tscn`: disc + beam + Label3D, group
"teleport_marker", StaticBody pick box. `SetNear` hides visuals, `SetHighlighted` swaps colors. "teleport_marker", StaticBody pick box. `SetNear` hides visuals, `SetHighlighted` swaps colors.
- **PhysicsTorso.cs** — the player's PHYSICAL CHEST (body-physics Stage 1). Implemented - **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). leaving the standard RigidBody3D stack).
Scene wiring (`scenes/main.tscn`): Main → WorldEnvironment, Sun, Floor, XROrigin3D (XRCamera3D, 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 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`. (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). 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 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 as fallback (git history). Stink-O's box3d-godot (custom components, more joint types) is
the plan-B. 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** — 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, 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 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. 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 - **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 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 when the locomotion direction is decided. The menu's laser cursor itself lives in MenuScreen
the laser system, so a deletion needs that decoupling first. (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 - **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 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- 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) | | `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 | | `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 | | 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 | | `LaserStart` | Locomotion.cs | 0.12 m | beam starts at the stylus tip; beam length = hit distance − this |
| `DirectGrabRadius` / `TipHoverRadius` / `TipLength` | WorldPanel.cs | 0.45 / 0.07 / 0.12 m | panel grab / tip hover / stylus reach | | `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 | | `MaximumRefreshRate` | Main.cs | 90 | physics tick = chosen refresh rate |
## 7. Next steps (in order) ## 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 2-anchor based), simultaneous two-hand movement acts weird, teleport + lasers broken
(experiments, not current issues). Owner's candidate replacement: physics thumbstick (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/ 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 laser machinery gets resurrected-or-deleted with it (the menu's laser cursor is
must decouple first if deleted). 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; 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). 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 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). 6. **Neck/head look system** — `LookAtModifier3D` head→neck distribution (needs a real skeleton).
7. **EMBODYING** — view-follow + avatar remapping (chest calibration transform); experiments only 7. **EMBODYING** — view-follow + avatar remapping (chest calibration transform); experiments only
after head/neck look override exists. Theory until proven. after head/neck look override exists. Theory until proven.
8. **UI milestone** — deliberate full action-map pass + SubViewport/composition-layer decision for 8. **UI milestone** — the ONE SubViewport screen now exists (in-VR menu, `MenuScreen`); remaining:
dense screens (at most ONE such screen; everything else stays props). 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 ## 8. Working agreements
+6
View File
@@ -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")
+12 -5
View File
@@ -7,6 +7,7 @@ public partial class Locomotion : Node
private const float RayLength = 50f; private const float RayLength = 50f;
private const float LaserMinDistance = 0.35f; private const float LaserMinDistance = 0.35f;
private const float MarkerNearDistance = 1.2f; private const float MarkerNearDistance = 1.2f;
private const float LaserStart = 0.12f;
private XROrigin3D _origin; private XROrigin3D _origin;
private XRCamera3D _camera; private XRCamera3D _camera;
@@ -78,7 +79,7 @@ public partial class Locomotion : Node
Node3D pivot = new() { Visible = false }; Node3D pivot = new() { Visible = false };
pivot.AddChild(beam); pivot.AddChild(beam);
Basis rot = Basis.FromEuler(new Vector3(-Mathf.Pi / 2f, 0, 0)); 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); controller.AddChild(pivot);
return pivot; return pivot;
} }
@@ -127,13 +128,19 @@ public partial class Locomotion : Node
bool show = false; bool show = false;
float dist = 0f; float dist = 0f;
if (hit.TryGetValue("collider", out Variant collider) && collider.As<Node>() 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<Node>() is Node node)
{ {
bool interactive; bool interactive;
if (node.GetParent() is TeleportMarker marker) if (node.GetParent() is TeleportMarker marker)
interactive = !marker.IsNear; interactive = !marker.IsNear;
else 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)) if (interactive && hit.TryGetValue("position", out Variant posVariant))
{ {
dist = from.DistanceTo(posVariant.As<Vector3>()); dist = from.DistanceTo(posVariant.As<Vector3>());
@@ -142,8 +149,8 @@ public partial class Locomotion : Node
} }
laser.Visible = show; laser.Visible = show;
if (show) if (show) // beam starts at the stylus tip (LaserStart ahead of the controller)
laser.Scale = new Vector3(1, dist, 1); laser.Scale = new Vector3(1, Mathf.Max(0.01f, dist - LaserStart), 1);
} }
private void CheckTeleport(XRController3D controller, ref bool wasPressed) private void CheckTeleport(XRController3D controller, ref bool wasPressed)
+288
View File
@@ -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);
}
}
+1
View File
@@ -0,0 +1 @@
uid://dv7mp0go4oxof
+467
View File
@@ -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<int, PointerMode> 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;
}
}
}
+1
View File
@@ -0,0 +1 @@
uid://cnjnswvcuecyf
+258
View File
@@ -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);
}
}
+1
View File
@@ -0,0 +1 @@
uid://bybf7naa4ron3
+123 -260
View File
@@ -1,16 +1,18 @@
using Godot; 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 public partial class WorldPanel : Node3D
{ {
private const float GripThreshold = 0.6f; private const float GripThreshold = 0.6f;
private const float TriggerThreshold = 0.6f; private const float TriggerThreshold = 0.6f;
private const float DirectGrabRadius = 0.45f; private const float DirectGrabRadius = 0.45f;
private const float TipHoverRadius = 0.07f; private const float SummonDistance = 0.3f;
private const float PressContactDistance = 0.045f; private const float HeadRecallDistance = 0.45f;
private const ulong PressCooldownMs = 400; private const float HeadRecallDrop = 0.12f;
private const float TipLength = 0.12f;
private const float RayLength = 50f;
private static WorldPanel _instance; private static WorldPanel _instance;
@@ -20,29 +22,17 @@ public partial class WorldPanel : Node3D
private Locomotion _locomotion; private Locomotion _locomotion;
private PropShop _propShop; private PropShop _propShop;
private AudioStreamPlayer3D _click; private AudioStreamPlayer3D _click;
private AudioStreamPlayer3D _ping;
private StaticBody3D _panelBody; private MenuScreen _screen;
private MainMenu _menu;
private struct Knob
{
public string Name;
public Node3D Root;
public MeshInstance3D Mesh;
public StandardMaterial3D Material;
public Callable Action;
public ulong LastPressMs;
}
private readonly List<Knob> _knobs = new();
private readonly bool[] _dragging = new bool[2]; private readonly bool[] _dragging = new bool[2];
private readonly bool[] _directDrag = new bool[2]; private readonly bool[] _directDrag = new bool[2];
private readonly float[] _laserDist = new float[2]; private readonly float[] _laserDist = new float[2];
private readonly Vector3[] _directOffset = new Vector3[2]; private readonly Vector3[] _directOffset = new Vector3[2];
private readonly bool[] _triggerPrev = new bool[2];
private readonly bool[] _summonPrev = new bool[2]; private readonly bool[] _summonPrev = new bool[2];
private readonly int[] _hover = new int[2] { -1, -1 }; private readonly MenuScreen.PointerInput[] _inputs = new MenuScreen.PointerInput[2];
private readonly bool[] _hoverPanel = new bool[2];
public override void _Ready() public override void _Ready()
{ {
@@ -52,9 +42,38 @@ public partial class WorldPanel : Node3D
_camera = GetNode<XRCamera3D>("../XRCamera3D"); _camera = GetNode<XRCamera3D>("../XRCamera3D");
_locomotion = GetNode<Locomotion>("../../Locomotion"); _locomotion = GetNode<Locomotion>("../../Locomotion");
_propShop = GetNode<PropShop>("../../PropShop"); _propShop = GetNode<PropShop>("../../PropShop");
Visible = false;
BuildPanel(); _screen = new MenuScreen { Name = "Screen" };
BuildClickSound(); 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); SetPanelActive(false);
} }
@@ -64,16 +83,48 @@ public partial class WorldPanel : Node3D
public static bool IsPanelCollider(Node node) => public static bool IsPanelCollider(Node node) =>
_instance != null && _instance.Visible && node != null && _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) public override void _Process(double delta)
{ {
PollSummon(_left, 0); PollSummon(_left, 0);
PollSummon(_right, 1); PollSummon(_right, 1);
if (!Visible) if (Visible)
return; {
UpdateHand(_left, 0); UpdateDrag(_left, 0);
UpdateHand(_right, 1); 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) private void PollSummon(XRController3D controller, int hand)
@@ -91,173 +142,68 @@ public partial class WorldPanel : Node3D
private void SetPanelActive(bool active) private void SetPanelActive(bool active)
{ {
if (!active) ResetInteractionState();
ResetInteractionState();
Visible = active; Visible = active;
SetShapesEnabled(this, active); _screen.SetActive(active);
} if (active)
_menu.RefreshCalibration();
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);
}
} }
private void PlaceInFrontOf(XRController3D controller) => 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() => private void PlaceInFrontOfHead()
OrientTo(_camera.GlobalPosition - _camera.GlobalTransform.Basis.Z * 0.5f);
private void OrientTo(Vector3 pos)
{ {
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) if (look.LengthSquared() < 0.0001f)
return; return Basis.Identity;
Vector3 zAxis = look.Normalized(); Vector3 zAxis = look.Normalized();
Vector3 xAxis = Vector3.Up.Cross(zAxis); Vector3 xAxis = Vector3.Up.Cross(zAxis);
if (xAxis.LengthSquared() < 0.0001f) if (xAxis.LengthSquared() < 0.0001f)
return; return Basis.Identity;
xAxis = xAxis.Normalized(); xAxis = xAxis.Normalized();
Vector3 yAxis = zAxis.Cross(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() private void OrientTowardCamera()
{ {
Vector3 look = _camera.GlobalPosition - GlobalPosition; Basis basis = FacingBasis(GlobalPosition, _camera.GlobalPosition, out bool ok);
if (look.LengthSquared() < 0.0001f) if (ok)
return; GlobalBasis = GlobalBasis.Slerp(basis, 0.35f).Orthonormalized();
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();
} }
private void BuildPanel() private AudioStreamPlayer3D MakeTone(float frequency, int durationDivisor, float amplitude, float decayPower)
{
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()
{ {
const int rate = 44100; const int rate = 44100;
const int samples = rate / 25; int samples = rate / durationDivisor;
byte[] data = new byte[samples * 2]; byte[] data = new byte[samples * 2];
for (int i = 0; i < samples; i++) for (int i = 0; i < samples; i++)
{ {
float envelope = 1f - (float)i / samples; float envelope = Mathf.Pow(1f - (float)i / samples, decayPower);
float value = Mathf.Sin(Mathf.Tau * 900f * i / rate) * envelope * 0.35f; float value = Mathf.Sin(Mathf.Tau * frequency * i / rate) * envelope * amplitude;
short sample = (short)(value * short.MaxValue); short sample = (short)(value * short.MaxValue);
data[i * 2] = (byte)sample; data[i * 2] = (byte)sample;
data[i * 2 + 1] = (byte)(sample >> 8); data[i * 2 + 1] = (byte)(sample >> 8);
} }
_click = new AudioStreamPlayer3D AudioStreamPlayer3D player = new()
{ {
Stream = new AudioStreamWav Stream = new AudioStreamWav
{ {
@@ -267,23 +213,22 @@ public partial class WorldPanel : Node3D
Data = data, 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 from = controller.GlobalPosition;
Vector3 dir = -controller.GlobalTransform.Basis.Z; Vector3 dir = -controller.GlobalTransform.Basis.Z;
Vector3 tip = from + dir * TipLength; bool laserOnPanel = _screen.GetMode(hand) == MenuScreen.PointerMode.Laser;
UpdateHover(controller, hand, from, dir, tip);
bool grip = controller.GetFloat("grip") > GripThreshold bool grip = controller.GetFloat("grip") > GripThreshold
&& !PropGrabber.IsGrabbing(controller); && !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; _dragging[hand] = true;
_directDrag[hand] = !_hoverPanel[hand]; _directDrag[hand] = !laserOnPanel;
if (_directDrag[hand]) if (_directDrag[hand])
_directOffset[hand] = GlobalPosition - from; _directOffset[hand] = GlobalPosition - from;
else else
@@ -303,94 +248,12 @@ public partial class WorldPanel : Node3D
: from + dir * _laserDist[hand]; : from + dir * _laserDist[hand];
OrientTowardCamera(); 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<Node>() 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() private void ResetInteractionState()
{ {
for (int hand = 0; hand < 2; hand++) for (int hand = 0; hand < 2; hand++)
{
_dragging[hand] = false; _dragging[hand] = false;
_hoverPanel[hand] = false; _screen?.ReleaseAll();
_triggerPrev[hand] = false;
if (_hover[hand] >= 0)
_knobs[_hover[hand]].Material.EmissionEnergyMultiplier = 1.2f;
_hover[hand] = -1;
}
} }
} }