Merge branch 'worktree-agent-a07f1b74ffe22c320'
# Conflicts: # HANDOFF.md
This commit is contained in:
commit
b5e7241ac1
11 files changed
+1281
-315
No files matched your search
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://dv7mp0go4oxof
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://cnjnswvcuecyf
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://bybf7naa4ron3
|
||||
+123
-260
@@ -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();
|
||||
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();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user