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
+1280 -314

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
`~/.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
+84 -24
View File
@@ -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
+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 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<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;
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<Vector3>());
@@ -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)
+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
+122 -259
View File
@@ -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<Knob> _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>("../XRCamera3D");
_locomotion = GetNode<Locomotion>("../../Locomotion");
_propShop = GetNode<PropShop>("../../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();
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<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()
{
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();
}
}