Skip to content

30b-vr-modes: Edit vs Interact in VR — grips, walking, spawn, clean views (C1) - #244

Merged
AlexZ005 merged 6 commits into
feat/1.17from
feat/30b-vr-modes
Sep 23, 2026
Merged

AlexZ005 merged 6 commits into
feat/1.17from
feat/30b-vr-modes

Conversation

@AlexZ005

Copy link
Copy Markdown
Collaborator

Lane 30b-vr-modes: roadmap 30 round 2 (the Quest round), contract C1. This PR fixes the Quest 3 feedback about modes, grips, walking, spawn and the one-eye helper.

What landed (one commit per phase)

  • P0 (616d6d2) The one-eye light helper. Helpers lived on render layer 1, and three's WebXRManager reserves layer 1 for the LEFT eye (2 for the right). So a light helper drew in the left eye only, whatever the editor camera said. HELPER_LAYER is now 30. Helpers hide in Play or Interact through one predicate (helpersHiddenFor / editorHelpersShown).
  • P1 (4b46f71) Interact and Play draw no editor scaffolding. Hidden there: the grid, collider/trigger wireframes, tiny-object dots, peer lock boxes, the selection and lock outlines, the VR selection shell, and the VR hover box/tint.
  • P2 (8677f38) Grips by mode. Nothing was disabling the Edit world grab. It fires only when a grip closes on empty air, and game scenes have none (closed rooms, glass boxes, big floors), so every grip grabbed the room instead. A grip now passes through scenery (≥ 4 m on an axis, or bounds containing your head), so the Edit world grab/pan works in game scenes. In Interact, grips hold only dynamic bodies. They never move the world, and a grab doesn't select or record undo. VR knock only happens in Interact.
  • P3 (35079f9) Walk like a game. Interact's stick walks with the rapier capsule (walls, gravity, a 0.3 m step), keeps snap turn, and has no fly or teleport unless play.locomotion allows it. That field is omitted when absent, so saved scenes are byte-identical. VR and desktop share one resolver (charController.resolveWalk), and the dungeon raster now also clamps the rapier tier. Edit flies through walls as before. Desktop Play keeps flying, but a head capsule stops it at colliders while a sim runs.
  • P4 (a09a247) Modes and spawn in VR. A game opens in Interact in VR. Left Y toggles Edit/Interact (right B if the radial menu is on the left hand), with a haptic tick and a wrist label. Spawn comes from play.spawn or api.setSpawn(pos, yaw, {teleport}) / api.respawnPlayer(). In VR your feet land on the spawn, facing its yaw. Desktop Play moves the play camera there, and desktop Interact moves the editor view.
  • P5 (af38e55) Module content follows the world. Registered module scene-root groups are re-homed under module-world-root inside the world rig. It's still outside objectsGroup, so nothing replicates or saves. Untangle's dots now spin with the world, and this works for every module with no module change.

Tests

New suites: vr-helper-eyes (13), interact-clean-view (16), vr-grips-by-mode (18), vr-walk-interact (20), vr-mode-spawn (22), module-world-root (11). New vitest files: vrGrip (10) and locomotionPolicy (12). tests/e2e/fakeXR.cjs fakes an XR session (gamepads plus a reference space that composes offsets per the WebXR spec), so the real per-frame VR path runs headless.

For the counterfactuals I broke every guard in one batch: all six suites and the vitest went red. The per-guard lines are in the commit bodies.

held suite base this branch
editor-modes green green
play-interact green green
pointer-capture-play green green
object-list-modules 25/0 25/0
vr-world-grab (PEER_CONFIG) 9/2 9/2 (the 2 are pre-existing two-peer sync reds)
vr-locomotion green green
char-controller 55/0 55/0 (--exclusive; one shared-slot red was load)
helpers-in-play green green (now reads the layer from the app)

svelte-check 334/47 (base 335/47; one base error was removed and none were added). vitest 244. Build is green.

Deviations

All five are recorded in QUESTIONS-30b-vr-modes.md, and each was built with the recommended option:

  1. VR starts in Interact only for game scenes.
  2. The mode button moves to right B when the menu is on the left hand.
  3. The ≥ 4 m scenery rule.
  4. Desktop Play keeps flight but collides.
  5. Leaving VR keeps the mode.

