Skip to content
Merged
47 changes: 47 additions & 0 deletions MODULES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions src/components/Scene.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down
16 changes: 10 additions & 6 deletions src/components/play/VRMenu.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -105,7 +109,7 @@
<T.Mesh name={`vrmenu-${s.entry.id}`}>
<T.RingGeometry args={[RING_INNER, RING_OUTER, 20, 1, s.thetaStart, s.thetaLength]} />
<T.MeshBasicMaterial
color={sectorColor(s.entry)}
color={sectorColor(s.entry, $vrHovered)}
transparent
opacity={0.94}
side={THREE.DoubleSide}
Expand All @@ -114,7 +118,7 @@
{#if s.entry.label}
<Text
text={typeof s.entry.label === 'function' ? s.entry.label() : s.entry.label}
color={labelColor(s.entry)}
color={labelColor(s.entry, $vrHovered)}
outlineColor="#000000"
outlineWidth={0.0012}
fontSize={sectors.length > 8 ? 0.0095 : 0.0115}
Expand Down
26 changes: 18 additions & 8 deletions src/lib/locomotionPolicy.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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;
}

Expand All @@ -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);
Expand Down
20 changes: 20 additions & 0 deletions src/lib/moduleSDK.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand Down
19 changes: 16 additions & 3 deletions src/lib/playSettings.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand All @@ -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 };
Expand Down
7 changes: 5 additions & 2 deletions src/lib/scenePhysics.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/lib/splineEdit.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
Loading
Loading