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/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/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 9eacc612..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); @@ -1202,6 +1210,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/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/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/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 0c469a3d..5f2ec1d9 100644 --- a/src/lib/vrControls.js +++ b/src/lib/vrControls.js @@ -42,6 +42,11 @@ import { editorMode, 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 { 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'; @@ -276,8 +281,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 +293,7 @@ function ensureRayLines() { new THREE.MeshBasicMaterial({ color: RAY_IDLE, transparent: true, - opacity: 0.6, - blending: THREE.AdditiveBlending, + opacity: 0.8, depthWrite: false }) ); @@ -310,6 +316,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); } @@ -503,6 +518,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); @@ -517,11 +534,78 @@ 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() { + 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 * 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; @@ -537,15 +621,37 @@ 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 = 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; + hit = true; + object = null; + 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; @@ -558,7 +664,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 @@ -605,8 +711,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 @@ -615,6 +723,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); @@ -686,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; @@ -698,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( @@ -705,19 +830,25 @@ 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); + 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); 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; } @@ -735,7 +866,119 @@ 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; + 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)); +} + +/** 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); +} + +/** + * 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; @@ -773,6 +1016,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; @@ -783,13 +1027,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); @@ -835,15 +1084,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; @@ -854,8 +1105,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 @@ -1373,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 []; + } } /** @@ -1393,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 */ @@ -1405,6 +1703,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); @@ -2472,8 +2779,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; @@ -3687,22 +3995,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/src/lib/vrGameInput.js b/src/lib/vrGameInput.js index ec0ff9c8..aa8c0e9e 100644 --- a/src/lib/vrGameInput.js +++ b/src/lib/vrGameInput.js @@ -29,7 +29,8 @@ import { pickStack, primaryIndex } from './selectThrough'; import { topLevelObjectOf } from './objectActions'; import { objectHasOnClick, fireObjectClick } from './flowRuntime'; import { moduleInteractiveGroups, runClickHandlers } from './moduleSDK'; -import { registerVRFrameHook, registerVRTriggerHooks, registerPanelGroupProvider, controllerIndexFor, hapticPattern, triggerClaimed } from './vrControls'; +import { isModuleTopLevel } from './moduleWorld'; +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'; @@ -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; } @@ -112,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/src/lib/vrGamePanel.js b/src/lib/vrGamePanel.js index 33e856cc..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'; @@ -341,8 +342,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 +369,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)'; @@ -525,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/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/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/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 diff --git a/tests/e2e/vr-bounded-teleport.test.cjs b/tests/e2e/vr-bounded-teleport.test.cjs new file mode 100644 index 00000000..ff1a70a1 --- /dev/null +++ b/tests/e2e/vr-bounded-teleport.test.cjs @@ -0,0 +1,197 @@ +// 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)})`); + + // ---- 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; + 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); +}); diff --git a/tests/e2e/vr-laser-panels.test.cjs b/tests/e2e/vr-laser-panels.test.cjs new file mode 100644 index 00000000..dc54e2e3 --- /dev/null +++ b/tests/e2e/vr-laser-panels.test.cjs @@ -0,0 +1,252 @@ +// 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=== 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; + 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); +}); 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/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); +}); 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); +}); 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); + }); +});