For the integrator

  • Merge with 30b-vr-play:
    • Add true (their force arg) to toggleVRMode's hapticPulse.
    • Fold their hapticPattern('hit') into onSqueezeStart.
    • Union the App.svelte debug-hook tails (moduleWorld + playSpawn with their gameKit).
    • Their C3 routes the VR trigger's raycastSelect to an interact click in Interact.
  • CLAUDE.md:
    • Gotcha: helper layers 1/2 are the XR eyes.
    • Architecture entries: vrGrip, locomotionPolicy, playSpawn, moduleWorld.
    • SDK: api.setSpawn / api.respawnPlayer.
    • Play block: play.locomotion / play.spawn.
    • e2e skill: fakeXR.
  • CHANGELOG: see the handover.

Owed on device (Quest 3)

  • The helper in both eyes in Edit and in neither in Interact.
  • Edit world scale/rotate in Towers, Stars Room and Football.
  • How walking feels: speed, walls, steps, gravity.
  • Left Y switching, the tick, and the label.
  • Spawn placement.
  • Untangle dots spinning with the world.
  • Knocking the football in Interact.

Screenshots (Edit vs Interact): /home/deck/.code/lanes-30/after-30b/30b-vr-modes/.

🤖 Generated with Claude Code

AlexZ005 and others added 6 commits September 23, 2026 15:32
… layers

- Cause: helpers lived on render layer 1, and three's WebXRManager renders each eye
  through a sub-camera whose mask it rewrites every frame as
  (camera.mask | 0b110) & ~0b100 (left) and & ~0b010 (right) — layer 1 IS the left eye.
  So a light helper was drawn by the left eye whatever the editor camera enabled, and
  never by the right: the "light source helper with one eye" in Towers, Stars Room and
  Football on the Quest.
