From de61621df91c1cea36bdad4e5f3e12ef76004120 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Thu, 1 Oct 2026 00:29:59 +0300 Subject: [PATCH 1/8] [fix] 31 P0 R1: the radial lights the sector under the stick, and the trigger picks it The Quest report: "In VR, when I open radial menu, i do not see highlights when i move joystick, fix navigation." The input was never the problem - vrControls set vrHovered to the right sector every frame (measured through fakeXR: 8/8 directions). VRMenu.svelte is a LEGACY-mode component, where `color={sectorColor(s.entry)}` compiles to untrack(() => sectorColor(...)) depending on `s` alone, so the $vrHovered read INSIDE the helper registered nothing: no sector mesh ever repainted, under the stick or the ray (the hub, which reads the store inline, did). - VRMenu: sectorColor/labelColor take the hovered id as a PARAMETER ($vrHovered passed in the template), so the colour depends on it. - vrControls: a stick-lit sector is remembered per frame (radialStickSelection); Scene's trigger picks it when its own ray misses the ring (before, only a ray hit could be triggered and a stick-lit sector fell through to an object select). Stick-click and the hold-mode release are unchanged. - each stick is read from its OWN inputSource found by handedness (the pointer stick used sources[], the 194/210 divergence after a hands<->controllers swap); a greyed (disabled) sector is never lit by the stick. - one haptic tick per sector change stays behind hapticPulse's C4 gate (Interact ticks, Edit is silent - the user's earlier rule; recorded in QUESTIONS). Suite vr-radial-stick (12, fakeXR through the real per-frame path + Scene's select handler): 8 directions light exactly their sector MESH, centred = nothing, 3 ticks for 3 changes, trigger picks the stick-lit Add, stick-click picks Scene, the other hand's stick, the ray lights what it hits, a greyed sector stays dark. Counterfactuals (one batch, all restored): colour helper without the hovered argument -> mesh-repaint, other-hand, ray and both greyed checks red; trigger without radialStickSelection -> "the trigger opens the stick-lit sector" red; disabled guard removed -> "a greyed sector is not lit" red. 6 red in total. svelte-check 333/47 (base 333/47, identical list modulo line shifts). Co-Authored-By: Claude Opus 5.5 (1M context) --- src/components/Scene.svelte | 5 +- src/components/play/VRMenu.svelte | 16 ++- src/lib/vrControls.js | 29 ++++- tests/e2e/vr-radial-stick.test.cjs | 176 +++++++++++++++++++++++++++++ 4 files changed, 212 insertions(+), 14 deletions(-) create mode 100644 tests/e2e/vr-radial-stick.test.cjs diff --git a/src/components/Scene.svelte b/src/components/Scene.svelte index e1fd898b..390fdc33 100644 --- a/src/components/Scene.svelte +++ b/src/components/Scene.svelte @@ -55,7 +55,7 @@ // the annotation is TS syntax — a JSDoc @type cast is ignored here (the documented trap). let knifeFrom: number[] | null = null; import { peerScenes } from '$lib/peerScenes'; - import { initVRControls, updateVRControls, raycastMenu, raycastPanel, raycastPalette, raycastProps, raycastPrefabs, raycastKeyboard, raycastChat, raycastEdit, raycastSnap, raycastSettings, raycastApprove, placePrefabGhost, vrFaceTrigger, vrVertexTrigger, vrVertexGrabStart, vrVertexGrabEnd, beginStretchSliderDrag, endStretchSliderDrag, executeVRMenuAction, resetWorldRig, onInputSourcesChange, worldToContentPose, boxSelectStart, boxSelectEnd, boxSelectActive, applyVRFrameRate, shouldSendHands, onHandPinchStart, onHandPinchEnd, pinchMenuToggledAt, firePingIfArmed, vrModuleTriggerStart, vrModuleTriggerEnd, vrModuleSelectSwallowed, handSnapshot, vrGrabbedUuid, hapticKnock, hapticPulse, onVRSessionStart } from '$lib/vrControls'; + import { initVRControls, updateVRControls, raycastMenu, radialStickSelection, raycastPanel, raycastPalette, raycastProps, raycastPrefabs, raycastKeyboard, raycastChat, raycastEdit, raycastSnap, raycastSettings, raycastApprove, placePrefabGhost, vrFaceTrigger, vrVertexTrigger, vrVertexGrabStart, vrVertexGrabEnd, beginStretchSliderDrag, endStretchSliderDrag, executeVRMenuAction, resetWorldRig, onInputSourcesChange, worldToContentPose, boxSelectStart, boxSelectEnd, boxSelectActive, applyVRFrameRate, shouldSendHands, onHandPinchStart, onHandPinchEnd, pinchMenuToggledAt, firePingIfArmed, vrModuleTriggerStart, vrModuleTriggerEnd, vrModuleSelectSwallowed, handSnapshot, vrGrabbedUuid, hapticKnock, hapticPulse, onVRSessionStart } from '$lib/vrControls'; // 30b (vr-play): the game in your hands — hover/press haptics (P1), the sweep (P4) import { startVrGameInput, stopVrGameInput } from '$lib/vrGameInput'; import { gameFeelActive } from '$lib/gameFeel'; @@ -1282,7 +1282,8 @@ return; } if ($vrMenuOpen) { - const action = raycastMenu(xrControllers.indexOf(controller)); + // 31 R1: the ray's sector first, else the one a thumbstick holds highlighted + const action = raycastMenu(xrControllers.indexOf(controller)) ?? radialStickSelection(); if (action) { executeVRMenuAction(action); return; diff --git a/src/components/play/VRMenu.svelte b/src/components/play/VRMenu.svelte index 83ec25cc..94ab925a 100644 --- a/src/components/play/VRMenu.svelte +++ b/src/components/play/VRMenu.svelte @@ -57,15 +57,19 @@ // selectedObject is [] when nothing is selected — presence = has a uuid $: hub = hubEntry($activeRing, !!$selectedObject?.uuid) - function sectorColor(entry: any) { + // 31 R1: the hovered id is a PARAMETER, never read inside. This file is legacy mode, where + // `color={sectorColor(s.entry, $vrHovered)}` compiles to untrack(() => sectorColor(...)) depending on + // `s` alone — so a $vrHovered read in here registered nothing and no sector ever lit up + // under the stick or the ray (the hub, which reads it inline, did). The Quest report. + function sectorColor(entry: any, hovered: string | null) { if (entry.disabled?.()) return '#1b1f26' // D4: greyed out, hover never lights it - if ($vrHovered === entry.id) return '#ff4000' + if (hovered === entry.id) return '#ff4000' if (entry.color) return entry.color return entry.active?.() ? '#2f81f7' : '#2a2f38' } - function labelColor(entry: any) { + function labelColor(entry: any, hovered: string | null) { if (entry.disabled?.()) return '#6b7280' - return $vrHovered === entry.id ? '#ffffff' : '#e8ecf2' + return hovered === entry.id ? '#ffffff' : '#e8ecf2' } const controllerPosition = new THREE.Vector3() @@ -105,7 +109,7 @@ 8 ? 0.0095 : 0.0115} diff --git a/src/lib/vrControls.js b/src/lib/vrControls.js index 0c469a3d..f35f7804 100644 --- a/src/lib/vrControls.js +++ b/src/lib/vrControls.js @@ -1405,6 +1405,15 @@ export function raycastMenu(index) { return tile ? tile.object.name.slice('vrmenu-'.length) : null; } +/** 31 R1: the radial sector a THUMBSTICK is holding highlighted this frame (null when the + * hover came from a ray or nothing). The trigger picks it when its own ray misses the ring — + * before, only a ray hit could be triggered, so a stick-lit sector fell through to a select. */ +let radialStickHover = /** @type {string | null} */ (null); +/** @returns {string | null} */ +export function radialStickSelection() { + return get(vrMenuOpen) ? radialStickHover : null; +} + /** Raycast the objects panel rows (101) @param {number} index @returns {string|null} panel action */ export function raycastPanel(index) { const panel = get(vrPanelGroup); @@ -3687,22 +3696,30 @@ export function updateVRControls() { } } else if (get(vrMenuOpen)) { const sources = [...session.inputSources]; - const menuIndex = sources.findIndex((s) => s.handedness === get(vrMenuHand)); - const pointerIndex = controllerIndexFor(get(vrMenuHand) === 'right' ? 'left' : 'right'); + const menuHand = get(vrMenuHand); + const pointerHand = menuHand === 'right' ? 'left' : 'right'; + const pointerIndex = controllerIndexFor(pointerHand); let hovered = pointerIndex >= 0 ? raycastMenu(pointerIndex) : null; + radialStickHover = null; if (!hovered) { const entries = ringEntries(get(activeRing)); - for (const index of [menuIndex, pointerIndex]) { - if (index < 0) continue; - const axes = sources[index]?.gamepad?.axes ?? []; + // 31 R1: each stick is read from ITS OWN inputSource, found by handedness — the + // pointer's used to be `sources[]`, which is the other hand's + // source after a hands<->controllers swap (the 194/210 divergence) + for (const hand of [menuHand, pointerHand]) { + const axes = sources.find((s) => s.handedness === hand)?.gamepad?.axes ?? []; const sector = sectorFromStick(axes[2] ?? 0, axes[3] ?? 0, entries.length); if (sector !== null) { - hovered = entries[sector]?.id ?? null; + const entry = entries[sector]; + // a greyed sector never lights (D4) — nor may a stick pick it + hovered = entry && !entry.disabled?.() ? entry.id : null; + radialStickHover = hovered; break; } } } if (hovered !== get(vrHovered)) { + // one tick per sector change (Edit-silent through hapticPulse's own gate, C4) if (hovered) hapticPulse(0.15, 18); vrHovered.set(hovered); } diff --git a/tests/e2e/vr-radial-stick.test.cjs b/tests/e2e/vr-radial-stick.test.cjs new file mode 100644 index 00000000..029f28aa --- /dev/null +++ b/tests/e2e/vr-radial-stick.test.cjs @@ -0,0 +1,176 @@ +// 31 R1 — THE RADIAL FOLLOWS THE STICK. The user, on a Quest 3: "In VR, when I open radial +// menu, i do not see highlights when i move joystick, fix navigation." +// +// The cause was not the input: vrControls always set `vrHovered` to the sector under the stick. +// VRMenu.svelte is a LEGACY-mode component, where `color={sectorColor(s.entry)}` compiles to +// untrack(() => sectorColor(...)) depending on `s` alone, so the $vrHovered read INSIDE the helper +// registered nothing and no sector mesh ever repainted — under the stick OR the ray. And a +// stick-lit sector could not be picked with the trigger (only a ray hit could). +// +// Driven through fakeXR (the real per-frame updateVRControls + Scene's real select handler): +// 1 the menu button opens the radial · 2 each of the 8 stick directions lights exactly its +// sector's MESH (the colour, not just the store) · 3 one haptic tick per sector change (Interact; +// Edit is silent by the C4 rule) · 4 the trigger picks the stick-lit sector (its own ray misses +// the ring) · 5 a stick-click picks it · 6 the pointer hand's stick navigates too · 7 the ray +// lights the sector it hits · 8 a greyed sector is never lit by the stick. +const h = require('./helpers.cjs'); +const xr = require('./fakeXR.cjs'); + +h.run(async () => { + const browser = await h.launch(); + const A = await h.setupPage(browser, 'A'); + const page = A.page; + await xr.install(page); + + const read = () => + page.evaluate(() => { + const s = window.__stores; + const g = (st) => { + let v; + st.subscribe((x) => (v = x))(); + return v; + }; + const grp = g(s.vrControls.vrMenuGroup); + const lit = []; + grp?.traverse((o) => { + if (o.name?.startsWith('vrmenu-') && o.material?.color?.getHexString() === 'ff4000') lit.push(o.name.slice(7)); + }); + const ring = g(s.vrRadialMenu.activeRing); + return { + open: g(s.vrMenuOpen), + hand: g(s.vrMenuHand), + hov: g(s.vrControls.vrHovered), + ring, + entries: s.vrRadialMenu.ringEntries(ring).map((e) => e.id), + lit + }; + }); + const settle = () => page.waitForTimeout(160); + + // posed away from the ring (the ring is not posed without a real session: it sits at the origin) + await xr.pose(page, 'left', [-0.3, 1.1, 0.6], { pitch: -1.3 }); + await xr.pose(page, 'right', [0.3, 1.1, 0.6], { pitch: -1.3 }); + const hand = (await read()).hand; + const other = hand === 'left' ? 'right' : 'left'; + + // --- 1 open through the real button path + await xr.button(page, hand, 5, true); + await settle(); + await xr.button(page, hand, 5, false); + await settle(); + let st = await read(); + h.check(st.open && st.ring === 'root' && st.entries.length === 8, `the menu button opens the root ring (${st.entries.length} sectors)`); + + // --- 2 every direction lights exactly its sector's mesh + let allLit = true; + let allHov = true; + const detail = []; + for (let i = 0; i < 8; i++) { + const a = (i * Math.PI * 2) / 8; // clockwise from 12 o'clock; stick UP is -y + await xr.stick(page, hand, Math.sin(a), -Math.cos(a)); + await settle(); + st = await read(); + const want = st.entries[i]; + if (st.hov !== want) allHov = false; + if (!(st.lit.length === 1 && st.lit[0] === want)) allLit = false; + detail.push(`${i}:${st.hov}/${st.lit.join('+')}`); + } + h.check(allHov, `the stick hovers each of the 8 sectors in order (${detail.join(' ')})`); + h.check(allLit, 'the hovered sector MESH repaints, and only that one'); + await xr.stick(page, hand, 0, 0); + await settle(); + st = await read(); + h.check(st.hov === null && st.lit.length === 0, 'a centred stick lights nothing'); + + // --- 3 a haptic tick per sector change (Interact — Edit is silent by the C4 rule) + await page.evaluate(() => window.__stores.objectActions.setEditorMode('interact')); + const before = await xr.pulses(page); + for (const [x, y] of [[0, -1], [1, 0], [0, 1]]) { + await xr.stick(page, hand, x, y); + await settle(); + } + const after = await xr.pulses(page); + const ticks = after[hand] - before[hand]; + h.check(ticks >= 3, `three sector changes tick the controller three times (${ticks})`); + await xr.stick(page, hand, 0, 0); + await settle(); + await page.evaluate(() => window.__stores.objectActions.setEditorMode('edit')); + + // --- 4 the trigger picks the stick-lit sector; its own ray misses the ring + const menuHandIndex = hand === 'left' ? 0 : 1; + await xr.stick(page, hand, Math.sin(Math.PI / 4), -Math.cos(Math.PI / 4)); // sector 1 = nav:add + await settle(); + st = await read(); + const target = st.hov; + const rayMiss = await page.evaluate((i) => window.__stores.vrControls.raycastMenu(i), menuHandIndex); + await page.evaluate((i) => { + const r = window.__fakeXR.renderer; + const c = r.xr.getController(i); + c.dispatchEvent({ type: 'select', target: c }); + }, menuHandIndex); + await settle(); + st = await read(); + h.check(target === 'nav:add' && rayMiss === null, `premise: the stick lights Add while the trigger hand's ray misses the ring (${target}, ray ${rayMiss})`); + h.check(st.ring === 'add', `the trigger opens the stick-lit sector (ring ${st.ring})`); + await xr.stick(page, hand, 0, 0); + await settle(); + await page.evaluate(() => window.__stores.vrControls.executeVRMenuAction('back')); + await settle(); + + // --- 5 a stick-click picks it too (the 74 gesture, kept) + await xr.stick(page, hand, 1, 0); // sector 2 = nav:scene + await settle(); + await xr.button(page, hand, 3, true); + await settle(); + await xr.button(page, hand, 3, false); + await xr.stick(page, hand, 0, 0); + await settle(); + st = await read(); + h.check(st.ring === 'scene', `a stick-click opens the lit sector (ring ${st.ring})`); + await page.evaluate(() => window.__stores.vrControls.executeVRMenuAction('back')); + await settle(); + + // --- 6 the pointer hand's stick navigates too + await xr.stick(page, other, -1, 0); // sector 6 + await settle(); + st = await read(); + h.check(st.hov === st.entries[6] && st.lit[0] === st.entries[6], `the other hand's stick lights sector 6 (${st.hov})`); + await xr.stick(page, other, 0, 0); + await settle(); + + // --- 7 the ray lights the sector it hits (the ring sits at the origin facing +z) + const aim = await page.evaluate(() => { + const m = window.__stores.vrRadialMenu; + const l = m.sectorLayout(2, 8); + return [l.labelX, l.labelY]; + }); + const pointerIndex = other === 'left' ? 0 : 1; + await xr.pose(page, other, [aim[0], aim[1], 0.4]); + await settle(); + st = await read(); + h.check(st.hov === st.entries[2] && st.lit.length === 1 && st.lit[0] === st.entries[2], `the pointer ray lights the sector it hits (${st.hov}/${st.lit})`); + await xr.pose(page, other, [0.3, 1.1, 0.6], { pitch: -1.3 }); + await settle(); + + // --- 8 a greyed sector is never lit by the stick + const greyed = await page.evaluate(() => { + const m = window.__stores.vrRadialMenu; + m.registerVRMenuEntry({ id: 'r1-grey', label: 'Grey', group: 'r1test', disabled: () => true }); + m.registerVRMenuEntry({ id: 'r1-live', label: 'Live', group: 'r1test' }); + m.pushRing('r1test'); + return m.ringEntries('r1test').map((e) => e.id); + }); + await settle(); + await xr.stick(page, hand, 0, -1); // sector 0 of 2 = the greyed one + await settle(); + st = await read(); + h.check(greyed[0] === 'r1-grey' && st.hov === null && st.lit.length === 0, `a greyed sector is not lit (${st.hov})`); + await xr.stick(page, hand, 0, 1); // sector 1 = live + await settle(); + st = await read(); + h.check(st.hov === 'r1-live' && st.lit[0] === 'r1-live', `its live neighbour is (${st.hov})`); + await xr.stick(page, hand, 0, 0); + + await xr.uninstall(page); + await h.finish(browser); +}); From 473c7f9b9ce34bf07b5bc0b40d0a5b99c2448324 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Thu, 1 Oct 2026 01:10:08 +0300 Subject: [PATCH 2/8] =?UTF-8?q?[feat]=2031=20P1=20G5:=20see=20where=20the?= =?UTF-8?q?=20ray=20points=20=E2=80=94=20a=20visible=20beam,=20a=20hit=20d?= =?UTF-8?q?ot,=20a=20filled=20hover,=20module=20menus=20as=20beam=20ends?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Quest report: "i want to be able to see from controller ray where i point in menu (now its hard to navigate)". The beam and reticle existed; four things made them hard to use. - vrControls: the beam is NORMAL-blended (the additive glow vanished against a bright sky - the documented additive-burst trap) and a touch wider; the reticle ring gains a solid DOT at the exact hit point (the ring left the point itself empty, which is where a small button is). - vrControls.beamTarget: a module's INTERACTIVE scene-root content ends the beam too (it lives under module-world-root since 30b P5, outside objectsGroup, so the laser passed through a module's own menu/board with no dot). The name list is cached on the registry revision - no sort or allocation per frame. - vrGamePanel: the button under the laser is FILLED (+ a thicker ring), the footer buttons too - a 4 px outline on a board 1.2 m away was too faint to tell which one the ray was on. - vrGameInput.interactiveGroupOf: accept a group under module-world-root. The old `node.parent === scene` test matched nothing after 30b P5 re-homed module groups, so a module's buttons never resolved to a click target for the laser hover or the sweep. Suite vr-laser-panels (23, fakeXR + the REAL Towers, Stars Room and Jam Room templates from scenes preview-1-17 via SCENES_DIR): per game the beam ends on the menu button (length = distance), reticle AND dot on its centre (< 1 cm), the board's hover id is that button and a pixel inside it changes (filled); the beam is normal-blended; an inline module's interactive group is re-homed, ends the beam at 1.49 m and resolves to its group; the reticle sits on a radial sector. Counterfactuals (one batch, restored): no dot -> 3 red; no fill -> 3 red; additive beam -> 1 red; no module roots in beamTarget -> beam 2.90 m red; parent === scene -> group null red. 9 red total. svelte-check 333/47 (identical list modulo line shifts). Co-Authored-By: Claude Opus 5.5 (1M context) --- src/lib/vrControls.js | 58 +++++++- src/lib/vrGameInput.js | 6 +- src/lib/vrGamePanel.js | 9 +- tests/e2e/vr-laser-panels.test.cjs | 220 +++++++++++++++++++++++++++++ 4 files changed, 286 insertions(+), 7 deletions(-) create mode 100644 tests/e2e/vr-laser-panels.test.cjs diff --git a/src/lib/vrControls.js b/src/lib/vrControls.js index f35f7804..ce013e82 100644 --- a/src/lib/vrControls.js +++ b/src/lib/vrControls.js @@ -42,6 +42,8 @@ import { editorMode, peerHandStyle, pokeScene } from '../stores/sceneStore'; import { isScenery, pickGripTarget, gripMovesWorld } from './vrGrip'; +import { moduleGroupList, moduleGroupsRevision } from './moduleContent'; +import { moduleWorldChildren } from './moduleWorld'; import { resolvePlaySettings, playPublishers } from './playSettings'; import { hudDocs, isGameHud } from './hudDocs'; import { locomotionPolicy, vrSpawnOffsets, yawForward } from './locomotionPolicy'; @@ -276,8 +278,10 @@ let snapArmed = true; function ensureRayLines() { if (rayLines.length > 0 || !renderer) return; // a tapered cylinder along -Z (spans 0..-1) reads as a visible beam on-device - // where a 1px THREE.Line vanishes; additive blending gives it a soft glow - const beamGeo = new THREE.CylinderGeometry(0.0012, 0.0035, 1, 8, 1, true); + // where a 1px THREE.Line vanishes. 31 G5: NORMAL blending — the additive glow it had + // vanished against a bright sky ("i want to be able to see from controller ray where i + // point in menu"), the documented additive-burst trap + const beamGeo = new THREE.CylinderGeometry(0.0014, 0.004, 1, 8, 1, true); beamGeo.rotateX(-Math.PI / 2); // axis +Y -> -Z (narrow top ends toward the tip) beamGeo.translate(0, 0, -0.5); // span 0 (controller) .. -1 (tip) for (let i = 0; i < 2; i++) { @@ -286,8 +290,7 @@ function ensureRayLines() { new THREE.MeshBasicMaterial({ color: RAY_IDLE, transparent: true, - opacity: 0.6, - blending: THREE.AdditiveBlending, + opacity: 0.8, depthWrite: false }) ); @@ -310,6 +313,15 @@ function ensureRayLines() { ); reticle.name = 'vr-ray-reticle'; reticle.visible = false; + // 31 G5: a solid DOT at the exact hit point inside the ring — the ring alone left the + // point itself empty, which is where a small button is + const dot = new THREE.Mesh( + new THREE.CircleGeometry(0.0075, 16), + new THREE.MeshBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0.95, depthWrite: false, side: THREE.DoubleSide }) + ); + dot.name = 'vr-ray-dot'; + dot.position.z = 0.0005; + reticle.add(dot); renderer.xr.getController(i).add(reticle); rayReticles.push(reticle); } @@ -517,6 +529,31 @@ function openPanelGroups() { return list; } +/** 31 G5: the registered INTERACTIVE module groups present in the scene (re-homed under + * module-world-root since 30b P5, or still at the scene root) @returns {any[]} */ +function moduleInteractiveRoots() { + const revision = get(moduleGroupsRevision); + if (revision !== interactiveNamesAt) { + interactiveNamesAt = revision; + interactiveNames = moduleGroupList() + .filter((entry) => entry.kinds.has('interactive')) + .map((entry) => entry.name); + } + interactiveRoots.length = 0; + if (!interactiveNames.length) return interactiveRoots; + const children = moduleWorldChildren(); + for (const name of interactiveNames) { + let node = null; + for (const c of children) if (c.name === name) node = c; + node = node ?? /** @type {any} */ (get(globalScene))?.getObjectByName(name); + if (node && node.visible !== false) interactiveRoots.push(node); + } + return interactiveRoots; +} +let interactiveNamesAt = -1; +/** @type {string[]} */ let interactiveNames = []; +/** @type {any[]} */ const interactiveRoots = []; + /** D5: where a beam terminates — the NEAREST hit among scene objects and any * open floating panel, so navigating menus shows the beam ending in a circle * on the hovered control (parity with object selection). Exported for @@ -537,6 +574,19 @@ export function beamTarget(ray) { info = hits[0]; } } + // 31 G5: a module's INTERACTIVE scene-root content (its board, its buttons, its level + // picker) ends the beam too — it lives under module-world-root, outside objectsGroup, so + // the laser used to pass straight through a module's menu with no dot on it + for (const root of moduleInteractiveRoots()) { + const hits = ray.intersectObject(root, true); + const first = hits.find((/** @type {any} */ h) => h.object.visible !== false && !h.object.isLine && !h.object.isPoints); + if (first && first.distance < distance) { + distance = first.distance; + hit = true; + object = null; + info = first; + } + } for (const panel of openPanelGroups()) { const hits = ray.intersectObject(panel, true); if (hits.length > 0 && hits[0].distance < distance) { diff --git a/src/lib/vrGameInput.js b/src/lib/vrGameInput.js index ec0ff9c8..68fe4aa3 100644 --- a/src/lib/vrGameInput.js +++ b/src/lib/vrGameInput.js @@ -29,6 +29,7 @@ import { pickStack, primaryIndex } from './selectThrough'; import { topLevelObjectOf } from './objectActions'; import { objectHasOnClick, fireObjectClick } from './flowRuntime'; import { moduleInteractiveGroups, runClickHandlers } from './moduleSDK'; +import { isModuleTopLevel } from './moduleWorld'; import { registerVRFrameHook, registerVRTriggerHooks, registerPanelGroupProvider, controllerIndexFor, hapticPattern, triggerClaimed } from './vrControls'; // P3 (C2): the game UI in VR — the panel, the wrist card, the strip, the banner import { vrGamePanelFrame, panelTargetAlong, panelHover, pressPanelTarget, pokeFrame, uAcross, vrGameSurface, hideVrGamePanel } from './vrGamePanel'; @@ -43,7 +44,10 @@ const _mat = new THREE.Matrix4(); function interactiveGroupOf(mesh) { const scene = get(globalScene); for (let node = mesh; node && node !== scene; node = node.parent) { - if (node.parent === scene && moduleInteractiveGroups.includes(node.name)) return node.name; + // 31 G5: since 30b P5 a registered group sits under module-world-root, not the scene + // root — the old parent test matched nothing there, so a module's buttons and board + // never resolved to a click target for the laser hover or the sweep + if (isModuleTopLevel(node, scene) && moduleInteractiveGroups.includes(node.name)) return node.name; } return null; } diff --git a/src/lib/vrGamePanel.js b/src/lib/vrGamePanel.js index 33e856cc..8829f9c1 100644 --- a/src/lib/vrGamePanel.js +++ b/src/lib/vrGamePanel.js @@ -341,8 +341,13 @@ export function drawPanel(g, entry, runtime, opts = {}) { if (pressable) { hits.push({ id: el.id, key: entry?.key ?? 'scene', kind: el.kind, x, y, w, h }); if (opts.hover === el.id) { + // 31 G5: the laser's button is FILLED as well as ringed — a 4 px outline on a + // 1.2 m-away board was too faint to tell which button the ray was on + roundRect(g, x, y, w, h, radius); + g.fillStyle = 'rgba(95, 208, 255, 0.28)'; + g.fill(); roundRect(g, x - 4 * k, y - 4 * k, w + 8 * k, h + 8 * k, radius + 4 * k); - g.lineWidth = 4 * k; + g.lineWidth = 6 * k; g.strokeStyle = '#5fd0ff'; g.stroke(); } @@ -363,7 +368,7 @@ export function drawPanel(g, entry, runtime, opts = {}) { const bx = W / 2 + (i === 0 ? -bw - 20 * k : 20 * k); const by = fy + (FOOTER_H * k - bh) / 2; roundRect(g, bx, by, bw, bh, 12 * k); - g.fillStyle = b.id === 'edit' ? '#1f2937' : '#111827'; + g.fillStyle = opts.hover === 'footer:' + b.id ? '#1e4a63' : b.id === 'edit' ? '#1f2937' : '#111827'; g.fill(); g.lineWidth = (opts.hover === 'footer:' + b.id ? 5 : 2) * k; g.strokeStyle = opts.hover === 'footer:' + b.id ? '#5fd0ff' : 'rgba(148,163,184,0.6)'; diff --git a/tests/e2e/vr-laser-panels.test.cjs b/tests/e2e/vr-laser-panels.test.cjs new file mode 100644 index 00000000..9d64eaf1 --- /dev/null +++ b/tests/e2e/vr-laser-panels.test.cjs @@ -0,0 +1,220 @@ +// 31 G5 — SEE WHERE THE RAY POINTS. The user, on a Quest 3: "i want to be able to see from +// controller ray where i point in menu (now its hard to navigate)". +// +// Driven through fakeXR (a fake session: updateVRControls' real beam/reticle pass runs every +// frame) with the VR game board drawn by vrGamePanelFrame from a synthetic head, on the REAL +// Towers, Stars Room and Jam Room templates (their own menu screens). Per game: +// the beam ends ON the hovered button (its length = the hit distance), the reticle AND its +// solid dot sit on the button's centre (< 1 cm), the button under the ray is FILLED (a pixel +// inside it changes, not just a ring outside it) and the board's hover id is that button. +// Then: the beam is NORMAL-blended (the additive glow vanished against a bright sky); a +// module's INTERACTIVE group (re-homed under module-world-root since 30b P5) ends the beam and +// resolves to a click target; the radial ring takes the reticle too. +const h = require('./helpers.cjs'); +const xr = require('./fakeXR.cjs'); +const fs = require('fs'); +const path = require('path'); + +const SCENES = process.env.SCENES_DIR || path.resolve(__dirname, '../../../scenes/games'); +const GAMES = ['towers', 'stars-room', 'jam-room']; + +h.run(async () => { + const browser = await h.launch(); + const A = await h.setupPage(browser, 'A'); + const page = A.page; + await xr.install(page); + await page.evaluate(() => { + const s = window.__stores; + const THREE = s.THREE; + const r = window.__fakeXR.renderer; + window.__L = { + head: () => ({ position: new THREE.Vector3(0, 1.6, 0), quaternion: new THREE.Quaternion() }), + // point controller `i` from `from` at world point `to` (a controller's ray is its -Z) + aim: (i, from, to) => { + const c = r.xr.getController(i); + const m = new THREE.Matrix4().lookAt(from, to, new THREE.Vector3(0, 1, 0)); + const q = new THREE.Quaternion().setFromRotationMatrix(m); + c.matrix.compose(from, q, new THREE.Vector3(1, 1, 1)); + c.matrixWorld.copy(c.matrix); + c.updateMatrixWorld(true); + }, + ray: (i) => { + const c = r.xr.getController(i); + const ray = new THREE.Raycaster(); + const m = new THREE.Matrix4().extractRotation(c.matrixWorld); + ray.ray.origin.setFromMatrixPosition(c.matrixWorld); + ray.ray.direction.set(0, 0, -1).applyMatrix4(m); + return ray; + }, + rectPoint: (id) => { + const k = s.gameKit.vrGamePanel; + const surf = k.vrGameSurface('vr-game-panel'); + const rect = k.vrGamePanelDebug().hits['vr-game-panel'].find((x) => x.id === id); + if (!surf || !rect) return null; + const g = surf.mesh.geometry.parameters; + const cx = (rect.x + rect.w / 2) / surf.canvas.width; + const cy = (rect.y + rect.h / 2) / surf.canvas.height; + return surf.mesh.localToWorld(new THREE.Vector3((cx - 0.5) * g.width, (0.5 - cy) * g.height, 0)); + }, + beam: (i) => { + let found = { beam: null, reticle: null, dot: null }; + r.xr.getController(i).traverse((o) => { + if (o.name === 'vr-ray') found.beam = o; + if (o.name === 'vr-ray-reticle') found.reticle = o; + if (o.name === 'vr-ray-dot') found.dot = o; + }); + const wp = (o) => (o ? o.getWorldPosition(new THREE.Vector3()) : null); + return { + length: found.beam?.scale.z ?? null, + blending: found.beam?.material.blending ?? null, + reticle: found.reticle?.visible ? wp(found.reticle).toArray() : null, + dot: found.dot && found.reticle?.visible && found.dot.visible ? wp(found.dot).toArray() : null + }; + } + }; + }); + const frames = (n = 4) => page.waitForTimeout(n * 40); + + for (const game of GAMES) { + const file = path.join(SCENES, game, 'scene.tpscene'); + if (!fs.existsSync(file)) { + console.log('SKIP ' + game + ': no ' + file); + continue; + } + console.log('\n=== ' + game + ' ==='); + await page.evaluate(async (arr) => { + const s = window.__stores; + const payload = await s.sessions.readSessionZip(new Uint8Array(arr).buffer); + await s.sessions.applySession(payload, { backup: false }); + }, Array.from(fs.readFileSync(file))); + await page.waitForTimeout(2000); + const menu = await page.evaluate(() => { + const s = window.__stores; + s.gameState.setGameState('menu'); + s.objectActions.setEditorMode('interact'); + const k = s.gameKit.vrGamePanel; + k.hideVrGamePanel(); + const f = k.vrGamePanelFrame({ head: window.__L.head(), hands: [null, null] }); + const hits = k.vrGamePanelDebug().hits['vr-game-panel'].filter((x) => x.kind !== 'footer'); + return { panel: f.panel, ids: hits.map((x) => x.id) }; + }); + h.check(!!menu.panel && menu.ids.length > 0, `${game}: its menu is on the VR board with pressable controls (${menu.ids.join(',')})`); + if (!menu.ids.length) continue; + const id = menu.ids[0]; + // the pixel at the button's centre BEFORE the ray is on it + const px = (id) => + page.evaluate((id) => { + const k = window.__stores.gameKit.vrGamePanel; + const surf = k.vrGameSurface('vr-game-panel'); + const rect = k.vrGamePanelDebug().hits['vr-game-panel'].find((x) => x.id === id); + const g = surf.canvas.getContext('2d'); + // sample a quarter in from the left edge (text sits in the middle) + return Array.from(g.getImageData(Math.round(rect.x + rect.w * 0.12), Math.round(rect.y + rect.h * 0.2), 1, 1).data); + }, id); + const before = await px(id); + const at = await page.evaluate((id) => { + const s = window.__stores; + const L = window.__L; + const to = L.rectPoint(id); + L.aim(1, new s.THREE.Vector3(0.25, 1.3, -0.1), to); + return to.toArray(); + }, id); + await frames(); + const beam = await page.evaluate(() => window.__L.beam(1)); + const d = (a, b) => (a && b ? Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]) : Infinity); + const dist = d([0.25, 1.3, -0.1], at); + h.check(Math.abs(beam.length - dist) < 0.02, `${game}: the beam ends on the button (${beam.length?.toFixed(3)} vs ${dist.toFixed(3)} m)`); + h.check(d(beam.reticle, at) < 0.01, `${game}: the reticle sits on it (${d(beam.reticle, at).toFixed(4)} m)`); + h.check(d(beam.dot, at) < 0.01, `${game}: the solid hit DOT sits on it (${d(beam.dot, at).toFixed(4)} m)`); + // the hover: the board's own hover id + the button's fill + const hov = await page.evaluate(() => { + const s = window.__stores; + const k = s.gameKit.vrGamePanel; + k.panelHover(1, window.__L.ray(1)); + k.vrGamePanelFrame({ head: window.__L.head(), hands: [null, null] }); + return k.vrGamePanelDebug().hover; + }); + const after = await px(id); + h.check(hov[1] === id, `${game}: the board's hover is the pointed button (${hov[1]})`); + const delta = Math.abs(after[0] - before[0]) + Math.abs(after[1] - before[1]) + Math.abs(after[2] - before[2]); + h.check(delta > 30, `${game}: the hovered button is FILLED, not just ringed (rgb ${before.slice(0, 3)} -> ${after.slice(0, 3)})`); + await page.evaluate(() => { + const k = window.__stores.gameKit.vrGamePanel; + k.panelHover(1, null); + k.hideVrGamePanel(); + }); + } + + console.log('\n=== the beam material ==='); + const blending = await page.evaluate(() => [window.__L.beam(1).blending, window.__stores.THREE.NormalBlending]); + h.check(blending[0] === blending[1], `the beam is normal-blended, visible against a bright sky (${blending[0]})`); + + console.log('\n=== a module interactive group ends the beam ==='); + await page.evaluate(async () => { + const s = window.__stores; + s.objectActions.setEditorMode('edit'); + s.sessions.clearScene?.(); + await s.moduleSDK.initModules([ + { + id: 'laser31', + name: 'Laser test', + version: '1.0.0', + description: '31 G5', + register(api) { + const THREE = api.THREE; + const g = new THREE.Group(); + g.name = 'laser31-board'; + const m = new THREE.Mesh(new THREE.BoxGeometry(0.4, 0.3, 0.02), new THREE.MeshBasicMaterial({ color: 0x3366ff })); + m.name = 'laser31-button'; + g.add(m); + g.position.set(0, 1.4, -1.5); + api.scene().add(g); + api.registerInteractiveGroup('laser31-board'); + api.registerClickHandler(() => false); + } + } + ]); + }); + await frames(6); + const mod = await page.evaluate(() => { + const s = window.__stores; + const THREE = s.THREE; + const L = window.__L; + L.aim(1, new THREE.Vector3(0, 1.4, 0), new THREE.Vector3(0, 1.4, -1.5)); + const board = s.globalScene && (() => { let sc; s.globalScene.subscribe((v) => (sc = v))(); return sc.getObjectByName('laser31-board'); })(); + const target = s.gameKit.vrGameInput.clickTargetAlong(L.ray(1)); + return { parent: board?.parent?.name, group: target?.group ?? null }; + }); + await frames(); + const modBeam = await page.evaluate(() => window.__L.beam(1)); + h.check(mod.parent === 'module-world-root', `premise: the module group is re-homed under the world rig (${mod.parent})`); + h.check(modBeam.reticle && Math.abs(modBeam.length - 1.49) < 0.02, `the beam ends ON the module's board (${modBeam.length?.toFixed(3)} m, reticle ${!!modBeam.reticle})`); + h.check(mod.group === 'laser31-board', `the laser resolves the module mesh to its interactive group (${mod.group})`); + + console.log('\n=== the radial ring takes the reticle ==='); + const ring = await page.evaluate(() => { + const s = window.__stores; + s.vrMenuOpen.set(true); + return null; + }); + await frames(4); + const aimRing = await page.evaluate(() => { + const s = window.__stores; + const THREE = s.THREE; + const l = s.vrRadialMenu.sectorLayout(0, 8); + const to = new THREE.Vector3(l.labelX, l.labelY, 0); + window.__L.aim(1, new THREE.Vector3(l.labelX, l.labelY, 0.35), to); + return to.toArray(); + }); + await frames(); + const ringBeam = await page.evaluate(() => window.__L.beam(1)); + h.check( + ringBeam.reticle && Math.hypot(ringBeam.reticle[0] - aimRing[0], ringBeam.reticle[1] - aimRing[1], ringBeam.reticle[2] - aimRing[2]) < 0.01, + `the reticle sits on the radial sector (${JSON.stringify(ringBeam.reticle)})` + ); + await page.evaluate(() => window.__stores.vrMenuOpen.set(false)); + void ring; + + await xr.uninstall(page); + await h.finish(browser); +}); From 80c3b0b4051e21eca2b8a5813bed3f4862735b16 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Thu, 1 Oct 2026 03:25:01 +0300 Subject: [PATCH 3/8] [feat] 31 P2 U4: VR panels draw over the scene (a depth clear before them) + api.vrPanel The Quest report: "menu buttons during game covered by scene objects below untangle (like base/floor objects)". A VR panel was a mesh like any other, so whatever stood between the eyes and it covered its buttons - while the laser, which prefers a panel, still pressed them. - vrPanelOverlay.js (leaf, THREE only): every open panel joins the transparent list at PANEL_ORDER after ONE depth clear (a colourless, never-culled sentinel whose onBeforeRender clears depth), so panels draw over the finished scene and still depth-test against EACH OTHER (a panel's own layers keep their order; a nearer panel covers a farther one - which a blanket depthTest:false gets wrong). Both eyes: without multiview three renders one eye completely, then the other, so the clear touches a finished half; with multiview both views clear together. Raycasts read geometry, so hit tests are untouched. - vrControls: core panels re-marked each frame (idempotent, allocation-free); the sentinel is in the scene only while a panel is open (a frame without one is the old frame); a panel the ray reaches ENDS THE BEAM even with a floor in front (the press already preferred the panel - now the beam agrees); beam + reticle draw at BEAM_ORDER while on a panel. registerOverlayPanel / panelOverlayDebug exported. - moduleSDK: api.vrPanel(object) -> undo (journalled with the module) for a module's own VR menu / level bar - Untangle's bar is the case the user saw. - vrGamePanel surfaces sit at PANEL_ORDER (they already ignored depth); splineEdit's handle group opts out (userData.vrOverlay = false - world handles, not a panel). Suite vr-panels-on-top (12, fakeXR, PIXELS with a red unlit blocker in objectsGroup between the camera and each panel): premise - the blocker hides an ordinary mesh (0.00 not red); the radial ring (1.00), the game board's Start (1.00) and an api.vrPanel module bar (1.00) draw over it; the beam passes the blocker and ends on the board (1.400 m) and on the module bar (0.900 m) at BEAM_ORDER; the trigger presses Start through the blocker; the depth clear ran and stands down with no panel open. Counterfactuals (restored after each): overlay disabled -> ring 0.00, module bar 0.00, clears 0 (3 red); panel hits compared by distance -> beam 0.660 m / 0.475 m and order 0 (3 red). svelte-check 333/47 (identical list). Co-Authored-By: Claude Opus 5.5 (1M context) --- src/lib/moduleSDK.js | 12 ++ src/lib/splineEdit.js | 1 + src/lib/vrControls.js | 78 ++++++++++- src/lib/vrGamePanel.js | 5 +- src/lib/vrPanelOverlay.js | 76 +++++++++++ tests/e2e/vr-panels-on-top.test.cjs | 204 ++++++++++++++++++++++++++++ 6 files changed, 368 insertions(+), 8 deletions(-) create mode 100644 src/lib/vrPanelOverlay.js create mode 100644 tests/e2e/vr-panels-on-top.test.cjs diff --git a/src/lib/moduleSDK.js b/src/lib/moduleSDK.js index 9eacc612..87c2cbae 100644 --- a/src/lib/moduleSDK.js +++ b/src/lib/moduleSDK.js @@ -1202,6 +1202,18 @@ function makeApi(moduleId, moduleName = moduleId) { vrHand(hand) { return vrControlsRef?.handSnapshot?.(hand) ?? null; }, + /** + * 31 K2: make `object` (a group of meshes: your VR menu, level bar, buttons) a VR + * PANEL — drawn OVER the scene so a floor or a base can never hide it, and a place the + * controller beam ends with its dot. Hit testing is unchanged. Returns the undo (also + * run when the module is disabled). Feature-detect: `api.vrPanel?.(group)`. + * @param {any} object @returns {() => void} + */ + vrPanel(object) { + const off = vrControlsRef?.registerOverlayPanel?.(object) ?? (() => {}); + onDispose(off); + return off; + }, /** * Fire the replicated flow click trigger on an object (DEVX #4, the * essentials pattern) — user graphs with an On Click node targeting the diff --git a/src/lib/splineEdit.js b/src/lib/splineEdit.js index f85ab01b..e98d6b63 100644 --- a/src/lib/splineEdit.js +++ b/src/lib/splineEdit.js @@ -192,6 +192,7 @@ function rebuildHandles() { } const size = handleSize(); group = new THREE.Group(); + group.userData.vrOverlay = false; // 31 K2: world handles, never drawn over the scene group.name = 'spline-handles'; pointHandles = makeHandles(points.length, POINT_COLOR, size, 'spline-point-handles'); radiusHandles = makeHandles(points.length, RADIUS_COLOR, size * 0.6, 'spline-radius-handles'); diff --git a/src/lib/vrControls.js b/src/lib/vrControls.js index ce013e82..220f056c 100644 --- a/src/lib/vrControls.js +++ b/src/lib/vrControls.js @@ -43,6 +43,7 @@ import { peerHandStyle, pokeScene } from '../stores/sceneStore'; import { isScenery, pickGripTarget, gripMovesWorld } from './vrGrip'; import { moduleGroupList, moduleGroupsRevision } from './moduleContent'; +import { overlayPanel, makeDepthSentinel, PANEL_ORDER, BEAM_ORDER } from './vrPanelOverlay'; import { moduleWorldChildren } from './moduleWorld'; import { resolvePlaySettings, playPublishers } from './playSettings'; import { hudDocs, isGameHud } from './hudDocs'; @@ -515,6 +516,8 @@ function openPanelGroups() { if (panel) list.push(panel); } catch {} } + // 31 K2: a module's registered VR panels (api.vrPanel) — the beam ends on them too + for (const panel of overlayPanels) if (panel.visible !== false && panel.parent) list.push(panel); add(get(vrMenuOpen), vrMenuGroup); add(get(vrObjectsPanelOpen), vrPanelGroup); add(get(vrPropsPanelOpen), vrPropsGroup); @@ -529,6 +532,48 @@ function openPanelGroups() { return list; } +/** 31 K2: module-registered VR panels (api.vrPanel) @type {Set} */ +const overlayPanels = new Set(); +/** + * 31 K2: make a module's object a VR PANEL — drawn over the scene like core's panels, and + * a beam end. Returns the undo. @param {any} object @returns {() => void} + */ +export function registerOverlayPanel(object) { + if (!object) return () => {}; + overlayPanels.add(object); + overlayPanel(object); + return () => { + overlayPanels.delete(object); + }; +} +/** @type {any} */ let depthSentinel = null; +/** + * 31 K2 (U4): every open panel draws OVER the scene — see vrPanelOverlay.js for why a depth + * clear rather than depthTest:false. Core's panels (svelte trees whose children change as + * they re-render) are re-marked every frame (idempotent, allocation-free); the game surfaces + * and api.vrPanel objects are marked when made. The sentinel is in the scene only while a + * panel is open, so a frame without one is byte-for-byte the old frame. + */ +function updatePanelOverlay() { + const panels = openPanelGroups(); + let any = false; + for (const panel of panels) { + if (panel.userData?.vrOverlay === false) continue; // a provider's world handles (spline, sleeve) + overlayPanel(panel); + any = true; + } + const scene = /** @type {any} */ (get(globalScene)); + if (any && scene) { + if (!depthSentinel) depthSentinel = makeDepthSentinel(THREE); + if (depthSentinel.parent !== scene) scene.add(depthSentinel); + depthSentinel.visible = true; + } else if (depthSentinel) depthSentinel.visible = false; +} +/** 31 K2: how many times the panel depth clear ran (the suites' view) */ +export function panelOverlayDebug() { + return { clears: depthSentinel?.userData.clears ?? 0, visible: !!depthSentinel?.visible, order: PANEL_ORDER }; +} + /** 31 G5: the registered INTERACTIVE module groups present in the scene (re-homed under * module-world-root since 30b P5, or still at the scene root) @returns {any[]} */ function moduleInteractiveRoots() { @@ -558,7 +603,7 @@ let interactiveNamesAt = -1; * open floating panel, so navigating menus shows the beam ending in a circle * on the hovered control (parity with object selection). Exported for * headless tests. @param {any} ray a THREE.Raycaster - * @returns {{distance: number, hit: boolean, object: any, info: any}} */ + * @returns {{distance: number, hit: boolean, object: any, info: any, panel: boolean}} */ export function beamTarget(ray) { const group = get(objectsGroup); let distance = 5; @@ -587,15 +632,24 @@ export function beamTarget(ray) { info = first; } } + // 31 K2: a VR panel is drawn OVER the scene (vrPanelOverlay), so a panel the ray reaches + // ends the beam even when a floor or a base stands in front of it — the press goes to the + // panel there too (a panel hit wins every trigger path), and the beam must agree with it + let panelDistance = Infinity; + /** @type {any} */ let panelInfo = null; for (const panel of openPanelGroups()) { const hits = ray.intersectObject(panel, true); - if (hits.length > 0 && hits[0].distance < distance) { - distance = hits[0].distance; - hit = true; - object = null; // a panel in front never highlights the object behind it - info = hits[0]; + if (hits.length > 0 && hits[0].distance < panelDistance) { + panelDistance = hits[0].distance; + panelInfo = hits[0]; } } + if (panelInfo) { + distance = panelDistance; + hit = true; + object = null; // a panel never highlights the object behind it + info = panelInfo; + } // D8: while vertex-editing, the scene-root handle dots are beam targets too // (they live OUTSIDE objectsGroup); a handle hit never highlights an object const handles = get(editingObject) ? vertexHandleMesh() : null; @@ -608,7 +662,7 @@ export function beamTarget(ray) { info = hits[0]; } } - return { distance, hit, object, info }; + return { distance, hit, object, info, panel: !!panelInfo && info === panelInfo }; } /** D5: controller-local reticle pose — the ring sits at the beam tip, laid @@ -655,8 +709,10 @@ function updateRaysAndHover(presenting) { if (!presenting) { rayReticles.forEach((r) => (r.visible = false)); setHovered(null); + if (depthSentinel) depthSentinel.visible = false; return; } + updatePanelOverlay(); const pointerIndex = controllerIndexFor(get(vrMenuHand) === 'right' ? 'left' : 'right'); // D6: an armed one-shot ping tints the beam + reticle with YOUR ping color // so the arm state is visible in-headset @@ -665,6 +721,14 @@ function updateRaysAndHover(presenting) { : null; for (let i = 0; i < 2; i++) { const target = beamTarget(controllerRay(i)); + // 31 K2: on a panel the pointer draws after the panels (seen on the button through the + // floor); anywhere else it depth-tests like the scene around it + const order = target.panel ? BEAM_ORDER : 0; + if (rayLines[i]) rayLines[i].renderOrder = order; + if (rayReticles[i]) { + rayReticles[i].renderOrder = order; + rayReticles[i].children[0] && (rayReticles[i].children[0].renderOrder = order + 1); + } if (rayLines[i]) { rayLines[i].scale.z = target.distance; if (pingTint) rayLines[i].material.color.set(pingTint); diff --git a/src/lib/vrGamePanel.js b/src/lib/vrGamePanel.js index 8829f9c1..e8b10814 100644 --- a/src/lib/vrGamePanel.js +++ b/src/lib/vrGamePanel.js @@ -39,6 +39,7 @@ import { fireHudButton, hudOptionsOf } from './flowRuntime'; import { cameraPreview } from './cameraPreview'; import { gameState } from './gameState'; import { gameFeelActive } from './gameFeel'; +import { PANEL_ORDER } from './vrPanelOverlay'; import { gameAnnouncement } from './gameAnnounce'; import { playGameSound } from './gameSfx'; import { hudImageFor, resolveHudImage } from './hudImages'; @@ -530,7 +531,9 @@ function surface(name, pxW, pxH, worldW) { new THREE.MeshBasicMaterial({ map: texture, transparent: true, depthTest: false, depthWrite: false, side: THREE.DoubleSide }) ); mesh.name = name; - mesh.renderOrder = 1000; + // 31 K2: the overlay order, after the panel depth clear (vrPanelOverlay) — its own + // depthTest:false stays, so it is over the scene with or without the clear + mesh.renderOrder = PANEL_ORDER; mesh.visible = false; mesh.frustumCulled = false; mesh.userData.localOnly = true; diff --git a/src/lib/vrPanelOverlay.js b/src/lib/vrPanelOverlay.js new file mode 100644 index 00000000..453c82de --- /dev/null +++ b/src/lib/vrPanelOverlay.js @@ -0,0 +1,76 @@ +// 31 K2 (U4): VR PANELS ARE NEVER OCCLUDED — a leaf (THREE only). +// +// The Quest report: "menu buttons during game covered by scene objects below untangle (like +// base/floor objects)". A VR panel is a mesh in the world like any other, so whatever stands +// between the eyes and it — Untangle's base, a floor tilted up by a world grab, a wall the +// board followed the head into — was drawn over its buttons, while the laser still reached +// them (a panel hit wins the press), so the player clicked buttons they could not see. +// +// THE CHOICE: a DEPTH CLEAR before the panels, rather than depthTest off on every panel +// material. Every panel mesh joins the TRANSPARENT list (three draws that last) at +// PANEL_ORDER, and one SENTINEL — a colourless, depth-less, never-culled mesh at +// PANEL_ORDER - 1 — clears the depth buffer in its onBeforeRender. So the panels draw over +// the finished scene but still depth-test AGAINST EACH OTHER: a panel's own layers (a +// backdrop, its buttons, their labels a few mm in front) keep their order, and a nearer +// panel covers a farther one, which a blanket depthTest:false would get wrong. +// BOTH EYES: without multiview three renders the left eye completely, then the right; a clear +// during the right eye's transparent pass touches a left half that is already finished. With +// multiview both views advance together, so they clear together. Hit tests are untouched — +// raycasting reads geometry, never materials or render order. +// The beam and its reticle join at BEAM_ORDER while they end ON a panel, so the pointer is +// seen on the button even when the floor is between the hand and the board. + +/** the render order every panel mesh takes */ +export const PANEL_ORDER = 5000; +/** the beam + reticle, while they end on a panel */ +export const BEAM_ORDER = 5010; + +/** + * Put one panel tree into the overlay: transparent (so it draws after the whole scene) at + * PANEL_ORDER. Idempotent and allocation-free; a material switched to transparent is flagged + * for a program refresh once. + * @param {any} root @returns {number} meshes touched + */ +export function overlayPanel(root) { + if (!root) return 0; + touched = 0; + root.traverse(visit); + return touched; +} +let touched = 0; +/** @param {any} node */ +function visit(node) { + const material = node.material; + if (!material) return; + node.renderOrder = PANEL_ORDER; + if (Array.isArray(material)) for (const m of material) mark(m); + else mark(material); + touched++; +} +/** @param {any} m */ +function mark(m) { + if (!m || m.transparent) return; + m.transparent = true; + m.needsUpdate = true; +} + +/** + * The depth-clearing sentinel. `renderer` is read at draw time (the one drawing the frame). + * @param {any} THREE_NS @returns {any} a Mesh to add at the scene root + */ +export function makeDepthSentinel(THREE_NS) { + const geometry = new THREE_NS.BufferGeometry(); + // one degenerate triangle: something to draw (so onBeforeRender runs), nothing to see + geometry.setAttribute('position', new THREE_NS.Float32BufferAttribute([0, 0, 0, 0, 0, 0, 0, 0, 0], 3)); + const material = new THREE_NS.MeshBasicMaterial({ colorWrite: false, depthWrite: false, depthTest: false, transparent: true }); + const sentinel = new THREE_NS.Mesh(geometry, material); + sentinel.name = 'vr-panel-depth-clear'; + sentinel.frustumCulled = false; + sentinel.renderOrder = PANEL_ORDER - 1; + sentinel.userData.clears = 0; + sentinel.onBeforeRender = (/** @type {any} */ renderer) => { + renderer.clearDepth(); + sentinel.userData.clears++; + }; + return sentinel; +} diff --git a/tests/e2e/vr-panels-on-top.test.cjs b/tests/e2e/vr-panels-on-top.test.cjs new file mode 100644 index 00000000..f7cb3836 --- /dev/null +++ b/tests/e2e/vr-panels-on-top.test.cjs @@ -0,0 +1,204 @@ +// 31 K2 (U4) — VR PANELS ARE NEVER HIDDEN. The user, on a Quest 3: "menu buttons during game +// covered by scene objects below untangle (like base/floor objects)". A panel was a mesh like +// any other, so whatever stood between the eyes and it covered its buttons — while the laser, +// which prefers a panel, still pressed them. +// +// vrPanelOverlay: every open panel joins the transparent list at PANEL_ORDER after ONE depth +// clear (a sentinel's onBeforeRender), so it draws over the finished scene and still +// depth-tests against other panels. Read with PIXELS (the metric the bug changes), with a +// RED unlit box parked between the camera and each panel: +// 1 the radial ring (a svelte panel) shows through the box · 2 the VR game board does, and +// the laser — aimed THROUGH the box — ends on the board and its trigger presses Start · +// 3 a module's own panel made with api.vrPanel does, and ends the beam · 4 the sentinel ran, +// and leaves the scene when no panel is open. +const h = require('./helpers.cjs'); +const xr = require('./fakeXR.cjs'); + +h.run(async () => { + const browser = await h.launch({ args: h.GPU_ARGS }); + const A = await h.setupPage(browser, 'A'); + const page = A.page; + await page.evaluate(async () => { + const s = window.__stores; + s.viewModeCtl?.viewMode?.set?.('shaded'); + await s.moduleSDK.initModules([ + { id: 'ontop31', name: 'On top', version: '1.0.0', description: '31 K2', register(api) { window.__api = api; } } + ]); + }); + await xr.install(page); + // park both controllers far off, aiming up: a ray on the ring would HOVER it, and the hover + // orange (#ff4000) sits inside the red tolerance this suite reads + await xr.pose(page, 'left', [-20, 30, 20], { pitch: 1.4 }); + await xr.pose(page, 'right', [20, 30, 20], { pitch: 1.4 }); + const settle = (ms = 400) => page.waitForTimeout(ms); + + // a RED unlit box and the camera looking through it at `at` + const blocker = (at, from, size) => + page.evaluate( + async ({ at, from, size }) => { + const s = window.__stores; + const THREE = s.THREE; + // in objectsGroup: real scene content, so the BEAM meets it too (a scene-root helper + // is no beam target, and the panel-priority guard would go unexercised) + let scene; + s.objectsGroup.subscribe((v) => (scene = v))(); + let box = scene.getObjectByName('k2-blocker'); + if (!box) { + box = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1), new THREE.MeshBasicMaterial({ color: 0xff0000, toneMapped: false })); + box.name = 'k2-blocker'; + scene.add(box); + } + box.scale.setScalar(size); + // halfway between the camera and the target + box.position.set((at[0] + from[0]) / 2, (at[1] + from[1]) / 2, (at[2] + from[2]) / 2); + box.updateMatrixWorld(true); + s.objectActions.flyTo(from, at, 0); + return box.position.toArray(); + }, + { at, from, size } + ); + // fraction of a small square around `at` that is NOT the blocker's red + const notRed = async (at) => { + await settle(500); + const p = await h.projectPoint(page, at); + const frame = await page.screenshot({ clip: { x: Math.round(p.x - 6), y: Math.round(p.y - 6), width: 12, height: 12 } }); + return (await h.framePixelsOffColor(page, frame, [255, 0, 0], 70)).fraction; + }; + + console.log('\n=== 1 the radial ring over a box ==='); + // the ring sits at the origin facing +z when no real session poses it + await page.evaluate(() => window.__stores.vrMenuOpen.set(true)); + await settle(); + // counter-premise: the same box hides a plain mesh at the same place + await page.evaluate(() => { + const s = window.__stores; + const THREE = s.THREE; + let scene; + s.globalScene.subscribe((v) => (scene = v))(); + const plain = new THREE.Mesh(new THREE.PlaneGeometry(0.2, 0.2), new THREE.MeshBasicMaterial({ color: 0x00ff00 })); + plain.name = 'k2-plain'; + plain.position.set(3, 0, 0); + scene.add(plain); + }); + await blocker([3, 0, 0], [3, 0, 0.8], 0.05); + const plainOff = await notRed([3, 0, 0]); + h.check(plainOff < 0.2, `premise: the box hides an ordinary mesh behind it (${plainOff.toFixed(2)} not red)`); + await blocker([0, 0, 0], [0, 0, 0.8], 0.05); + const ringOff = await notRed([0, 0, 0]); + const hov = await page.evaluate(() => { let v; window.__stores.vrControls.vrHovered.subscribe((x) => (v = x))(); return v; }); + h.check(hov === null, `premise: nothing on the ring is hovered (${hov})`); + h.check(ringOff > 0.8, `the radial ring draws OVER the box in front of it (${ringOff.toFixed(2)} not red)`); + await page.evaluate(() => window.__stores.vrMenuOpen.set(false)); + + console.log('\n=== 2 the VR game board over a box, and the laser presses through it ==='); + await page.evaluate(() => { + const s = window.__stores; + s.hudDocs.setHudDocFor('scene', { + active: '', + screens: [ + { + id: 'menu', name: 'Menu', showWhile: 'menu', input: 'menu', + elements: [{ id: 'start', kind: 'button', anchor: 'center', x: 0, y: 0, w: 420, h: 160, label: 'Start', style: { bg: '#2563eb', size: 40 } }] + } + ] + }); + s.gameState.setGameState('menu'); + s.hudActions.addBinding('start', 'start'); + s.objectActions.setEditorMode('interact'); + }); + const board = await page.evaluate(() => { + const s = window.__stores; + const THREE = s.THREE; + const k = s.gameKit.vrGamePanel; + k.vrGamePanelFrame({ head: { position: new THREE.Vector3(0, 1.6, 0), quaternion: new THREE.Quaternion() }, hands: [null, null] }); + const surf = k.vrGameSurface('vr-game-panel'); + const rect = k.vrGamePanelDebug().hits['vr-game-panel'].find((x) => x.id === 'start'); + const g = surf.mesh.geometry.parameters; + const at = surf.mesh.localToWorld(new THREE.Vector3(((rect.x + rect.w / 2) / surf.canvas.width - 0.5) * g.width, (0.5 - (rect.y + rect.h / 2) / surf.canvas.height) * g.height, 0)); + return { at: at.toArray(), order: surf.mesh.renderOrder }; + }); + await blocker(board.at, [board.at[0], board.at[1], board.at[2] + 1.4], 0.08); + const boardOff = await notRed(board.at); + h.check(boardOff > 0.8, `the game board's Start draws OVER the box (${boardOff.toFixed(2)} not red, order ${board.order})`); + // the laser from behind the box: the beam ends on the board, and the trigger presses Start + const press = await page.evaluate(async (at) => { + const s = window.__stores; + const THREE = s.THREE; + const r = window.__fakeXR.renderer; + const c = r.xr.getController(1); + const from = new THREE.Vector3(at[0], at[1], at[2] + 1.4); + const m = new THREE.Matrix4().lookAt(from, new THREE.Vector3(...at), new THREE.Vector3(0, 1, 0)); + c.matrix.compose(from, new THREE.Quaternion().setFromRotationMatrix(m), new THREE.Vector3(1, 1, 1)); + c.updateMatrixWorld(true); + await new Promise((res) => setTimeout(res, 250)); + let beam = null; + c.traverse((o) => { if (o.name === 'vr-ray') beam = o; }); + const length = beam.scale.z; + const order = beam.renderOrder; + s.vrControls.vrModuleTriggerStart(1); + s.vrControls.vrModuleSelectSwallowed(); + s.vrControls.vrModuleTriggerEnd(1); + await new Promise((res) => setTimeout(res, 300)); + let state; + s.gameState.gameState.subscribe((v) => (state = v.state))(); + return { length, order, state }; + }, board.at); + h.check(Math.abs(press.length - 1.4) < 0.03, `the beam passes the box and ends on the board (${press.length.toFixed(3)} m)`); + h.check(press.order > board.order, `on a panel the beam draws after it (order ${press.order})`); + h.check(press.state === 'playing', `the trigger presses Start through the box (${press.state})`); + await page.evaluate(() => { + const s = window.__stores; + s.gameKit.vrGamePanel.hideVrGamePanel(); + s.objectActions.setEditorMode('edit'); + s.gameState.setGameState('menu'); + }); + + console.log('\n=== 3 a module panel made with api.vrPanel ==='); + const mod = await page.evaluate(() => { + const s = window.__stores; + const api = window.__api; + const THREE = api.THREE; + const g = new THREE.Group(); + g.name = 'k2-module-bar'; + const m = new THREE.Mesh(new THREE.PlaneGeometry(0.3, 0.12), new THREE.MeshBasicMaterial({ color: 0x00ff66 })); + g.add(m); + g.position.set(-3, 1, 0); + api.scene().add(g); + const off = api.vrPanel(g); + return { has: typeof api.vrPanel, off: typeof off, order: m.renderOrder }; + }); + h.check(mod.has === 'function' && mod.off === 'function', 'api.vrPanel exists and returns an undo'); + await blocker([-3, 1, 0], [-3, 1, 0.8], 0.05); + const modOff = await notRed([-3, 1, 0]); + h.check(modOff > 0.8, `the module's panel draws OVER the box (${modOff.toFixed(2)} not red, order ${mod.order})`); + const modBeam = await page.evaluate(async () => { + const s = window.__stores; + const THREE = s.THREE; + const c = window.__fakeXR.renderer.xr.getController(1); + const from = new THREE.Vector3(-3, 1, 0.9); + const m = new THREE.Matrix4().lookAt(from, new THREE.Vector3(-3, 1, 0), new THREE.Vector3(0, 1, 0)); + c.matrix.compose(from, new THREE.Quaternion().setFromRotationMatrix(m), new THREE.Vector3(1, 1, 1)); + c.updateMatrixWorld(true); + await new Promise((res) => setTimeout(res, 250)); + let beam = null; + c.traverse((o) => { if (o.name === 'vr-ray') beam = o; }); + return beam.scale.z; + }); + h.check(Math.abs(modBeam - 0.9) < 0.03, `the beam passes the box and ends on the module panel (${modBeam.toFixed(3)} m)`); + + console.log('\n=== 4 the sentinel ==='); + const sent = await page.evaluate(() => window.__stores.vrControls.panelOverlayDebug()); + h.check(sent.clears > 0 && sent.visible, `the depth clear ran while panels were open (${sent.clears} clears)`); + await page.evaluate(() => { + const s = window.__stores; + let scene; + s.globalScene.subscribe((v) => (scene = v))(); + scene.remove(scene.getObjectByName('k2-module-bar')); + }); + await settle(); + const after = await page.evaluate(() => window.__stores.vrControls.panelOverlayDebug()); + h.check(!after.visible, 'no panel open: the sentinel stands down (the frame is the old frame)'); + + await xr.uninstall(page); + await h.finish(browser); +}); From 778b9f7c32f35ff59221a74ba133693b4be1c177 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Thu, 1 Oct 2026 04:07:32 +0300 Subject: [PATCH 4/8] [feat] 31 P3 U1/J1: K1 locomotion flags - worldGrab gives a game the world gestures; play.bounds The Quest reports: "Entangle game does not allow me to scale scene with grips within game, only in edit mode" and "In Jam Room I would like to be able to fly around and also scale the entire environment with grips and move around same as in edit mode". - locomotionPolicy: `play.locomotion.worldGrab: true` -> Interact's worldGestures; the normaliser keeps exactly the typed booleans teleport / fly / worldGrab (empty -> absent, so a scene that never used them saves byte-identically). - vrGrip.gripMovesWorld(mode, worldGrab): Edit always, Interact only with the flag. vrControls' empty-air grip asks it with the live policy - so two grips scale/rotate the world, the right grip alone pans it, and a grip on a grabbable body (dynamic, interaction 'grab') still takes the BODY (pickGripTarget runs first, unchanged). - teleportRules.js (new pure leaf, P4's rules) - here for normalizeBounds: `play.bounds {min:[x,y,z], max:[x,y,z]}` is a SIBLING of locomotion; six finite numbers |v| <= 1e5, each axis ordered, else dropped. scenePhysics keeps it in the play block (omitted when absent); resolvePlaySettings resolves it field-by-field like spawn, a publisher's in ITS local frame (`boundsOwner`). Vitest teleportRules (18): bounds, shrink, slab test, raster segment, the verdict order. - moduleSDK: `api.locomotion = {boundedTeleport: true, worldGrab: true}` (frozen) - the feature probe the game lanes publish against (agreed with 31-fb-dungeon and 31-stars-jam). - wireValidate: no change - scenephysics has no shape there, and the singleton's own normaliser is its boundary. Suite vr-locomotion-flags (13, fakeXR through the real grip path): the normaliser (typed flags only, bounds ordered, bad bounds dropped, untouched scene writes neither key); WITHOUT the flag Interact's two grips start nothing and the world stays 1:1; WITH it two grips start the world grab and a spread scales x2.00, a right grip on the floor pans, a grip on the dynamic cube holds the cube; api.locomotion probe; a module publisher's userData.play turns it on. Vitest +5 (locomotionPolicy 2, vrGrip 1, + the teleportRules file): 296 green (base 275). Counterfactuals (one batch, restored): gripMovesWorld(mode) -> 5 red; worldGrab dropped by the normaliser -> 1 red; bounds not normalised -> 1 red. 7 red. svelte-check 333/47 (identical list). Co-Authored-By: Claude Opus 5.5 (1M context) --- src/lib/locomotionPolicy.js | 26 ++-- src/lib/moduleSDK.js | 8 ++ src/lib/playSettings.js | 19 ++- src/lib/scenePhysics.js | 7 +- src/lib/teleportRules.js | 160 +++++++++++++++++++++++++ src/lib/vrControls.js | 5 +- src/lib/vrGrip.js | 9 +- tests/e2e/vr-locomotion-flags.test.cjs | 151 +++++++++++++++++++++++ tests/unit/locomotionPolicy.test.js | 18 +++ tests/unit/teleportRules.test.js | 104 ++++++++++++++++ tests/unit/vrGrip.test.js | 9 ++ 11 files changed, 497 insertions(+), 19 deletions(-) create mode 100644 src/lib/teleportRules.js create mode 100644 tests/e2e/vr-locomotion-flags.test.cjs create mode 100644 tests/unit/teleportRules.test.js diff --git a/src/lib/locomotionPolicy.js b/src/lib/locomotionPolicy.js index e59afbd8..fef3a186 100644 --- a/src/lib/locomotionPolicy.js +++ b/src/lib/locomotionPolicy.js @@ -7,10 +7,17 @@ // unless the scene's play block allows it (`play.locomotion: {teleport?, fly?}`, absent = // false). The Quest report this answers: "In [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." +// +// 31 K1 adds `worldGrab`: in Interact/Play the grips move/rotate/SCALE the world exactly +// like Edit's world gestures, whenever a grip does not start on something a player may hold +// (a grip on a grabbable body still takes it). "Entangle game does not allow me to scale +// scene with grips within game, only in edit mode" / "In Jam Room ... scale the entire +// environment with grips and move around same as in edit mode". Teleport, when allowed, is +// BOUNDED in Interact/Play (teleportRules.js). /** * @param {'edit' | 'interact'} mode - * @param {{teleport?: boolean, fly?: boolean} | null | undefined} locomotion the resolved play block + * @param {{teleport?: boolean, fly?: boolean, worldGrab?: boolean} | null | undefined} locomotion the resolved play block * @returns {{walk: boolean, fly: boolean, teleport: boolean, collide: boolean, gravity: boolean, worldGestures: boolean}} */ export function locomotionPolicy(mode, locomotion) { @@ -25,21 +32,24 @@ export function locomotionPolicy(mode, locomotion) { teleport: locomotion?.teleport === true, collide: true, gravity: !fly, - worldGestures: false + // 31 K1: the world gestures come back only when the scene or a module asks + worldGestures: locomotion?.worldGrab === true }; } /** - * `play.locomotion` at a store boundary: only booleans survive, and an empty block is - * ABSENT (so a scene that never used it saves byte-identically). - * @param {any} raw @returns {{teleport?: boolean, fly?: boolean} | null} + * `play.locomotion` at a store boundary: only the typed booleans survive (`teleport`, `fly`, + * 31 K1's `worldGrab`), and an empty block is ABSENT (so a scene that never used it saves + * byte-identically). `play.bounds` is a SIBLING (teleportRules.normalizeBounds). + * @param {any} raw @returns {{teleport?: boolean, fly?: boolean, worldGrab?: boolean} | null} */ export function normalizeLocomotion(raw) { if (!raw || typeof raw !== 'object') return null; - /** @type {{teleport?: boolean, fly?: boolean}} */ + /** @type {{teleport?: boolean, fly?: boolean, worldGrab?: boolean}} */ const out = {}; if (typeof raw.teleport === 'boolean') out.teleport = raw.teleport; if (typeof raw.fly === 'boolean') out.fly = raw.fly; + if (typeof raw.worldGrab === 'boolean') out.worldGrab = raw.worldGrab; return Object.keys(out).length ? out : null; } @@ -53,8 +63,8 @@ const SPAWN_LIMIT = 100000; * @returns {{position: [number, number, number], yaw: number} | null} */ export function normalizeSpawn(raw, yawArg) { - // 30c's first shape was `{pos, yaw}` (the level lane); read it too so a scene authored - // that way keeps its spawn + // 30c's first shape was `{pos, yaw}` (the level lane); read it too so a scene authored + // that way keeps its spawn const position = Array.isArray(raw) ? raw : (raw?.position ?? raw?.pos); if (!Array.isArray(position) || position.length < 3) return null; const p = position.slice(0, 3).map(Number); diff --git a/src/lib/moduleSDK.js b/src/lib/moduleSDK.js index 87c2cbae..714f5959 100644 --- a/src/lib/moduleSDK.js +++ b/src/lib/moduleSDK.js @@ -1044,6 +1044,14 @@ function makeApi(moduleId, moduleName = moduleId) { hitLog() { return knockRef?.hitLogSnapshot?.() ?? { last: {}, recent: [] }; }, + /** + * 31 K1: what this core's locomotion understands, for a module to feature-detect before + * it publishes `userData.play.locomotion` / `play.bounds`. `boundedTeleport`: a + * `teleport: true` in Interact/Play lands only on walkable ground inside `play.bounds` + * (else the content bounds) and never through a wall; `worldGrab`: `worldGrab: true` + * gives the grips Edit's world gestures in Interact/Play. An older core has no object. + */ + locomotion: Object.freeze({ boundedTeleport: true, worldGrab: true }), /** In a VR session right now? (DEVX #6) @returns {boolean} */ isVR() { return !!get(isVRMode); diff --git a/src/lib/playSettings.js b/src/lib/playSettings.js index 973aed71..49887e55 100644 --- a/src/lib/playSettings.js +++ b/src/lib/playSettings.js @@ -2,6 +2,7 @@ import { get, writable } from 'svelte/store'; import { scenePlay } from './scenePhysics'; import { showToast } from '../stores/appStore'; import { normalizeLocomotion, normalizeSpawn } from './locomotionPolicy'; +import { normalizeBounds } from './teleportRules'; import { moduleWorldChildren } from './moduleWorld'; /** @@ -75,7 +76,11 @@ export function playPublishers(scene) { * allows them — field by field, like `grounded`) and `spawn` (the runtime api.setSpawn, * else a publisher's `userData.play.spawn`, else the scene's `play.spawn`, else null). * @returns {{interaction: 'grab'|'click'|'off', grounded: boolean, eyeHeight: number, cursor: 'free'|'locked', - * locomotion: {teleport: boolean, fly: boolean}, spawn: {position: [number, number, number], yaw: number} | null}} + * locomotion: {teleport: boolean, fly: boolean, worldGrab: boolean}, spawn: {position: [number, number, number], yaw: number} | null, + * bounds: {min: [number, number, number], max: [number, number, number]} | null, boundsOwner: any}} + * 31 K1: `locomotion.worldGrab` and `bounds` (`play.bounds {min, max}`, the bounded teleport's + * area) — a publisher's bounds are in its own LOCAL frame, which `boundsOwner` names (null = + * the scene's, in objectsGroup's frame). */ export function resolvePlaySettings(scene) { const base = get(scenePlay); @@ -85,8 +90,11 @@ export function resolvePlaySettings(scene) { grounded: base.grounded, eyeHeight: DEFAULT_EYE_HEIGHT, cursor: base.cursor === 'free' ? 'free' : 'locked', - locomotion: { teleport: false, fly: false }, - spawn: normalizeSpawn(base.spawn) + locomotion: { teleport: false, fly: false, worldGrab: false }, + spawn: normalizeSpawn(base.spawn), + // 31 K1: the teleport play area (scene frame), or a publisher's in ITS local frame + bounds: normalizeBounds(base.bounds), + boundsOwner: null }; const baseLoco = normalizeLocomotion(base.locomotion); if (baseLoco) Object.assign(out.locomotion, baseLoco); @@ -111,6 +119,11 @@ export function resolvePlaySettings(scene) { if (loco) Object.assign(out.locomotion, loco); const spawn = normalizeSpawn(play.spawn); if (spawn) out.spawn = spawn; + const bounds = normalizeBounds(play.bounds); + if (bounds) { + out.bounds = bounds; + out.boundsOwner = publisher; + } } const runtime = get(runtimeSpawn); if (runtime) out.spawn = { position: runtime.position, yaw: runtime.yaw }; diff --git a/src/lib/scenePhysics.js b/src/lib/scenePhysics.js index 43491845..65b442a9 100644 --- a/src/lib/scenePhysics.js +++ b/src/lib/scenePhysics.js @@ -2,6 +2,7 @@ import { writable, derived, get } from 'svelte/store'; import { sessionNow } from './sessionClock'; // 25-E: stamps another peer compares import { peers } from '../stores/appStore'; import { normalizeLocomotion, normalizeSpawn } from './locomotionPolicy'; // 30b P3/P4 +import { normalizeBounds } from './teleportRules'; // 31 K1 // CL-A A6 / 21-B B1: scene-wide physics settings. ONE shared object for the // whole session, replicated as its OWN latest-wins singleton message (the @@ -159,9 +160,11 @@ export function normalizeScenePhysics(raw) { // 30b P3/P4: present only when authored (locomotionPolicy's normalizers), so a // scene that never used them stays byte-identical ...optional('locomotion', normalizeLocomotion(playRaw.locomotion)), - ...optional('spawn', normalizeSpawn(playRaw.spawn)) + ...optional('spawn', normalizeSpawn(playRaw.spawn)), + // 31 K1: the bounded teleport's area, present only when authored + ...optional('bounds', normalizeBounds(playRaw.bounds)) }, - ['interaction', 'grounded', 'simOnPlay', 'cursor', 'locomotion', 'spawn'] + ['interaction', 'grounded', 'simOnPlay', 'cursor', 'locomotion', 'spawn', 'bounds'] ), // A1: the 20 ceiling is throwVelocity's MAX_LINVEL, restated rather than imported — // this module is store-only and the response clamps through clampThrow anyway diff --git a/src/lib/teleportRules.js b/src/lib/teleportRules.js new file mode 100644 index 00000000..9320de04 --- /dev/null +++ b/src/lib/teleportRules.js @@ -0,0 +1,160 @@ +// 31 K1 (D2/S1): WHERE A PLAYER MAY TELEPORT — a pure leaf (imports nothing), so every rule is +// unit-tested with no headset, no scene and no physics. +// +// The Quest reports: "Dungeon Realms ... I would like to be able to teleport but not outside +// the dungeon walls" and "In Stars room I would like to be able to teleport but not outside +// the scene". Edit's teleport lands anywhere an arc touches (a top face or the y = 0 plane); +// a GAME's teleport (Interact/Play, `play.locomotion.teleport: true`) is BOUNDED: +// 1 the landing is WALKABLE — a surface whose world normal points up (y >= 0.7), or the +// scene's floor plane; +// 2 it lies INSIDE the play area — `play.bounds {min, max}` when a scene or a publisher +// gives one, else the scene's content bounds shrunk by 0.3 m on x and z; +// 3 a dungeon RASTER (dungeonPlay's grid) says the target cell is floor AND no wall cell +// lies on the straight line from the player to it; +// 4 nothing SOLID is crossed on the way — the straight segment 1.1 m above both ends must +// miss every published collider box (and, at runtime, every mesh). +// The first rule that fails names the verdict; an invalid target draws the arc red and a +// release does nothing. + +/** a landing surface's world normal.y must reach this (about 45 degrees) */ +export const WALKABLE_NORMAL_Y = 0.7; +/** content bounds are shrunk by this on x and z when no play.bounds is given */ +export const BOUNDS_INSET = 0.3; +/** the wall probe runs this high above both ends (a knee-high rim can be teleported over) */ +export const PROBE_HEIGHT = 1.1; +/** raster samples along the segment, metres */ +export const RASTER_STEP = 0.25; +/** the body radius a raster floor cell must hold at the target */ +export const RASTER_RADIUS = 0.2; + +const LIMIT = 100000; + +/** + * `{min:[x,y,z], max:[x,y,z]}` at a store/api boundary: six finite numbers within the spawn + * limit, each axis ordered (a swapped pair is put right). Anything else is null. + * @param {any} raw @returns {{min: [number, number, number], max: [number, number, number]} | null} + */ +export function normalizeBounds(raw) { + const lo = raw?.min; + const hi = raw?.max; + if (!Array.isArray(lo) || !Array.isArray(hi) || lo.length < 3 || hi.length < 3) return null; + const a = lo.slice(0, 3).map(Number); + const b = hi.slice(0, 3).map(Number); + if (![...a, ...b].every((v) => Number.isFinite(v) && Math.abs(v) <= LIMIT)) return null; + return { + min: /** @type {[number, number, number]} */ ([Math.min(a[0], b[0]), Math.min(a[1], b[1]), Math.min(a[2], b[2])]), + max: /** @type {[number, number, number]} */ ([Math.max(a[0], b[0]), Math.max(a[1], b[1]), Math.max(a[2], b[2])]) + }; +} + +/** + * The content box pulled in by `inset` on x and z (y is left alone: a floor sits AT the + * box's bottom). A box narrower than twice the inset collapses onto its centre line. + * @param {{min: number[], max: number[]}} box @param {number} [inset] + * @returns {{min: [number, number, number], max: [number, number, number]}} + */ +export function shrinkBox(box, inset = BOUNDS_INSET) { + /** @param {number} i */ + const axis = (i) => { + const lo = box.min[i] + inset; + const hi = box.max[i] - inset; + if (lo <= hi) return [lo, hi]; + const mid = (box.min[i] + box.max[i]) / 2; + return [mid, mid]; + }; + const [x0, x1] = axis(0); + const [z0, z1] = axis(2); + return { min: [x0, box.min[1], z0], max: [x1, box.max[1], z1] }; +} + +/** + * @param {{x: number, y: number, z: number}} p @param {{min: number[], max: number[]}} box + * @param {number} [eps] + */ +export function inBox(p, box, eps = 1e-6) { + return ( + p.x >= box.min[0] - eps && p.x <= box.max[0] + eps && + p.y >= box.min[1] - eps && p.y <= box.max[1] + eps && + p.z >= box.min[2] - eps && p.z <= box.max[2] + eps + ); +} + +/** + * Does the segment a -> b cross the box? (the slab test over t in [0, 1]) + * @param {{x: number, y: number, z: number}} a @param {{x: number, y: number, z: number}} b + * @param {{min: number[], max: number[]}} box + */ +export function segmentHitsBox(a, b, box) { + let t0 = 0; + let t1 = 1; + const from = [a.x, a.y, a.z]; + const d = [b.x - a.x, b.y - a.y, b.z - a.z]; + for (let i = 0; i < 3; i++) { + if (Math.abs(d[i]) < 1e-12) { + if (from[i] < box.min[i] || from[i] > box.max[i]) return false; + continue; + } + let near = (box.min[i] - from[i]) / d[i]; + let far = (box.max[i] - from[i]) / d[i]; + if (near > far) [near, far] = [far, near]; + t0 = Math.max(t0, near); + t1 = Math.min(t1, far); + if (t0 > t1) return false; + } + return true; +} + +/** + * Every RASTER_STEP along a -> b (x/z only) must be a floor cell for a point body (the + * target itself is tested with a body radius by the caller). `walkable(raster, x, z, r)` is + * dungeonPlay's rule, passed in so this leaf imports nothing. + * @param {any} raster @param {{x: number, z: number}} a @param {{x: number, z: number}} b + * @param {(raster: any, x: number, z: number, r?: number) => boolean} walkable + */ +export function rasterSegmentClear(raster, a, b, walkable) { + const length = Math.hypot(b.x - a.x, b.z - a.z); + const steps = Math.max(1, Math.ceil(length / RASTER_STEP)); + // start one step OUT from the player (you may be brushing a wall where you stand) + for (let i = 1; i <= steps; i++) { + const t = i / steps; + if (!walkable(raster, a.x + (b.x - a.x) * t, a.z + (b.z - a.z) * t, 0)) return false; + } + return true; +} + +/** + * THE VERDICT. Every input but `to` is optional; an absent rule passes. + * @param {{ + * from?: {x: number, y: number, z: number} | null, + * to: {x: number, y: number, z: number}, + * normalY?: number, + * bounds?: {min: number[], max: number[]} | null, + * colliders?: {min: number[], max: number[]}[] | null, + * raster?: any, + * walkable?: ((raster: any, x: number, z: number, r?: number) => boolean) | null, + * meshBlocked?: boolean + * }} input + * @returns {{ok: boolean, reason: 'ok' | 'steep' | 'outside' | 'off-floor' | 'wall-cell' | 'blocked'}} + */ +export function teleportVerdictPure(input) { + const { from, to } = input; + if (!to || ![to.x, to.y, to.z].every(Number.isFinite)) return { ok: false, reason: 'off-floor' }; + if ((input.normalY ?? 1) < WALKABLE_NORMAL_Y) return { ok: false, reason: 'steep' }; + if (input.bounds && !inBox(to, input.bounds)) return { ok: false, reason: 'outside' }; + if (input.raster && input.walkable) { + if (!input.walkable(input.raster, to.x, to.z, RASTER_RADIUS)) return { ok: false, reason: 'off-floor' }; + if (from && !rasterSegmentClear(input.raster, from, to, input.walkable)) return { ok: false, reason: 'wall-cell' }; + } + if (from && input.colliders?.length) { + const a = { x: from.x, y: from.y + PROBE_HEIGHT, z: from.z }; + const b = { x: to.x, y: to.y + PROBE_HEIGHT, z: to.z }; + for (const box of input.colliders) { + // a box the player is already standing in (a doorway's lintel, a room volume) is not + // a wall between here and there + if (inBox(a, box, -1e-6)) continue; + if (segmentHitsBox(a, b, box)) return { ok: false, reason: 'blocked' }; + } + } + if (input.meshBlocked) return { ok: false, reason: 'blocked' }; + return { ok: true, reason: 'ok' }; +} diff --git a/src/lib/vrControls.js b/src/lib/vrControls.js index 220f056c..0b1931b0 100644 --- a/src/lib/vrControls.js +++ b/src/lib/vrControls.js @@ -2595,8 +2595,9 @@ function onSqueezeStart(index) { const mode = get(editorMode) === 'interact' ? 'interact' : 'edit'; let object = gripTargetOf(controllerRay(index), controller.getWorldPosition(new THREE.Vector3()), mode); if (!object) { - // 30b P2: Interact's grips never move the world (contract C1) - if (!gripMovesWorld(mode)) return; + // 30b P2: Interact's grips never move the world (contract C1) — 31 K1: unless the play + // block allows it (`locomotion.worldGrab`); a grip on a grabbable still took it above + if (!gripMovesWorld(mode, mode === 'interact' && vrLocomotionNow().worldGestures)) return; emptyAirSqueeze[index] = true; // 186: in stretch mode both grips drive the stretch, not a world grab if (get(vrStretchObject)) return; diff --git a/src/lib/vrGrip.js b/src/lib/vrGrip.js index a4aaba3a..0eb38228 100644 --- a/src/lib/vrGrip.js +++ b/src/lib/vrGrip.js @@ -66,9 +66,10 @@ export function pickGripTarget(candidates, mode) { } /** - * Does an empty-air grip move the world in this mode? Only Edit's does. - * @param {'edit' | 'interact'} mode + * Does an empty-air grip move the world in this mode? Edit's always does; Interact's only + * when the play block says `locomotion.worldGrab` (31 K1 — Untangle, Jam Room). + * @param {'edit' | 'interact'} mode @param {boolean} [worldGrab] */ -export function gripMovesWorld(mode) { - return mode !== 'interact'; +export function gripMovesWorld(mode, worldGrab = false) { + return mode !== 'interact' || worldGrab === true; } diff --git a/tests/e2e/vr-locomotion-flags.test.cjs b/tests/e2e/vr-locomotion-flags.test.cjs new file mode 100644 index 00000000..be653434 --- /dev/null +++ b/tests/e2e/vr-locomotion-flags.test.cjs @@ -0,0 +1,151 @@ +// 31 K1 (U1/J1) — THE GRIPS MOVE THE WORLD IN A GAME, WHEN THE GAME SAYS SO. The user, on a +// Quest 3: "Entangle game does not allow me to scale scene with grips within game, only in +// edit mode" and "In Jam Room I would like to be able to fly around and also scale the entire +// environment with grips and move around same as in edit mode". +// +// `play.locomotion.worldGrab: true` gives Interact/Play Edit's world gestures on any grip +// that does not start on something a player may hold. Through fakeXR (the real per-frame +// grip path), a game-shaped room: 1 the typed normaliser keeps worldGrab/teleport/fly and +// nothing else, and play.bounds survives the scene store · 2 WITHOUT the flag Interact's +// grips never move the world (the 30b rule, the counterfactual) · 3 WITH it two grips on the +// wall scale the world and one right grip pans it · 4 a grip on a grabbable still takes the +// BODY, not the world · 5 a module publisher can turn it on (userData.play) and the SDK +// probe says so. (`fly` is the 30b walker flag, unchanged and covered by vr-walk-interact.) +const h = require('./helpers.cjs'); +const xr = require('./fakeXR.cjs'); + +const state = (page) => + page.evaluate(() => { + const s = window.__stores; + const get = (store) => { let v; store.subscribe((x) => (v = x))(); return v; }; + const rig = get(s.worldRig); + return { ...s.vrControls.vrGripDebug(), rigScale: rig.scale.x, rigPos: rig.position.toArray(), mode: get(s.editorMode) }; + }); +const grip = async (page, hand, down) => { + await xr.button(page, hand, 1, down); + await page.waitForTimeout(250); +}; +const setPlay = (page, play) => + page.evaluate((play) => { + window.__stores.scenePhysics.setScenePhysics({ play }); + }, play); + +h.run(async () => { + const browser = await h.launch({ args: h.GPU_ARGS }); + const A = await h.setupPage(browser, 'A'); + const page = A.page; + + // ---- 1 the normaliser -------------------------------------------------------------------- + const norm = await page.evaluate(() => { + const s = window.__stores; + const n = s.scenePhysics.normalizeScenePhysics({ + play: { locomotion: { worldGrab: true, teleport: true, fly: false, walkOnWater: true, jet: 'yes' }, bounds: { min: [4, 0, -4], max: [-4, 3, 4] } } + }); + const bad = s.scenePhysics.normalizeScenePhysics({ play: { bounds: { min: [0, 0, 'x'], max: [1, 1, 1] } } }); + const plain = s.scenePhysics.normalizeScenePhysics({}); + return { loco: n.play.locomotion, bounds: n.play.bounds, bad: 'bounds' in bad.play && bad.play.bounds !== undefined ? bad.play.bounds : 'absent', plainKeys: Object.keys(plain.play) }; + }); + h.check(JSON.stringify(norm.loco) === JSON.stringify({ teleport: true, fly: false, worldGrab: true }), `locomotion keeps the typed flags only (${JSON.stringify(norm.loco)})`); + h.check(JSON.stringify(norm.bounds) === JSON.stringify({ min: [-4, 0, -4], max: [4, 3, 4] }), `play.bounds survives, each axis ordered (${JSON.stringify(norm.bounds)})`); + h.check(norm.bad === 'absent' || norm.bad === null, `a bad bounds is dropped (${JSON.stringify(norm.bad)})`); + h.check(!norm.plainKeys.includes('bounds') && !norm.plainKeys.includes('locomotion'), 'a scene that never used them saves neither key'); + + // ---- the fixture: a floor, a wall ahead, a dynamic cube ----------------------------------- + const ids = await page.evaluate(async () => { + const s = window.__stores; + const get = (store) => { let v; store.subscribe((x) => (v = x))(); return v; }; + for (let i = 0; i < 3; i++) s.commandsHandler.sceneCommand('/create box'); + await new Promise((r) => setTimeout(r, 600)); + const [floor, wall, cube] = get(s.objectsGroup).children.filter((c) => c.name === 'Box'); + floor.scale.set(20, 0.5, 20); + floor.position.set(0, -0.25, 0); + floor.userData.physics = { mode: 'static' }; + wall.scale.set(10, 4, 0.5); + wall.position.set(0, 2, -3); + wall.userData.physics = { mode: 'static' }; + cube.scale.setScalar(0.4); + cube.position.set(0, 1.2, -1.5); + cube.userData.physics = { mode: 'dynamic', mass: 1 }; + for (const b of [floor, wall, cube]) b.updateMatrixWorld(true); + s.objectActions.deselectObject(); + s.isVRMode.set(true); + s.scenePhysics.setScenePhysics({ play: { interaction: 'grab' } }); + return { cube: cube.uuid }; + }); + await page.waitForTimeout(600); + await xr.install(page); + const aimWall = async () => { + await xr.pose(page, 'left', [-0.3, 1.6, 0]); + await xr.pose(page, 'right', [0.3, 1.6, 0]); + }; + const spread = async () => { + await xr.pose(page, 'left', [-0.6, 1.6, 0]); + await xr.pose(page, 'right', [0.6, 1.6, 0]); + await page.waitForTimeout(300); + }; + await page.evaluate(() => window.__stores.objectActions.setEditorMode('interact')); + + // ---- 2 without the flag: the world stays put (the counterfactual) ------------------------- + await aimWall(); + await grip(page, 'left', true); + await grip(page, 'right', true); + let s = await state(page); + h.check(s.mode === 'interact' && !s.worldGrab, `no flag: Interact's two grips start no world gesture (${s.worldGrab})`); + await spread(); + s = await state(page); + h.check(Math.abs(s.rigScale - 1) < 1e-6, `no flag: the world stays 1:1 (x${s.rigScale})`); + await grip(page, 'left', false); + await grip(page, 'right', false); + + // ---- 3 with it: scale + pan --------------------------------------------------------------- + await setPlay(page, { locomotion: { worldGrab: true } }); + await aimWall(); + await grip(page, 'left', true); + await grip(page, 'right', true); + s = await state(page); + h.check(s.worldGrab && s.grab === null, 'worldGrab: two grips on the wall start the world grab'); + await spread(); + s = await state(page); + h.check(s.rigScale > 1.8, `worldGrab: spreading the hands scales the world (x${s.rigScale.toFixed(2)})`); + await grip(page, 'left', false); + await grip(page, 'right', false); + await page.evaluate(() => window.__stores.vrControls.resetWorldRig()); + await xr.pose(page, 'right', [0.3, 1.2, 0], { pitch: -Math.PI / 2 }); + await grip(page, 'right', true); + s = await state(page); + h.check(s.worldPan && s.grab === null, `worldGrab: one right grip on the floor pans the world (${s.worldPan})`); + await grip(page, 'right', false); + + // ---- 4 a grip on the grabbable (dynamic) cube still takes the cube --------------------------- + await xr.pose(page, 'right', [0, 1.2, 0]); + await grip(page, 'right', true); + s = await state(page); + h.check(s.grab === ids.cube && !s.worldPan && !s.worldGrab, `worldGrab: a grip on the cube holds the CUBE (${s.grab})`); + await grip(page, 'right', false); + + // ---- 5 a module publisher turns it on; the SDK says it can ------------------------------ + await setPlay(page, { locomotion: {} }); + const probe = await page.evaluate(async () => { + const s = window.__stores; + let api; + await s.moduleSDK.initModules([{ id: 'loco31', name: 'Loco', version: '1.0.0', description: '31 K1', register(a) { api = a; } }]); + const g = new api.THREE.Group(); + g.name = 'loco31-root'; + g.userData.play = { locomotion: { worldGrab: true } }; + api.scene().add(g); + const r = s.playSettings.resolvePlaySettings((() => { let sc; s.globalScene.subscribe((v) => (sc = v))(); return sc; })()); + return { flag: api.locomotion, worldGrab: r.locomotion.worldGrab }; + }); + h.check(probe.flag?.worldGrab === true && probe.flag?.boundedTeleport === true, `api.locomotion is the feature probe (${JSON.stringify(probe.flag)})`); + h.check(probe.worldGrab === true, 'a module publisher turns worldGrab on'); + await aimWall(); + await grip(page, 'left', true); + await grip(page, 'right', true); + s = await state(page); + h.check(s.worldGrab, 'publisher worldGrab: the grips move the world'); + await grip(page, 'left', false); + await grip(page, 'right', false); + + await xr.uninstall(page); + await h.finish(browser); +}); diff --git a/tests/unit/locomotionPolicy.test.js b/tests/unit/locomotionPolicy.test.js index e702d5d6..e150218a 100644 --- a/tests/unit/locomotionPolicy.test.js +++ b/tests/unit/locomotionPolicy.test.js @@ -125,3 +125,21 @@ describe('a VR-only spawn', () => { expect(normalizeSpawn([1, 0, -1], 0.5)).toEqual({ position: [1, 0, -1], yaw: 0.5 }); }); }); + +// 31 K1: worldGrab gives Interact the world gestures; the normaliser keeps the typed flags only +describe('31 K1 worldGrab', () => { + it('Interact gets the world gestures only when the play block asks', () => { + expect(locomotionPolicy('interact', null).worldGestures).toBe(false); + expect(locomotionPolicy('interact', { worldGrab: true }).worldGestures).toBe(true); + expect(locomotionPolicy('interact', { worldGrab: false }).worldGestures).toBe(false); + expect(locomotionPolicy('edit', null).worldGestures).toBe(true); + }); + it('normalizeLocomotion keeps teleport, fly and worldGrab booleans and nothing else', () => { + expect(normalizeLocomotion({ worldGrab: true, teleport: false, fly: true, jet: true, bounds: {} })).toEqual({ + teleport: false, + fly: true, + worldGrab: true + }); + expect(normalizeLocomotion({ worldGrab: 'yes' })).toBe(null); + }); +}); diff --git a/tests/unit/teleportRules.test.js b/tests/unit/teleportRules.test.js new file mode 100644 index 00000000..0f1d6c47 --- /dev/null +++ b/tests/unit/teleportRules.test.js @@ -0,0 +1,104 @@ +import { describe, it, expect } from 'vitest'; +import { + normalizeBounds, + shrinkBox, + inBox, + segmentHitsBox, + rasterSegmentClear, + teleportVerdictPure, + WALKABLE_NORMAL_Y +} from '../../src/lib/teleportRules.js'; +import { walkable } from '../../src/lib/dungeonPlay.js'; + +// 31 K1 (D2/S1): the bounded teleport's rules, with no headset and no scene. + +/** @param {number} x @param {number} y @param {number} z */ +const P = (x, y, z) => ({ x, y, z }); + +// a 6 x 6 dungeon raster, origin (0, 0): a room x 1..4, z 1..4 split by a wall at x = 3 +// (cells x = 3, z 1..4), everything else wall +function dungeon() { + const W = 6; + const H = 6; + const grid = new Array(W * H).fill(1); + for (let z = 1; z <= 4; z++) for (let x = 1; x <= 4; x++) grid[z * W + x] = 0; + for (let z = 1; z <= 4; z++) grid[z * W + 3] = 1; + return { grid, width: W, height: H, minX: 0, minY: 0, floorValue: 0 }; +} + +describe('normalizeBounds', () => { + it('keeps six finite numbers and orders each axis', () => { + expect(normalizeBounds({ min: [2, 0, -1], max: [-2, 3, 1] })).toEqual({ min: [-2, 0, -1], max: [2, 3, 1] }); + }); + it('refuses anything else', () => { + expect(normalizeBounds(null)).toBe(null); + expect(normalizeBounds({ min: [0, 0], max: [1, 1, 1] })).toBe(null); + expect(normalizeBounds({ min: [0, 0, NaN], max: [1, 1, 1] })).toBe(null); + expect(normalizeBounds({ min: [0, 0, 0], max: [1e9, 1, 1] })).toBe(null); + expect(normalizeBounds({ min: ['a', 0, 0], max: [1, 1, 1] })).toBe(null); + }); +}); + +describe('shrinkBox', () => { + it('pulls x and z in, leaves y', () => { + expect(shrinkBox({ min: [-5, 0, -5], max: [5, 3, 5] }, 0.3)).toEqual({ min: [-4.7, 0, -4.7], max: [4.7, 3, 4.7] }); + }); + it('a thin box collapses onto its centre line, never inverts', () => { + const b = shrinkBox({ min: [0, 0, 0], max: [0.4, 1, 10] }, 0.3); + expect(b.min[0]).toBeCloseTo(0.2); + expect(b.max[0]).toBeCloseTo(0.2); + expect(b.min[2]).toBeCloseTo(0.3); + }); +}); + +describe('segmentHitsBox', () => { + const wall = { min: [2, 0, -5], max: [2.2, 3, 5] }; + it('a line through the wall hits it', () => expect(segmentHitsBox(P(0, 1.1, 0), P(4, 1.1, 0), wall)).toBe(true)); + it('a line stopping short does not', () => expect(segmentHitsBox(P(0, 1.1, 0), P(1.9, 1.1, 0), wall)).toBe(false)); + it('a line over it does not', () => expect(segmentHitsBox(P(0, 3.5, 0), P(4, 3.5, 0), wall)).toBe(false)); + it('a line beside it does not', () => expect(segmentHitsBox(P(0, 1.1, 6), P(4, 1.1, 6), wall)).toBe(false)); + it('an axis-parallel line inside the slab hits', () => expect(segmentHitsBox(P(2.1, 1, -9), P(2.1, 1, 9), wall)).toBe(true)); +}); + +describe('rasterSegmentClear', () => { + const d = dungeon(); + it('within one side of the room is clear', () => expect(rasterSegmentClear(d, P(1.5, 0, 1.5), P(2.5, 0, 4.5), walkable)).toBe(true)); + it('across the dividing wall is not', () => expect(rasterSegmentClear(d, P(1.5, 0, 2.5), P(4.5, 0, 2.5), walkable)).toBe(false)); +}); + +describe('teleportVerdictPure', () => { + const bounds = { min: [-5, -0.5, -5], max: [5, 0.4, 5] }; + it('a floor target inside the bounds is ok', () => { + expect(teleportVerdictPure({ from: P(0, 0, 0), to: P(2, 0, 2), normalY: 1, bounds })).toEqual({ ok: true, reason: 'ok' }); + }); + it('a steep surface (a wall face) is refused', () => { + expect(teleportVerdictPure({ to: P(1, 1, 0), normalY: WALKABLE_NORMAL_Y - 0.01 }).reason).toBe('steep'); + expect(teleportVerdictPure({ to: P(1, 1, 0), normalY: WALKABLE_NORMAL_Y }).ok).toBe(true); + }); + it('outside the bounds is refused, and a box top above them too', () => { + expect(teleportVerdictPure({ to: P(6, 0, 0), bounds }).reason).toBe('outside'); + expect(teleportVerdictPure({ to: P(0, 1, 0), bounds }).reason).toBe('outside'); + }); + it('a raster: a wall cell is off-floor, a line across the wall is wall-cell', () => { + const raster = dungeon(); + expect(teleportVerdictPure({ from: P(1.5, 0, 2.5), to: P(3.5, 0, 2.5), raster, walkable }).reason).toBe('off-floor'); + expect(teleportVerdictPure({ from: P(1.5, 0, 2.5), to: P(4.5, 0, 2.5), raster, walkable }).reason).toBe('wall-cell'); + expect(teleportVerdictPure({ from: P(1.5, 0, 1.5), to: P(2.5, 0, 3.5), raster, walkable }).ok).toBe(true); + }); + it('a collider between the ends blocks; one you stand inside does not', () => { + const wall = { min: [2, 0, -5], max: [2.2, 3, 5] }; + expect(teleportVerdictPure({ from: P(0, 0, 0), to: P(4, 0, 0), colliders: [wall] }).reason).toBe('blocked'); + expect(teleportVerdictPure({ from: P(0, 0, 0), to: P(1.5, 0, 0), colliders: [wall] }).ok).toBe(true); + const room = { min: [-3, 0, -3], max: [3, 3, 3] }; + expect(teleportVerdictPure({ from: P(0, 0, 0), to: P(2, 0, 2), colliders: [room] }).ok).toBe(true); + }); + it('a mesh on the way blocks; nothing named passes', () => { + expect(teleportVerdictPure({ from: P(0, 0, 0), to: P(1, 0, 0), meshBlocked: true }).reason).toBe('blocked'); + expect(teleportVerdictPure({ to: P(1, 0, 0) }).ok).toBe(true); + expect(teleportVerdictPure({ to: P(NaN, 0, 0) }).ok).toBe(false); + }); + it('inBox honours an epsilon on the faces', () => { + expect(inBox(P(5, 0.4, 5), bounds)).toBe(true); + expect(inBox(P(5.01, 0, 0), bounds)).toBe(false); + }); +}); diff --git a/tests/unit/vrGrip.test.js b/tests/unit/vrGrip.test.js index b09e4a80..3d127c82 100644 --- a/tests/unit/vrGrip.test.js +++ b/tests/unit/vrGrip.test.js @@ -63,3 +63,12 @@ describe('gripMovesWorld', () => { expect(gripMovesWorld('interact')).toBe(false); }); }); + +// 31 K1: an Interact grip moves the world only with the play block's worldGrab +describe('31 K1 gripMovesWorld', () => { + it('Edit always, Interact only with worldGrab', () => { + expect(gripMovesWorld('edit')).toBe(true); + expect(gripMovesWorld('interact')).toBe(false); + expect(gripMovesWorld('interact', true)).toBe(true); + }); +}); From efbf1f653d1bc5e58a06c92d86ee87af3fe0390a Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Thu, 1 Oct 2026 04:19:02 +0300 Subject: [PATCH 5/8] [feat] 31 P4 D2/S1: a game's teleport is bounded - walkable, inside the play area, never through a wall; red when refused The Quest reports: "Dungeon Realms ... I would like to be able to teleport but not outside the dungeon walls" and "In Stars room I would like to be able to teleport but not outside the scene". Edit's teleport lands anywhere the arc does; a game's (Interact/Play with `play.locomotion.teleport: true`) asks teleportRules for a verdict now. - computeTeleportArc(origin, dir, group, {bounded, roots}): a bounded arc stops at the FIRST surface it meets, of any slope (a wall face ends it instead of the arc passing through the wall to the floor behind), over objectsGroup AND registered module content (a dungeon's walls live under module-world-root), and reports the landing's world normal.y. - teleportVerdict(from, to, normalY) (exported; the dungeon lane's e2e entry point), the rules in order, each in ITS OWN frame so a world grab never skews one: 1 walkable: normal.y >= 0.7 (or the floor plane) else 'steep'; 2 inside `play.bounds` (scene frame, or the publisher group's local frame) - else the scene's content box pulled in 0.3 m on x/z (measured once per aim) - else 'outside'; 3 a dungeon raster (dungeonPlay): the target cell is floor ('off-floor') and no wall cell lies on the straight line ('wall-cell'); 4 nothing solid crossed 1.1 m above both ends: a publisher's `play.colliders` AABBs, then a mesh raycast ('blocked'). - updateTeleport: the arc is GREEN when valid and RED when refused (teleportPreview() reads it); release teleports only on a valid landing - a red release does nothing. - executeTeleport(target, bounded): a game's teleport stands your FEET on the landing (a step onto a platform is a step up); Edit keeps its head-height move exactly as before. Suite vr-bounded-teleport (19, a walled test room through fakeXR's real right-stick path): the verdict (floor ok, behind the wall blocked, beyond the bounds outside, a wall face steep; no bounds -> the content box shrunk 0.3 m); the arc short = green on the floor, the release moves you there; onto a 0.5 m platform (a sim running) = green, head 0.5 m higher; over the wall = RED behind it, release does nothing; at the wall = ends on the face, steep; a raster wall cell on the way refused; Interact without the flag = no arc; Edit = the over-wall landing green and taken. Counterfactuals: mesh probe / raster / content bounds / bounded arc removed (one batch) -> 6 red; feet not placed (y: 0) -> "stands you ON it" 1.60 red. Both restored. svelte-check 333/47 (identical list); vitest 296. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/lib/vrControls.js | 173 ++++++++++++++++++++++--- tests/e2e/vr-bounded-teleport.test.cjs | 173 +++++++++++++++++++++++++ 2 files changed, 330 insertions(+), 16 deletions(-) create mode 100644 tests/e2e/vr-bounded-teleport.test.cjs diff --git a/src/lib/vrControls.js b/src/lib/vrControls.js index 0b1931b0..0b8f8712 100644 --- a/src/lib/vrControls.js +++ b/src/lib/vrControls.js @@ -45,6 +45,8 @@ import { isScenery, pickGripTarget, gripMovesWorld } from './vrGrip'; import { moduleGroupList, moduleGroupsRevision } from './moduleContent'; import { overlayPanel, makeDepthSentinel, PANEL_ORDER, BEAM_ORDER } from './vrPanelOverlay'; import { moduleWorldChildren } from './moduleWorld'; +import { teleportVerdictPure, shrinkBox, BOUNDS_INSET, PROBE_HEIGHT } from './teleportRules'; +import { walkable as dungeonWalkable } from './dungeonPlay'; import { resolvePlaySettings, playPublishers } from './playSettings'; import { hudDocs, isGameHud } from './hudDocs'; import { locomotionPolicy, vrSpawnOffsets, yawForward } from './locomotionPolicy'; @@ -800,10 +802,16 @@ const arcRaycaster = new THREE.Raycaster(); /** * Sample a ballistic arc from origin along direction; lands on the ground * plane (y=0) or an upward-facing surface of a scene object. + * 31 K1: `opts.bounded` (a game's teleport) stops at the FIRST surface the arc meets, of any + * slope — a wall face ends it (and is refused as too steep) instead of the arc passing + * through the wall to the floor behind — and `opts.roots` adds module content (a dungeon's + * walls live under module-world-root, not objectsGroup). `normalY` is the landing's world + * normal (1 on the floor plane). * @param {any} origin @param {any} direction @param {any=} group - * @returns {{points: any[], target: any | null}} + * @param {{bounded?: boolean, roots?: any[]}} [opts] + * @returns {{points: any[], target: any | null, normalY: number}} */ -export function computeTeleportArc(origin, direction, group) { +export function computeTeleportArc(origin, direction, group, opts = {}) { const speed = 8; const gravity = -9.8; const step = 1 / 12; @@ -812,6 +820,9 @@ export function computeTeleportArc(origin, direction, group) { const points = [origin.clone()]; let previous = origin.clone(); let target = null; + let normalY = 1; + const bounded = !!opts.bounded; + const roots = group ? [...group.children, ...(opts.roots ?? [])] : (opts.roots ?? []); for (let t = step; t <= maxT && !target; t += step) { const point = new THREE.Vector3( @@ -819,19 +830,22 @@ export function computeTeleportArc(origin, direction, group) { origin.y + velocity.y * t + 0.5 * gravity * t * t, origin.z + velocity.z * t ); - if (group) { + if (roots.length) { const segment = point.clone().sub(previous); const length = segment.length(); arcRaycaster.set(previous, segment.normalize()); arcRaycaster.far = length; - const hits = arcRaycaster.intersectObjects(group.children, true); + const hits = arcRaycaster.intersectObjects(roots, true); const landing = hits.find((hit) => { if (!hit.face) return false; + if (bounded) return hit.object.visible !== false && !!(/** @type {any} */ (hit.object).isMesh); const normal = hit.face.normal.clone().transformDirection(hit.object.matrixWorld); return normal.y > 0.5; // only land on top-ish faces }); if (landing) { target = landing.point.clone(); + // @ts-ignore - a face is present (the find above) + normalY = landing.face.normal.clone().transformDirection(landing.object.matrixWorld).y; points.push(target.clone()); break; } @@ -849,7 +863,108 @@ export function computeTeleportArc(origin, direction, group) { points.push(point.clone()); previous = point; } - return { points, target }; + return { points, target, normalY }; +} + +// ---- 31 K1 (D2/S1): THE BOUNDED TELEPORT ----------------------------------------------------- +// Edit's teleport lands wherever the arc does. A game's (Interact/Play with +// `play.locomotion.teleport`) asks teleportRules for a verdict: walkable ground, inside the +// play area, no wall cell on the way, no collider or mesh crossed. Each rule reads its data in +// ITS OWN frame — a publisher's `play.bounds`/`play.colliders`/raster in the publisher group's +// local frame, the scene's bounds in objectsGroup's — so a world grab never skews a verdict. + +/** content bounds per teleport engage (Box3 over the whole scene is not a per-frame cost) */ +/** @type {any} */ let contentBoundsCache = null; + +/** @param {any} object @param {{x: number, y: number, z: number}} p world -> the object's frame */ +function toFrame(object, p) { + const v = new THREE.Vector3(p.x, p.y, p.z); + if (object) object.worldToLocal(v); + return { x: v.x, y: v.y, z: v.z }; +} + +/** everything a teleport may land on or be stopped by: objectsGroup + registered module content */ +function teleportRoots() { + const group = get(objectsGroup); + return [...(group ? group.children : []), ...moduleWorldChildren()]; +} + +/** the scene's content bounds (world), shrunk by BOUNDS_INSET on x and z; null when empty */ +function contentBounds() { + if (contentBoundsCache) return contentBoundsCache; + const box = new THREE.Box3(); + for (const root of teleportRoots()) if (root.visible !== false) box.expandByObject(root); + if (box.isEmpty()) return null; + contentBoundsCache = shrinkBox({ min: box.min.toArray(), max: box.max.toArray() }, BOUNDS_INSET); + return contentBoundsCache; +} + +const _probeRay = new THREE.Raycaster(); +/** does a mesh stand between the two ends, PROBE_HEIGHT above both? (world) */ +function meshBetween(/** @type {any} */ from, /** @type {any} */ to) { + const a = new THREE.Vector3(from.x, from.y + PROBE_HEIGHT, from.z); + const b = new THREE.Vector3(to.x, to.y + PROBE_HEIGHT, to.z); + const d = b.clone().sub(a); + const length = d.length(); + if (length < 1e-4) return false; + _probeRay.set(a, d.normalize()); + _probeRay.far = length; + return _probeRay + .intersectObjects(teleportRoots(), true) + .some((/** @type {any} */ hit) => hit.object.isMesh && hit.object.visible !== false && hit.distance > 0.05); +} + +/** + * 31 K1: may a player standing at `from` (feet, world) teleport to `to` (a landing, world) + * whose surface normal has y = `normalY`? The dungeon lane's e2e entry point too. + * @param {number[] | {x: number, y: number, z: number} | null} fromRaw + * @param {number[] | {x: number, y: number, z: number}} toRaw + * @param {number} [normalY] + * @returns {{ok: boolean, reason: string}} + */ +export function teleportVerdict(fromRaw, toRaw, normalY = 1) { + const pt = (/** @type {any} */ p) => (Array.isArray(p) ? { x: +p[0], y: +p[1], z: +p[2] } : p); + const from = fromRaw ? pt(fromRaw) : null; + const to = pt(toRaw); + const scene = /** @type {any} */ (get(globalScene)); + const play = resolvePlaySettings(scene); + // 1 + 2: the surface and the play area + /** @type {any} */ let verdict; + if (play.bounds) { + const owner = play.boundsOwner ?? get(objectsGroup); + verdict = teleportVerdictPure({ to: toFrame(owner, to), normalY, bounds: play.bounds }); + } else verdict = teleportVerdictPure({ to, normalY, bounds: contentBounds() }); + if (!verdict.ok) return verdict; + // 3: a dungeon raster, in its group's frame + const dungeon = scene?.getObjectByName('dungeon-module'); + const raster = dungeon?.userData?.play; + if (raster?.grid) { + verdict = teleportVerdictPure({ + from: from ? toFrame(dungeon, from) : null, + to: toFrame(dungeon, to), + raster, + walkable: dungeonWalkable + }); + if (!verdict.ok) return verdict; + } + // 4: published collider boxes (each publisher's frame), then real meshes + if (from) { + for (const publisher of playPublishers(scene)) { + const boxes = publisher.userData.play.colliders; + if (!Array.isArray(boxes) || !boxes.length) continue; + const colliders = boxes.filter((/** @type {any} */ b) => Array.isArray(b?.min) && Array.isArray(b?.max)); + verdict = teleportVerdictPure({ from: toFrame(publisher, from), to: toFrame(publisher, to), colliders }); + if (!verdict.ok) return verdict; + } + if (meshBetween(from, to)) return { ok: false, reason: 'blocked' }; + } + return { ok: true, reason: 'ok' }; +} + +/** 31 K1: the live arc's verdict, for the suites @type {{engaged: boolean, bounded: boolean, valid: boolean, reason: string, target: number[] | null, color: number}} */ +let teleportPreviewState = { engaged: false, bounded: false, valid: false, reason: '', target: null, color: 0 }; +export function teleportPreview() { + return { ...teleportPreviewState, engaged: teleportEngaged }; } let teleportEngaged = false; @@ -887,6 +1002,7 @@ function showArc(points, valid, target) { arcLine.geometry.dispose(); arcLine.geometry = new THREE.BufferGeometry().setFromPoints(points); const color = valid ? 0x22cc66 : 0xcc3344; + teleportPreviewState.color = color; arcLine.material.color.setHex(color); arcDisc.material.color.setHex(color); arcDisc.visible = !!target; @@ -897,13 +1013,18 @@ function hideArc() { if (arcGroup) arcGroup.visible = false; } -/** @param {any} target */ -function executeTeleport(target) { +/** @param {any} target @param {boolean} [bounded] 31 K1: a game's teleport lands the FEET on it */ +function executeTeleport(target, bounded = false) { const space = renderer?.xr.getReferenceSpace(); if (!space) return; - const viewer = renderer.xr.getCamera().getWorldPosition(new THREE.Vector3()); - // reference-space convention: offset = -(viewer displacement); height kept - const t = { x: viewer.x - target.x, y: 0, z: viewer.z - target.z }; + const pose = bounded ? viewerNow() : null; + const viewer = pose + ? new THREE.Vector3(pose.head.x, pose.head.y, pose.head.z) + : renderer.xr.getCamera().getWorldPosition(new THREE.Vector3()); + // reference-space convention: offset = -(viewer displacement); Edit keeps its height, a + // game's teleport stands you ON the landing (a step up onto a platform is a step up) + const feet = pose ? pose.head.y - pose.headHeight : null; + const t = { x: viewer.x - target.x, y: feet === null ? 0 : feet - target.y, z: viewer.z - target.z }; renderer.xr.setReferenceSpace(space.getOffsetReferenceSpace(new XRRigidTransform(t))); // blink to soften the jump const camera = get(globalCamera); @@ -949,15 +1070,17 @@ export function updateTeleport(session) { if (!teleportEngaged) { // stick pushed clearly UP and more up than sideways -> arm - if (teleportArms(x, y)) teleportEngaged = true; - else { + if (teleportArms(x, y)) { + teleportEngaged = true; + contentBoundsCache = null; // re-measured once per aim + } else { hideArc(); return; } } else if (y > -0.4) { - // released -> blink if we had a valid landing + // released -> blink if we had a VALID landing (31 K1: an invalid, red one does nothing) teleportEngaged = false; - if (lastArc?.target) executeTeleport(lastArc.target); + if (lastArc?.target && lastArc.valid) executeTeleport(lastArc.target, lastArc.bounded); lastArc = null; hideArc(); return; @@ -968,8 +1091,26 @@ export function updateTeleport(session) { hideArc(); return; } - lastArc = computeTeleportArc(pose.origin, pose.direction, get(objectsGroup)); - showArc(lastArc.points, !!lastArc.target, lastArc.target); + // 31 K1: a GAME's teleport (Interact/Play) is bounded; Edit's lands where the arc does + const bounded = get(editorMode) === 'interact'; + lastArc = computeTeleportArc(pose.origin, pose.direction, get(objectsGroup), bounded ? { bounded, roots: moduleWorldChildren() } : {}); + lastArc.bounded = bounded; + let verdict = { ok: !!lastArc.target, reason: lastArc.target ? 'ok' : 'off-floor' }; + if (bounded && lastArc.target) { + const viewer = viewerNow(); + const from = viewer ? { x: viewer.head.x, y: viewer.head.y - viewer.headHeight, z: viewer.head.z } : null; + verdict = teleportVerdict(from, lastArc.target, lastArc.normalY); + } + lastArc.valid = verdict.ok; + teleportPreviewState = { + engaged: true, + bounded, + valid: verdict.ok, + reason: verdict.reason, + target: lastArc.target ? lastArc.target.toArray() : null, + color: teleportPreviewState.color + }; + showArc(lastArc.points, verdict.ok, lastArc.target); } /** D1 (roadmap 13): the teleport arc anchors to the RIGHT hand's controller diff --git a/tests/e2e/vr-bounded-teleport.test.cjs b/tests/e2e/vr-bounded-teleport.test.cjs new file mode 100644 index 00000000..295c13ce --- /dev/null +++ b/tests/e2e/vr-bounded-teleport.test.cjs @@ -0,0 +1,173 @@ +// 31 K1 (D2/S1) — A GAME'S TELEPORT STAYS IN THE GAME. The user, on a Quest 3: "Dungeon +// Realms ... I would like to be able to teleport but not outside the dungeon walls" and "In +// Stars room I would like to be able to teleport but not outside the scene". +// +// A walled test room (a floor, a 1.5 m wall at x = 2, play.bounds x/z in [-4, 8]) with +// `play.locomotion.teleport: true`, in Interact, driven through fakeXR's real stick path +// (right stick UP arms the arc, release blinks): +// 1 the verdict entry point (the dungeon lane's e2e uses it): a floor target inside -> ok, +// behind the wall -> blocked, beyond the bounds -> outside, a wall face -> steep · 2 the REAL +// arc: aimed short it lands on the floor GREEN and the release moves you there, feet on the +// floor · 3 aimed over the wall it lands behind it RED and the release does nothing · 4 aimed +// at the wall it ends ON the wall face, red · 5 a dungeon RASTER: a wall cell on the way is +// refused (wall-cell) · 6 without `teleport` there is no arc at all in Interact · 7 Edit's +// teleport is unchanged: the same over-the-wall landing is green and taken. +const h = require('./helpers.cjs'); +const xr = require('./fakeXR.cjs'); + +h.run(async () => { + const browser = await h.launch({ args: h.GPU_ARGS }); + const A = await h.setupPage(browser, 'A'); + const page = A.page; + + await page.evaluate(async () => { + const s = window.__stores; + const get = (store) => { let v; store.subscribe((x) => (v = x))(); return v; }; + for (let i = 0; i < 2; i++) s.commandsHandler.sceneCommand('/create box'); + await new Promise((r) => setTimeout(r, 600)); + const [floor, wall] = get(s.objectsGroup).children.filter((c) => c.name === 'Box'); + floor.scale.set(14, 0.2, 14); + floor.position.set(2, -0.1, 2); + floor.userData.physics = { mode: 'static' }; + wall.scale.set(0.3, 1.5, 14); + wall.position.set(2, 0.75, 2); + wall.userData.physics = { mode: 'static' }; + for (const b of [floor, wall]) b.updateMatrixWorld(true); + s.objectActions.deselectObject(); + s.isVRMode.set(true); + s.scenePhysics.setScenePhysics({ play: { locomotion: { teleport: true }, bounds: { min: [-4, -0.5, -4], max: [8, 0.5, 8] } } }); + s.objectActions.setEditorMode('interact'); + s.vrTeleportEnabled?.set?.(true); + }); + await page.waitForTimeout(500); + await xr.install(page); + await xr.installSpace(page, { head: [0, 1.6, 0], yaw: 0 }); + + // ---- 1 the verdict --------------------------------------------------------------------------- + const v = await page.evaluate(() => { + const t = window.__stores.vrControls.teleportVerdict; + return { + inside: t([0, 0, 0], [1, 0, 0]), + behind: t([0, 0, 0], [5, 0, 0]), + outside: t([0, 0, 0], [0, 0, 9]), + steep: t([0, 0, 0], [1.85, 0.6, 0], 0) + }; + }); + h.check(v.inside.ok, `a floor target inside -> ok (${JSON.stringify(v.inside)})`); + h.check(!v.behind.ok && v.behind.reason === 'blocked', `behind the wall -> blocked (${JSON.stringify(v.behind)})`); + h.check(!v.outside.ok && v.outside.reason === 'outside', `beyond the bounds -> outside (${JSON.stringify(v.outside)})`); + h.check(!v.steep.ok && v.steep.reason === 'steep', `a wall face -> steep (${JSON.stringify(v.steep)})`); + + // ---- 1b no play.bounds: the scene's content box pulled in 0.3 m (the floor spans x -5..9) -- + const fb = await page.evaluate(() => { + const s = window.__stores; + s.scenePhysics.setScenePhysics({ play: { locomotion: { teleport: true }, bounds: null } }); + const t = s.vrControls.teleportVerdict; + const r = { inside: t(null, [8.5, 0, 0]), edge: t(null, [8.85, 0, 0]), beyond: t(null, [12, 0, 0]) }; + s.scenePhysics.setScenePhysics({ play: { bounds: { min: [-4, -0.5, -4], max: [8, 0.5, 8] } } }); + return r; + }); + h.check(fb.inside.ok && fb.edge.reason === 'outside' && fb.beyond.reason === 'outside', `no bounds: the content box shrunk 0.3 m decides (${JSON.stringify(fb)})`); + + // the stick: aim the right controller from (0, 1.2, 0) toward +x at `pitch`, push up, read + const aimAndArm = async (pitch) => { + await xr.pose(page, 'right', [0, 1.2, 0], { yaw: -Math.PI / 2, pitch }); + await xr.stick(page, 'right', 0, -1); + await page.waitForTimeout(250); + return page.evaluate(() => window.__stores.vrControls.teleportPreview()); + }; + const release = async () => { + await xr.stick(page, 'right', 0, 0); + await page.waitForTimeout(250); + return xr.head(page); + }; + + // ---- 2 short: the floor, green, taken -------------------------------------------------------- + let p = await aimAndArm(-0.5); + h.check(p.engaged && p.bounded && p.valid && p.color === 0x22cc66, `aimed short: a green arc on the floor (${JSON.stringify(p)})`); + h.check(p.target && p.target[0] > 0.5 && p.target[0] < 1.9, `the landing is in front of the wall (x ${p.target?.[0]?.toFixed(2)})`); + let head = await release(); + h.check(Math.abs(head.x - p.target[0]) < 0.05 && Math.abs(head.y - 1.6) < 0.05, `the release moves you there, feet on the floor (${head.x.toFixed(2)}, ${head.y.toFixed(2)})`); + // back to the start + await xr.installSpace(page, { head: [0, 1.6, 0], yaw: 0 }); + + // ---- 2b onto a 0.5 m platform: the FEET land on its top (Edit keeps the head height) ------ + await page.evaluate(async () => { + const s = window.__stores; + s.commandsHandler.sceneCommand('/create box'); + await new Promise((r) => setTimeout(r, 500)); + let g; + s.objectsGroup.subscribe((v) => (g = v))(); + const b = g.children.filter((c) => c.name === 'Box').at(-1); + b.scale.set(0.6, 0.5, 1); + b.position.set(1.2, 0.25, -3); + b.userData.physics = { mode: 'static' }; + b.updateMatrixWorld(true); + s.objectActions.deselectObject(); + }); + // a simulation runs: without one the 30b walker has only the plane tier, nothing to stand + // a platform on, and gravity takes you back down to y 0 (its limit, not the teleport's) + await page.evaluate(() => window.__stores.physics.toggleSimulation()); + await h.eventually(() => page.evaluate(() => window.__stores.physics.physicsWorldDebug().running), (ok) => ok, 'premise: a simulation runs', 15000); + await xr.installSpace(page, { head: [0, 1.6, -3], yaw: 0 }); + await xr.pose(page, 'right', [0, 1.2, -3], { yaw: -Math.PI / 2, pitch: -0.5 }); + await xr.stick(page, 'right', 0, -1); + await page.waitForTimeout(250); + p = await page.evaluate(() => window.__stores.vrControls.teleportPreview()); + h.check(p.valid && Math.abs(p.target[1] - 0.5) < 0.01, `aimed at the platform: a green landing on its top (${JSON.stringify(p.target)})`); + head = await release(); + h.check(Math.abs(head.y - 2.1) < 0.05, `the release stands you ON it: head 0.5 m higher (${head.y.toFixed(2)})`); + await page.evaluate(() => window.__stores.physics.toggleSimulation()); + await page.waitForTimeout(300); + await xr.installSpace(page, { head: [0, 1.6, 0], yaw: 0 }); + + // ---- 3 over the wall: red, and the release does nothing ------------------------------------ + p = await aimAndArm(0.35); + h.check(p.target && p.target[0] > 2.2 && !p.valid && p.reason === 'blocked' && p.color === 0xcc3344, `over the wall: a RED arc behind it (${JSON.stringify(p)})`); + head = await release(); + h.check(Math.abs(head.x) < 0.01 && Math.abs(head.z) < 0.01, `the release does nothing (${head.x.toFixed(2)}, ${head.z.toFixed(2)})`); + + // ---- 4 at the wall: the arc ends ON the face, red -------------------------------------------- + p = await aimAndArm(-0.05); + h.check(p.target && Math.abs(p.target[0] - 1.85) < 0.05 && !p.valid && p.reason === 'steep', `at the wall: the arc ends on its face, refused (${JSON.stringify(p)})`); + await release(); + + // ---- 5 a dungeon raster ----------------------------------------------------------------------- + const raster = await page.evaluate(() => { + const s = window.__stores; + let scene; + s.globalScene.subscribe((v) => (scene = v))(); + const THREE = s.THREE; + const g = new THREE.Group(); + g.name = 'dungeon-module'; + // 10 x 10 cells from (-2, -2): floor everywhere but a wall column at x = 5 (cell 7) + const W = 10; + const grid = new Array(W * W).fill(0); + for (let z = 0; z < W; z++) grid[z * W + 7] = 1; + g.userData.play = { grid, width: W, height: W, minX: -2, minY: -2, floorValue: 0, rooms: [] }; + scene.add(g); + const t = s.vrControls.teleportVerdict; + const r = { near: t([0, 0, 0], [1, 0, 1]), across: t([4, 0, 0], [6.5, 0, 0]) }; + scene.remove(g); + return r; + }); + h.check(raster.near.ok, `raster: a floor cell nearby -> ok (${JSON.stringify(raster.near)})`); + h.check(!raster.across.ok && raster.across.reason === 'wall-cell', `raster: a wall cell on the way -> refused (${JSON.stringify(raster.across)})`); + + // ---- 6 no teleport flag: no arc in Interact ---------------------------------------------------- + await page.evaluate(() => window.__stores.scenePhysics.setScenePhysics({ play: { locomotion: {} } })); + p = await aimAndArm(-0.5); + h.check(!p.engaged, 'Interact without play.locomotion.teleport: no arc at all'); + await release(); + + // ---- 7 Edit is unchanged: over the wall is green and taken ----------------------------------- + await page.evaluate(() => window.__stores.objectActions.setEditorMode('edit')); + await xr.installSpace(page, { head: [0, 1.6, 0], yaw: 0 }); + p = await aimAndArm(0.35); + h.check(p.engaged && !p.bounded && p.valid && p.target[0] > 2.2, `Edit: the same landing behind the wall is green (${JSON.stringify(p)})`); + head = await release(); + h.check(head.x > 2.2, `Edit: and taken (x ${head.x.toFixed(2)})`); + + await xr.uninstall(page); + await h.finish(browser); +}); From 91bee64a398f258001c7f4bece0453e9c780cb0f Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Thu, 1 Oct 2026 04:19:27 +0300 Subject: [PATCH 6/8] [docs] 31 P5: MODULES.md - moving around in a game (locomotion flags, play.bounds, the bounded teleport, api.vrPanel) - New section "Moving around in a game: locomotion, bounds, VR panels (1.18, roadmap 31)": `locomotion.teleport` (the bounded rules, red arc, feet on the landing), `locomotion.worldGrab`, `locomotion.fly`, `play.bounds` (a sibling of locomotion; a module's in its group's local frame), `play.colliders`, the `api.locomotion` feature probe, the teleportVerdict / teleportPreview debug entry points, and `api.vrPanel(group)` for a module's own VR menu (drawn over the scene, ends the laser). - static/llms-full.txt regenerated (npm run sync-llms). Co-Authored-By: Claude Opus 5.5 (1M context) --- MODULES.md | 47 +++++++++++++++++++++++++++++++++++++++ static/llms-full.txt | 53 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 100 insertions(+) diff --git a/MODULES.md b/MODULES.md index dd2c3444..76c6bbb7 100644 --- a/MODULES.md +++ b/MODULES.md @@ -533,6 +533,53 @@ register(api) { checkpoint and nobody moves until then. A module's spawn overrides the scene's `play.spawn`. `api.respawnPlayer()` sends the player back to it now. +### Moving around in a game: locomotion, bounds, VR panels (1.18, roadmap 31) + +In Interact and Play a VR player WALKS (collisions, gravity, a 0.3 m step, snap turn) +and nothing else unless the scene's play block — or your module's `userData.play` on +its scene-root group, field by field — allows more: + +```js +// feature-detect: an older core has no api.locomotion and would ignore the bounds +if (api.locomotion?.boundedTeleport) { + group.userData.play = { + locomotion: { teleport: true, worldGrab: false, fly: false }, + bounds: { min: [-12, -0.5, -12], max: [12, 0.4, 12] }, // in THIS group's local frame + colliders: [{ min: [2, 0, -6], max: [2.3, 3, 6] }] // optional: solid boxes, same frame + }; +} +``` + +- **`locomotion.teleport: true`** — the right stick's arc teleport, BOUNDED: the arc stops + at the first surface it meets; the landing must face up (normal y ≥ 0.7) or be the floor + plane, must lie inside `bounds`, and the straight line 1.1 m above your feet and the + landing must not cross a collider box, a mesh, or (with a dungeon raster published) a wall + cell. A refused landing draws the arc RED and releasing the stick does nothing; a valid one + is green and puts your FEET on it. Without `bounds` the play area is the scene's content + box pulled in 0.3 m. Edit mode's teleport is unchanged (it lands anywhere). +- **`locomotion.worldGrab: true`** — the grips move, rotate and SCALE the world the way they + do in Edit (two grips scale/rotate, the right grip alone pans) whenever a grip does NOT + start on something the player may hold (a grip on a dynamic body under + `interaction: 'grab'` still takes the body). For board games and instruments: Untangle, + the Jam Room. +- **`locomotion.fly: true`** — the left stick flies along the controller's aim, no gravity. +- **`bounds: {min: [x, y, z], max: [x, y, z]}`** — a sibling of `locomotion`. The scene's + play block gives it in scene coordinates; a module's `userData.play.bounds` is read in + that group's local frame. Keep `max.y` just above the floor if box tops and ledges must + not be landing spots. +- `vrControls.teleportVerdict(from, to, normalY?)` (on the debug hook + `window.__stores.vrControls`) answers `{ok, reason}` — `ok` `steep` `outside` `off-floor` + `wall-cell` `blocked` — for your e2e; `from`/`to` are world FEET points `[x, y, z]`. + `vrControls.teleportPreview()` reads the live arc (`{engaged, bounded, valid, reason, target}`). + +**Your own VR menu is a panel.** A board, a level bar or buttons you draw in THREE for the +headset should be registered with **`api.vrPanel?.(group)`** (returns the undo, also run +when the module is disabled): it is then drawn OVER the scene — a floor, a base or a wall +between the player and it can never hide its buttons — and the controller laser ends on it +with its dot, even through whatever stands in front. Hit testing is unchanged. Core's own +panels (the radial menu, the game board, the wrist card) already work this way, and the +laser ends on your `registerInteractiveGroup` content too. + **In VR, your game's HUD is in the player's hands.** A screen with `input: 'menu'`, a control on it, or bound to the `menu` / `paused` / `over` game state is drawn on a board ~1.2 m in front of the player that follows their head lazily; its buttons diff --git a/static/llms-full.txt b/static/llms-full.txt index 4e569f61..0557fea7 100644 --- a/static/llms-full.txt +++ b/static/llms-full.txt @@ -576,6 +576,59 @@ register(api) { "Level 3"): the desktop HUD, and in a headset a banner fixed in front of the player. A new one replaces the one showing; `ms` 300..15000 (default 1800). +- **`api.setSpawn([x, y, z], yaw, {teleport?})`** — where the player starts: `y` is the + FEET, `yaw` is three's `rotation.y` (0 faces −Z). Entering Interact or Play puts the player + there (a headset's rig lands its feet on it, a desktop its camera); without `teleport` it is a + checkpoint and nobody moves until then. A module's spawn overrides the scene's + `play.spawn`. `api.respawnPlayer()` sends the player back to it now. + +### Moving around in a game: locomotion, bounds, VR panels (1.18, roadmap 31) + +In Interact and Play a VR player WALKS (collisions, gravity, a 0.3 m step, snap turn) +and nothing else unless the scene's play block — or your module's `userData.play` on +its scene-root group, field by field — allows more: + +```js +// feature-detect: an older core has no api.locomotion and would ignore the bounds +if (api.locomotion?.boundedTeleport) { + group.userData.play = { + locomotion: { teleport: true, worldGrab: false, fly: false }, + bounds: { min: [-12, -0.5, -12], max: [12, 0.4, 12] }, // in THIS group's local frame + colliders: [{ min: [2, 0, -6], max: [2.3, 3, 6] }] // optional: solid boxes, same frame + }; +} +``` + +- **`locomotion.teleport: true`** — the right stick's arc teleport, BOUNDED: the arc stops + at the first surface it meets; the landing must face up (normal y ≥ 0.7) or be the floor + plane, must lie inside `bounds`, and the straight line 1.1 m above your feet and the + landing must not cross a collider box, a mesh, or (with a dungeon raster published) a wall + cell. A refused landing draws the arc RED and releasing the stick does nothing; a valid one + is green and puts your FEET on it. Without `bounds` the play area is the scene's content + box pulled in 0.3 m. Edit mode's teleport is unchanged (it lands anywhere). +- **`locomotion.worldGrab: true`** — the grips move, rotate and SCALE the world the way they + do in Edit (two grips scale/rotate, the right grip alone pans) whenever a grip does NOT + start on something the player may hold (a grip on a dynamic body under + `interaction: 'grab'` still takes the body). For board games and instruments: Untangle, + the Jam Room. +- **`locomotion.fly: true`** — the left stick flies along the controller's aim, no gravity. +- **`bounds: {min: [x, y, z], max: [x, y, z]}`** — a sibling of `locomotion`. The scene's + play block gives it in scene coordinates; a module's `userData.play.bounds` is read in + that group's local frame. Keep `max.y` just above the floor if box tops and ledges must + not be landing spots. +- `vrControls.teleportVerdict(from, to, normalY?)` (on the debug hook + `window.__stores.vrControls`) answers `{ok, reason}` — `ok` `steep` `outside` `off-floor` + `wall-cell` `blocked` — for your e2e; `from`/`to` are world FEET points `[x, y, z]`. + `vrControls.teleportPreview()` reads the live arc (`{engaged, bounded, valid, reason, target}`). + +**Your own VR menu is a panel.** A board, a level bar or buttons you draw in THREE for the +headset should be registered with **`api.vrPanel?.(group)`** (returns the undo, also run +when the module is disabled): it is then drawn OVER the scene — a floor, a base or a wall +between the player and it can never hide its buttons — and the controller laser ends on it +with its dot, even through whatever stands in front. Hit testing is unchanged. Core's own +panels (the radial menu, the game board, the wrist card) already work this way, and the +laser ends on your `registerInteractiveGroup` content too. + **In VR, your game's HUD is in the player's hands.** A screen with `input: 'menu'`, a control on it, or bound to the `menu` / `paused` / `over` game state is drawn on a board ~1.2 m in front of the player that follows their head lazily; its buttons From 8189daad2732147b23cd07b6e63f649c19c1d539 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Thu, 1 Oct 2026 04:29:06 +0300 Subject: [PATCH 7/8] [fix] 31 P4: a dynamic body in the line never refuses a teleport (the Stars Room's floating stars) Found by 31-stars-jam on the union: in the Stars Room, teleportVerdict from the spawn to the floor across the room returned 'blocked'. The wall probe runs 1.1 m above both ends, which is the stars' layer (24 dynamic icosahedra at 0.8-2.6 m), so any floating star in the line refused every landing. meshBetween now skips hits whose top-level object is a DYNAMIC physics body or a spawner's transient copy - a body you can knock aside is not a wall. Static and pick-through solids (the room's glass) still block. vr-bounded-teleport 21/21 (+2: a dynamic box at 1.1 m in the line -> ok; the same box static -> blocked). Counterfactual: the filter removed -> the dynamic check red. svelte-check 333/47 (identical list). Co-Authored-By: Claude Opus 5.5 (1M context) --- src/lib/vrControls.js | 12 +++++++++++- tests/e2e/vr-bounded-teleport.test.cjs | 24 ++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/src/lib/vrControls.js b/src/lib/vrControls.js index 0b8f8712..1a734870 100644 --- a/src/lib/vrControls.js +++ b/src/lib/vrControls.js @@ -911,7 +911,17 @@ function meshBetween(/** @type {any} */ from, /** @type {any} */ to) { _probeRay.far = length; return _probeRay .intersectObjects(teleportRoots(), true) - .some((/** @type {any} */ hit) => hit.object.isMesh && hit.object.visible !== false && hit.distance > 0.05); + .some((/** @type {any} */ hit) => hit.object.isMesh && hit.object.visible !== false && hit.distance > 0.05 && !movableBody(hit.object)); +} + +/** 31 K1: a DYNAMIC physics body (or a spawner's transient copy) is not a wall — a body you can + * knock aside never refuses a teleport. Found by 31-stars-jam: the Stars Room's 24 floating + * stars sit at 0.8-2.6 m, right on the 1.1 m probe, so any star in the line refused every + * landing across the room. Static and pick-through solids (the room's glass) still block. + * @param {any} mesh */ +function movableBody(mesh) { + const top = topLevelObjectOf(mesh); + return !!top && (top.userData?.physics?.mode === 'dynamic' || top.userData?.transient === true); } /** diff --git a/tests/e2e/vr-bounded-teleport.test.cjs b/tests/e2e/vr-bounded-teleport.test.cjs index 295c13ce..ff1a70a1 100644 --- a/tests/e2e/vr-bounded-teleport.test.cjs +++ b/tests/e2e/vr-bounded-teleport.test.cjs @@ -58,6 +58,30 @@ h.run(async () => { h.check(!v.outside.ok && v.outside.reason === 'outside', `beyond the bounds -> outside (${JSON.stringify(v.outside)})`); h.check(!v.steep.ok && v.steep.reason === 'steep', `a wall face -> steep (${JSON.stringify(v.steep)})`); + // ---- 1a a DYNAMIC body in the line is not a wall (the Stars Room's floating stars, at the + // probe's height); the same box made static blocks + const dyn = await page.evaluate(async () => { + const s = window.__stores; + s.commandsHandler.sceneCommand('/create box'); + await new Promise((r) => setTimeout(r, 500)); + let g; + s.objectsGroup.subscribe((v) => (g = v))(); + const star = g.children.filter((c) => c.name === 'Box').at(-1); + star.scale.setScalar(0.4); + star.position.set(0.8, 1.1, 0.8); + star.updateMatrixWorld(true); + s.objectActions.deselectObject(); + const t = s.vrControls.teleportVerdict; + star.userData.physics = { mode: 'dynamic', mass: 1 }; + const dynamic = t([0, 0, 0], [1.5, 0, 1.5]); + star.userData.physics = { mode: 'static' }; + const fixed = t([0, 0, 0], [1.5, 0, 1.5]); + g.remove(star); + return { dynamic, fixed }; + }); + h.check(dyn.dynamic.ok, `a dynamic body in the line does not refuse the landing (${JSON.stringify(dyn.dynamic)})`); + h.check(!dyn.fixed.ok && dyn.fixed.reason === 'blocked', `the same box static blocks it (${JSON.stringify(dyn.fixed)})`); + // ---- 1b no play.bounds: the scene's content box pulled in 0.3 m (the floor spans x -5..9) -- const fb = await page.evaluate(() => { const s = window.__stores; From c5620cac38800788b0a960e26737a11540de58b6 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Thu, 1 Oct 2026 04:37:15 +0300 Subject: [PATCH 8/8] [fix] 31 G5: a THREE.Sprite in module content no longer kills the VR frame (rays carry the camera) Found by 31-untangle: since P1 the VR rays reach module content (the beam ends on interactive groups; P4's arc and wall probe raycast registered module groups). three's Sprite.raycast needs `raycaster.camera` - it warns and THROWS on a null camera ("Cannot read properties of null (reading 'matrixWorld')"). One Sprite in a module group (Untangle's globe-mode canvas HUD) threw inside updateVRControls every frame and aborted it before onSqueezeStart: worldGrab silently dead in the globe, fine on the 2D board. - vrControls.withRayCamera(ray): every VR ray carries the camera the viewer sees through (the XR camera in a session, the editor camera otherwise) - controllerRay, pointerHandRay (api.pointerRay), the teleport arc and wall probe; vrGameInput.controllerRayOf too. - safeIntersect: a raycast INTO module content is guarded - a module mesh whose raycast throws logs once per call and yields no hit instead of taking the frame down. vr-laser-panels 25/25 (+2: a Sprite on the module board -> no page error, the beam ends on it at 1.200 m). Counterfactual (camera + guard removed): the exact reported pageerror, the beam passes to 1.490 m, and the radial reticle after it is off (3 red). Re-run on this commit: vr-bounded-teleport 21/0, vr-panels-on-top 12/0, vr-game-panel 35/0, vr-sweep 25/0 (with JAM_ROOM_TPSCENE = the preview-1-17 Jam Room - the scenes checkout on main is older and has no mixer mute buttons, which reads as a 10.4 failure). svelte-check 333/47 (identical list). Co-Authored-By: Claude Opus 5.5 (1M context) --- src/lib/vrControls.js | 45 ++++++++++++++++++++++++++---- src/lib/vrGameInput.js | 5 ++-- tests/e2e/vr-laser-panels.test.cjs | 32 +++++++++++++++++++++ 3 files changed, 74 insertions(+), 8 deletions(-) diff --git a/src/lib/vrControls.js b/src/lib/vrControls.js index 1a734870..5f2ec1d9 100644 --- a/src/lib/vrControls.js +++ b/src/lib/vrControls.js @@ -625,7 +625,7 @@ export function beamTarget(ray) { // picker) ends the beam too — it lives under module-world-root, outside objectsGroup, so // the laser used to pass straight through a module's menu with no dot on it for (const root of moduleInteractiveRoots()) { - const hits = ray.intersectObject(root, true); + const hits = safeIntersect(ray, root); const first = hits.find((/** @type {any} */ h) => h.object.visible !== false && !h.object.isLine && !h.object.isPoints); if (first && first.distance < distance) { distance = first.distance; @@ -835,7 +835,10 @@ export function computeTeleportArc(origin, direction, group, opts = {}) { const length = segment.length(); arcRaycaster.set(previous, segment.normalize()); arcRaycaster.far = length; - const hits = arcRaycaster.intersectObjects(roots, true); + withRayCamera(arcRaycaster); + /** @type {any[]} */ const hits = []; + for (const root of roots) hits.push(...safeIntersect(arcRaycaster, root)); + hits.sort((x, y) => x.distance - y.distance); const landing = hits.find((hit) => { if (!hit.face) return false; if (bounded) return hit.object.visible !== false && !!(/** @type {any} */ (hit.object).isMesh); @@ -909,8 +912,9 @@ function meshBetween(/** @type {any} */ from, /** @type {any} */ to) { if (length < 1e-4) return false; _probeRay.set(a, d.normalize()); _probeRay.far = length; - return _probeRay - .intersectObjects(teleportRoots(), true) + withRayCamera(_probeRay); + return teleportRoots() + .flatMap((root) => safeIntersect(_probeRay, root)) .some((/** @type {any} */ hit) => hit.object.isMesh && hit.object.visible !== false && hit.distance > 0.05 && !movableBody(hit.object)); } @@ -1638,7 +1642,36 @@ function controllerRay(index) { tempMatrix.identity().extractRotation(controller.matrixWorld); raycaster.ray.origin.setFromMatrixPosition(controller.matrixWorld); raycaster.ray.direction.set(0, 0, -1).applyMatrix4(tempMatrix); - return raycaster; + return withRayCamera(raycaster); +} + +/** + * 31 (found by 31-untangle): a THREE.Sprite cannot be raycast without `raycaster.camera` — + * three warns and Sprite.raycast THROWS on the null camera. Since the VR rays reach module + * content (G5's beam ends, the bounded teleport's arc and wall probe), one Sprite in a module's + * group threw inside updateVRControls every frame and aborted it before the grips ran + * (worldGrab silently dead in Untangle's globe mode). Every VR ray carries the camera the + * viewer sees through — the XR camera in a session, the editor camera otherwise. + * @template T @param {T} ray @returns {T} + */ +export function withRayCamera(ray) { + const camera = renderer?.xr?.isPresenting ? renderer.xr.getCamera() : get(globalCamera); + if (camera) /** @type {any} */ (ray).camera = camera; + return ray; +} + +/** + * Module content is not ours: a raycast into it must never take the VR frame down with it + * (a mesh with a custom raycast, a Sprite before the camera exists). Returns [] on a throw. + * @param {any} ray @param {any} root @returns {any[]} + */ +function safeIntersect(ray, root) { + try { + return ray.intersectObject(root, true); + } catch (error) { + console.log('VR raycast into module content failed', root?.name, error); + return []; + } } /** @@ -1658,7 +1691,7 @@ export function pointerHandRay() { const m = new THREE.Matrix4().identity().extractRotation(controller.matrixWorld); fresh.ray.origin.setFromMatrixPosition(controller.matrixWorld); fresh.ray.direction.set(0, 0, -1).applyMatrix4(m); - return fresh; + return withRayCamera(fresh); } /** Raycast the quick-menu tiles @param {number} index @returns {string|null} tile action name */ diff --git a/src/lib/vrGameInput.js b/src/lib/vrGameInput.js index 68fe4aa3..aa8c0e9e 100644 --- a/src/lib/vrGameInput.js +++ b/src/lib/vrGameInput.js @@ -30,7 +30,7 @@ import { topLevelObjectOf } from './objectActions'; import { objectHasOnClick, fireObjectClick } from './flowRuntime'; import { moduleInteractiveGroups, runClickHandlers } from './moduleSDK'; import { isModuleTopLevel } from './moduleWorld'; -import { registerVRFrameHook, registerVRTriggerHooks, registerPanelGroupProvider, controllerIndexFor, hapticPattern, triggerClaimed } from './vrControls'; +import { registerVRFrameHook, registerVRTriggerHooks, registerPanelGroupProvider, controllerIndexFor, hapticPattern, triggerClaimed, withRayCamera } from './vrControls'; // P3 (C2): the game UI in VR — the panel, the wrist card, the strip, the banner import { vrGamePanelFrame, panelTargetAlong, panelHover, pressPanelTarget, pokeFrame, uAcross, vrGameSurface, hideVrGamePanel } from './vrGamePanel'; @@ -116,7 +116,8 @@ export function controllerRayOf(index) { _mat.identity().extractRotation(controller.matrixWorld); ray.ray.origin.setFromMatrixPosition(controller.matrixWorld); ray.ray.direction.set(0, 0, -1).applyMatrix4(_mat); - return ray; + // 31: a Sprite in module content needs the camera to be raycast (else three throws) + return withRayCamera(ray); } /** 'left' | 'right' | undefined for a controller slot @param {number} index */ diff --git a/tests/e2e/vr-laser-panels.test.cjs b/tests/e2e/vr-laser-panels.test.cjs index 9d64eaf1..dc54e2e3 100644 --- a/tests/e2e/vr-laser-panels.test.cjs +++ b/tests/e2e/vr-laser-panels.test.cjs @@ -191,6 +191,38 @@ h.run(async () => { h.check(modBeam.reticle && Math.abs(modBeam.length - 1.49) < 0.02, `the beam ends ON the module's board (${modBeam.length?.toFixed(3)} m, reticle ${!!modBeam.reticle})`); h.check(mod.group === 'laser31-board', `the laser resolves the module mesh to its interactive group (${mod.group})`); + console.log('\n=== a THREE.Sprite in module content (found by 31-untangle) ==='); + // a Sprite cannot be raycast without raycaster.camera: three threw inside updateVRControls + // every frame, which aborted it before the grips ran (worldGrab dead in Untangle's globe) + const errorsBefore = h.pageErrors ? h.pageErrors(A).length : 0; + await page.evaluate(() => { + const s = window.__stores; + const THREE = s.THREE; + let scene; + s.globalScene.subscribe((v) => (scene = v))(); + const board = scene.getObjectByName('laser31-board'); + const sprite = new THREE.Sprite(new THREE.SpriteMaterial({ color: 0xffcc00 })); + sprite.name = 'laser31-sprite'; + sprite.scale.set(0.3, 0.3, 1); + sprite.position.set(0, 0, 0.3); // 0.3 m in front of the board, on the beam + board.add(sprite); + board.updateMatrixWorld(true); + }); + await frames(6); + const spriteBeam = await page.evaluate(() => { + const s = window.__stores; + return { beam: window.__L.beam(1), grip: s.vrControls.vrGripDebug() }; + }); + const errorsAfter = h.pageErrors ? h.pageErrors(A).length : 0; + h.check(errorsAfter === errorsBefore, `no page error from the Sprite (${errorsAfter - errorsBefore} new)`); + h.check(spriteBeam.beam.reticle && Math.abs(spriteBeam.beam.length - 1.19) < 0.03, `the beam ends ON the sprite (${spriteBeam.beam.length?.toFixed(3)} m)`); + await page.evaluate(() => { + let scene; + window.__stores.globalScene.subscribe((v) => (scene = v))(); + const sp = scene.getObjectByName('laser31-sprite'); + sp?.parent?.remove(sp); + }); + console.log('\n=== the radial ring takes the reticle ==='); const ring = await page.evaluate(() => { const s = window.__stores;