- helperLayer: HELPER_LAYER 1 -> 30 (3..30 are only ever inherited by the eye cameras;
  31 is overloadGuard's REDUCED_LAYER, postprocessing Selection ids count up from 2).
  helpersHiddenFor(locked, mode, debug) is the one predicate: Play OR Interact hide
  helpers (unless the debug toggle); editorHelpersShown is the same answer as a store.
  The editor camera and the camera-marker hop now follow editorMode as well as isLocked.
- helpers-in-play reads the layer from the app instead of assuming 1.
- New suite vr-helper-eyes (13): asserts three's installed WebXRManager still reserves
  1/2 for the eyes, then computes both eye masks with that formula — Edit: both eyes
  draw the helper and its proxy; VR Interact: neither; desktop Interact and Play: none.
- Counterfactual: HELPER_LAYER back to 1 -> vr-helper-eyes red ("the helper layer is not
  an XR eye layer", "Edit: BOTH eyes draw the light helper" L true R false).
- svelte-check 335/47 (unchanged list); vitest 222 green; build green.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- The Quest report: "In interact mode I do not need helper boxes or shapes, outlines
  around objects." Play already hid the helper layer and the grid; Interact hid nothing.
- Every editor helper now answers the ONE predicate from P0 (helperLayer.helpersHidden /
  editorHelpersShown — Play or Interact, unless "Show helpers in Play (debug)"):
  the grid (Grid.svelte), collider/trigger wireframes (colliderHelpers), the tiny-object
  dots (tinyMarkers), a peer's lock box (LockHighlights), the selection + lock outlines
  (Outline: Interact joins Play), the VR selection shell (VRSelectionShell) and the VR
  hover box + emissive hover tint (vrControls updateHoverBox / setHovered). Light
  helpers, frustums and the module-content proxy ride the helper layer (P0). The
  selection itself survives Interact — only its glare goes.
- New suite interact-clean-view (16): a scene holding one of each helper, read in Edit,
  Interact, back in Edit, VR Interact/Edit, Play, and Play with the debug toggle.
- Counterfactuals: colliderHelpers without the helpersHidden gate -> "Interact: no
  collider wireframes" red (and Play); Outline without the Interact clause -> "Interact:
  no selection or lock outline (1/1)" red.
- Held: editor-modes, play-interact, pointer-capture-play green (= base).
- svelte-check 335/47 unchanged; build green.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…teract never does

- The Quest report: "When in game and in edit mode I should be able to move around the
  world and scale it with grips, now it disables this for some reason". Nothing disabled
  it: the world gestures fire only on a grip that closes on EMPTY AIR, and a game scene
  has none — Stars Room is a closed room, Football a glass box, Towers a 26 m floor ringed
  by walls — so every grip grabbed the room.
- vrGrip.js (pure, imports nothing): SCENERY (world bounds >= 4 m on any axis, or bounds
  that contain the head) is never held by a grip; pickGripTarget walks the ray's top-level
  hits: Edit takes the first non-scenery object, Interact only a grabbable one (a dynamic
  body under a play block whose interaction is 'grab'; a static object in front blocks);
  gripMovesWorld: only Edit's empty-air grip moves the world.
- vrControls: gripTargetOf (ray hits + the hand-inside test through that rule) feeds
  onSqueezeStart. Interact: no world grab/pan, no two-hand object scale, a RIGID grab with
  no snapping and no stick reel/scale, no selection (no editor lock), and no undo entry on
  release (a player's throw is not an edit). vrGripDebug for suites.
- knock.js: in VR only Interact's hands knock (an Edit hand is placing things).
- tests/e2e/fakeXR.cjs: a fake XR session whose gamepads a suite presses, so the REAL
  per-frame path (Scene's useTask -> updateVRControls) runs headless; controller poses are
  written into the controller matrices.
- New: vitest vrGrip (10), e2e vr-grips-by-mode (18): Edit two grips on a wall = world
  grab that scales x2 and leaves the wall; right grip on the floor = pan; a cube is still
  held, selected and one undo step. Interact: walls/air hold nothing and never move the
  world; a static podium is not holdable; the dynamic cube is, unselected, with no undo.
- Counterfactuals: scenery not skipped in pickGripTarget -> 8 e2e reds (Edit grabs the
  wall/floor instead of the world) + 3 vitest reds; gripMovesWorld always true -> "Interact:
  ... start no world gesture" and "Interact: the world stays put (x2.67)" red.
- Held: vr-world-grab 9/2 = base (the 2 are its pre-existing two-peer sync checks, red on
  pristine base with PEER_CONFIG too); vr-locomotion green.
- svelte-check 335/47 unchanged; vitest green; build green.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…; Edit still flies

- The Quest report (the dungeon): "I can go through walls, teleport, I want to be able to
  do this only in edit mode and fly only in edit mode. In interactive mode I should be
  able to move like in a game."
- locomotionPolicy.js (pure): Edit = fly + teleport + world gestures, no walls (exactly
  the old movement); Interact = walk, collide, gravity, snap turn, NO fly or teleport
  unless `play.locomotion: {teleport?, fly?}` allows (absent = false; a flier drops
  gravity but keeps walls). normalizeLocomotion/normalizeSpawn + yaw helpers + the VR
  spawn as two WebXR offsets (used from P4).
- scenePhysics: play.locomotion and play.spawn normalised at the boundary and OMITTED
  when absent/empty, so saved scenes stay byte-identical. playSettings.resolvePlaySettings
  resolves `locomotion` field by field from the scene and publishers (the dungeon Kit
  publishes {teleport:false, fly:false}) and `spawn`.
- charController: tickWalker's body became resolveWalk(feet, height, dt, desired) so the VR
  walker resolves through the SAME tiers (rapier capsule with a sim / dungeon raster /
  ground plane). Asked by the dungeon lane: the raster now also clamps the rapier tier (Kit
  walls are module meshes, never rapier colliders). Desktop parity: tickWalker unchanged
  in behaviour (char-controller 55/55, twice).
- vrControls: vrWalkStep (stick -> head-yaw step at 2.2 m/s -> resolveWalk) and
  tickVRInteractLocomotion (feet = head world y minus the head's height in the BASE
  reference space captured at session start; moves by offsetting the XR reference space;
  falls out of the world -> back to the spawn/origin). updateTeleport honours the policy.
  VRControls.svelte: Interact runs the walker (a held object no longer stops your feet);
  Edit keeps its stick and now FLIES THROUGH walls (the raster clamp moved to Interact).
- Desktop Play keeps its built-in flight (the 21-E6 parity contract) but a 1 m head
  capsule now stops it at colliders while a simulation runs (collideRigStep; no world =
  byte-for-byte the old step).
- fakeXR.installSpace: a reference space + frame that compose offsets exactly as WebXR
  specifies, so the real per-frame walker runs headless.
- New: vitest locomotionPolicy (12, incl. the spawn offsets against WebXR's composition
  rule), e2e vr-walk-interact (20): a real rapier world — wall stops at -2.5, gravity lands
  the feet, a 0.25 m step is climbed, a 0.6 m block is a wall, a flier climbs; the real
  per-frame path walks forward and stops at the wall; teleport off in Interact, on with
  play.locomotion.teleport and in Edit; Edit flies through the wall; desktop Play stops.
- Counterfactuals: tickVRInteractLocomotion disabled -> "Interact: the wall stops you (z
  -7.55)" red; the teleport gate removed -> "Interact: the right stick does NOT arm a
  teleport" red; collideRigStep disabled -> "desktop Play: the wall stops you (z -15.10)" red.
- svelte-check 334/47 (one base error gone with the Edit raster clamp); build green.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… players spawn

- The Quest report: "By default for games there should be interact mode in VR, but I
  should be able to switch to edit mode", and "if the game should have a specific place
  where the character should stand, it should move."
- vrControls.onVRSessionStart (Scene's XR onsessionstart): remembers the base reference
  space, and a GAME (a HUD screen bound to a game state, a spawn, or a userData.play
  publisher) lands in Interact. Entering Interact in a session (editorMode subscription,
  the LAST statement of the file for the TDZ rule) drops any editor gesture, resets the
  world to 1:1 and pays the spawn on the first frame with a viewer pose.
- The mode button: LEFT Y (B on the right when the radial menu lives on the left, so
  they never share a button) toggles Edit <-> Interact with a haptic tick and a wrist
  label on that controller ('vr-mode-label': INTERACT teal / EDIT amber + the button).
- Spawn = `play.spawn: {position:[x,y,z], yaw}` (y = FEET, three rotation.y) or a
  module's `api.setSpawn(position, yaw, {teleport?})` (LOCAL, overrides the scene's,
  cleared when the module is disabled; without teleport a new spawn is a checkpoint) +
  `api.respawnPlayer()`. VR: vrControls.spawnPlayer turns the rig in place then moves it
  so the feet land on the point (locomotionPolicy.vrSpawnOffsets). Desktop Play: the
  play camera (playSpawn.spawnDesktopPlayer, ahead of the dungeon's per-peer rooms).
  Desktop Interact: the editor view flies there (objectActions.setEditorMode).
- New: e2e vr-mode-spawn (22): a plain scene stays in Edit, a game lands in Interact ON
  the spawn facing its yaw with the world back at 1:1; left Y -> Edit with one haptic tick
  and the label; walking off and pressing Y again re-spawns; with the menu on the left the
  right B switches; api.setSpawn checkpoint vs {teleport:true}, a malformed spawn refused,
  the module's spawn dropped on disable; desktop Play and desktop Interact spawn.
- Counterfactuals: no spawn on entering Interact -> "the player stands ON the spawn" and
  "facing the spawn's yaw" red; the mode button unwired -> "left Y: Interact -> Edit",
  "a haptic tick", "the label follows", "the RIGHT B toggles" red.
- svelte-check 334/47; build green.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…in with it)

- The Quest report: "When I spin the world around, the untangled dots do not spin
  around." The world gestures transform `world-grab-rig`; module viewport content lives
  at the SCENE ROOT (golden rule 5 keeps it out of objectsGroup), outside the rig.
- moduleWorld.js (a leaf): a `module-world-root` group INSIDE the rig (Scene.svelte, a
  sibling of objectsGroup — never serialised or sent), and every REGISTERED module group
  (registerInteractiveGroup / registerSystemGroup / registerListedGroup) is re-homed under
  it as it reaches the scene root (childadded + registry revision), local transform kept —
  desktop (rig = identity) moves nothing, VR carries it. For every module at once, no
  module change: `getObjectByName` still finds it, the scene instance's remove() also takes
  a re-homed group out, and core's scene-root scans (playSettings.playPublishers,
  moduleContent's rows) read the module root too. Unregistered content stays put.
- New e2e module-world-root (11): an inline module's groups land under the root (also a
  group added before its name was registered), an unregistered one does not, nothing
  enters objectsGroup, the dot follows a 90-degree spin + x2 scale of the rig, the Module
  content rows and the play publishers still find them, api.scene().remove works, disable
  removes them.
- Counterfactual: adoptModuleGroups a no-op -> "a registered group lands under the world
  rig's module root", "...also when added BEFORE its name was registered" and "the dot
  followed (1.000,1.000,0.000)" red.
- Held: object-list-modules 25/25 (= base, UNTANGLE_ZIP/TPSCENE from the sibling
  checkouts); svelte-check 334/47; vitest 244; build green.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@AlexZ005
AlexZ005 merged commit 7f46cfe into feat/1.17 Sep 23, 2026
4 checks passed
@AlexZ005
AlexZ005 deleted the feat/30b-vr-modes branch September 23, 2026 22:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant