diff --git a/CHANGELOG.md b/CHANGELOG.md
index f5ee71c0..9c5ba88d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,131 @@
per release, newest first. HTML comments like this one are stripped before
rendering, so maintainer notes stay out of the user-facing window. -->
+## 1.17.0 โ Games that look finished ๐จ
+
+### ๐๏ธ Dragging, clicking and selecting that do what you meant
+
+- ๐๏ธ **Dragging in games no longer throws on Quest.** Every press in play logged a
+ `setPointerCapture` InvalidStateError (about a hundred in one Untangle session) and leaving play
+ could end in "Something went wrong": the editor's orbit controls stayed mounted in play and
+ captured the pointer under a pointer lock. They stand down while you play now, and the capture
+ is guarded.
+- ๐ **Edit and Interact.** A new cell at the end of the bottom bar (or the **I** key) switches
+ the editor between Edit โ every click selects, so every object can be moved โ and Interact,
+ where clicks and drags work the way they do in play (piano keys play, crates carry) without
+ starting the game. Module click handlers say which modes they run in; by default a game piece
+ answers in Interact and Play and an Edit click selects it.
+- ๐ป **Click through glass.** A see-through wall or a shell marked "Click-through in the viewport"
+ (Inspector โธ Object) no longer steals the click from what is behind it, and clicking the same
+ spot again (after the double-click window) cycles down through everything under the cursor.
+- ๐งฉ **Every module's content is in the object list.** A new "Module content" section lists the
+ pieces a module builds outside the scene (the Untangle board, a piano, a dungeon) by name;
+ click a row to frame it, hide it locally, or open the module's toolbox.
+
+### ๐ฎ Games start when you press Start
+
+- โถ๏ธ **Test play.** A game no longer paints its menu over the editor with live buttons. A small
+ "Game ยท menu" chip says the scene is a game, and โถ Test play resets it to its menu, enters
+ Play and puts the Start screen in front of you (also on the play button's right-click menu).
+ The HUD editor's eye still previews the screens, inert.
+- ๐ **The round resets when the last player leaves.** Alone, leaving Play returns the game to its
+ menu at once; with others, when the last player has been out for ten seconds.
+- ๐ฑ๏ธ **Free-cursor games.** A scene can play with the real mouse cursor and no pointer lock
+ (Untangle and the Jam Room do): the cursor aims, clicks fire On Click, press-drag carries. The
+ editor grid no longer shows in Play.
+- ๐พ **Games remember things.** `api.storage` for modules and the new **Store Value** / **Stored
+ Value** nodes keep a best score or your unlocked levels on this device (never shared, never
+ saved into the scene). The HUD action "Save best score" wires one up.
+- ๐ **A quieter console.** The PCFSoftShadowMap deprecation, the router pushState warning and
+ rapier's init warning are gone.
+
+### ๐จ Seven games that look finished
+
+- ๐๏ธ **Towers remembers your best height** โ and looks finished: a sky, a tiled floor, wooden
+ crates, glowing height rings, turning stars; Round over shows this round and your best.
+- ๐ **Stars Room is a space room** โ a starfield behind glass, crystal stars, a start screen
+ with Start round or Free play, a two-minute round, your best round saved.
+- ๐ธ **Jam Room is a game** โ a small studio; press Start, then โถ on the Transport, and keep the
+ band going for eight bars. Played with the mouse, your best tempo saved.
+- ๐๏ธ **Football** is a floodlit glass stadium with a live scoreboard.
+- ๐ฐ **Dungeon Realms** is torch-lit stone under a vault, with glowing gems, animated portals and
+ a Start menu you can click.
+- ๐ **Waves** is a sunset arena with a crystal tower that dims with your health, portals,
+ readable enemies and hit flashes.
+- ๐ชข **Untangle, finished:** a real drag (press-drag-release, or click then click), a redrawn
+ board, 30 levels per mode that unlock as you solve them (your progress stays on this device,
+ with a Reset), and a new 3D mode โ untangle the graph around a globe.
+- Every game has a Start screen, Pause on **P**, and a restart.
+
+### ๐ฅฝ Games in a headset
+
+- ๐ฎ **Play in VR puts you in Interact.** A game opens in Interact in a headset: your grips grab
+ and knock things instead of the world, the stick WALKS you (walls stop you, gravity holds you,
+ small steps climb), and there is no flying or teleporting unless the scene allows it. The left
+ **Y** button switches to Edit and back (a tick in your hand and a small wrist label say which),
+ and the game can put you on its start spot. In Edit, grips move and scale the world again โ
+ in game scenes too.
+- ๐๏ธ **No more light helper in one eye.** Editor helpers drew into the left eye only in a
+ headset. Now they are in both eyes in Edit and gone in Interact and Play โ together with the
+ grid, collider wireframes and selection outlines.
+- ๐ **The game menu in VR.** A game's menu, pause and results screens float in front of you and
+ answer your laser and trigger (or a poke); the score and timer sit on your left wrist and,
+ if you like, in a strip at the top of your view.
+- ๐น **Hold the trigger and sweep.** Hold the trigger and brush across piano keys, drum steps,
+ pads or buttons โ each one you pass fires once.
+- ๐ **Module content follows the world.** Grab and turn the world and the Untangle board (and
+ every module's content) turns with it.
+
+### ๐ Games that sound and feel like games
+
+- ๐ 20 built-in game sounds, 7 music loops (quiet under the sound effects, only while you
+ play), a big centred banner for moments like "GOAL!" or "Floor 3", sparkle / confetti / smoke /
+ spark bursts, and controller vibration patterns (in Interact and Play only). Settings โธ Sound
+ has a "Game sounds" and a "Music" volume.
+- ๐งฉ New Game nodes: **Announce**, **Game Sound**, **Effect Burst**, **Controller Buzz**,
+ **Game Music** and the **On Grab** trigger.
+- ๐๏ธ **Towers** tells you when your tower reaches each ring (a banner, sparkles at the ring,
+ a chime), and the gold ring at the top is a fanfare with confetti. **Stars Room** sparkles where
+ you hit a star and pays a coin when it lights. **The Jam Room is a VR cockpit**: stand in the
+ middle and every instrument is in reach; a sweep also stomps the pedals and flips the mixer
+ mutes (Music FX 0.2.0).
+- โฝ **Football plays like football**: your first touch kicks a match off, 3-2-1, GOAL! with
+ confetti and a crowd, the conceding team kicks off, first to 5 or 3:00 with a golden goal;
+ swing a controller through the ball to kick it; the consoles stand outside the court.
+- ๐ฐ **Dungeon Realms is lit everywhere**, with real wall torches, footsteps, gem chimes, a
+ "Floor N" banner and dungeon music; you can no longer walk through pillars, crates or chests.
+- ๐ซ **Waves is a VR shooter**: a gun in your hand (Blaster, Scatter, Beam), an ability on your
+ grip (Shield, Slow-mo, Pulse), five levels of rigged grunts, runners and tanks walking at the
+ crystal, a menu with How to play / Loadout / Options, and a Start board you shoot in a headset.
+- ๐ชข **Untangle** drags with a controller's trigger (laser or tip), lets you hold and turn the
+ globe in one hand, and lost its drone: one sound per event and a quiet puzzle loop.
+
+### ๐งฑ Kits and levels
+
+- ๐งฑ Four new packs in Explorer โธ Packs โ **Modular Architecture**, **Nature & Terrain**,
+ **Props & Interiors** and **Sci-fi & Modern** โ over 120 pieces that snap to the 1 m grid (CC0,
+ made with Meshy.ai).
+- ๐บ๏ธ Three walkable example levels in Templates โธ General: **Castle Courtyard**, **Forest
+ Clearing** and **Tavern Interior**. Kit pieces are saved as references to their pack, so a
+ level of 150 pieces is about 25 KB. Configure Scene โธ Physics โธ Play mode โธ Spawn point.
+- โฉ๏ธ Placing a kit piece is undoable (it was "too large for undo history").
+- ๐ ๏ธ **Fixed:** moves made with the gizmo, Align to ground or an Explorer drop were refused by
+ peers (a rotation sent with its order letters); loading a scene while the simulation runs no
+ longer leaves the old physics world behind.
+
+### ๐งฐ For authors
+
+- ๐งฑ **Game and template defs can say more:** rounded boxes, capsules, rings, planes,
+ ico/dodecahedra; spot, directional (shadows fitted to the scene) and hemisphere lights; glass,
+ clearcoat, sheen and toon materials; animation and particle presets by name; click-through
+ shells; custom skies with a gradient, fog and a solid ground. Games-tab cards are rendered with
+ each game's own lighting.
+- ๐ค **Centred HUD text is centred.** A single-line HUD text sat flush left inside its box
+ whatever its alignment said.
+- ๐งฉ SDK: `registerClickHandler(fn, {modes})`, `api.registerListedGroup(name, {label})`,
+ `api.storage`, `api.editorMode()`, and `api.pointerRay()` is the crosshair ray under a pointer lock and the
+ cursor's ray in a free-cursor game.
+
## 1.16.0 โ Waves, and one world to keep ๐
### ๐ฎ A new game, and a Games tab that can move again
diff --git a/CLAUDE.md b/CLAUDE.md
index 9de987e5..c3d92adf 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -2156,6 +2156,153 @@ loadable play content. Everything a user does must be visible to connected peers
`#embed-play` (โถ while not playing, so an Esc inside the frame has a way back in). Absent = the
old app. The community Worker's `/e/` frames the app at `/?s=&play=1&embed=1`; no
separate viewer build. Suite `embed-boot` (20).
+- **ROADMAP 30 โ EDIT / INTERACT, AND THE SELECTION THAT FINDS THINGS** (1.17.0; plan + as-built:
+ cloud `plans/core/roadmap-30-games-that-look-finished.md`). `sceneStore.editorMode`
+ ('edit' | 'interact', LOCAL โ never replicated, never saved) + `objectActions.setEditorMode`; the
+ toggle is the `mode` cell at the RIGHT END of the default Controls bar (`#editor-mode-toggle`, a
+ `
+ {/if}
{/if}
{/each}
@@ -2360,6 +2424,8 @@
{/each}
{/if}
+
+
{/if}
{/if}
diff --git a/src/components/menu/Inspector.svelte b/src/components/menu/Inspector.svelte
index 703c2dd1..3e856961 100644
--- a/src/components/menu/Inspector.svelte
+++ b/src/components/menu/Inspector.svelte
@@ -30,7 +30,9 @@
import { recordEntry, beginHistoryBatch, endHistoryBatch, recordTransformSet } from '$lib/history';
import { deviceOf, deviceSpec, isDeviceObject, setDeviceFor, previewDeviceParams } from '$lib/audioDevices';
import { MUSIC_TOOLBOX_ID, musicToolboxPick } from '$lib/musicToolbox';
- import { openModuleToolbox } from '$lib/moduleToolboxes';
+ import { openModuleToolbox, moduleToolboxes } from '$lib/moduleToolboxes';
+ // 30 P3: a Module content row selected in the object list (a proxy, not an object)
+ import { moduleSelection } from '$lib/moduleContent';
import { canEditObject } from '$lib/objectPermissions';
import {
attachMultiPivot,
@@ -56,7 +58,7 @@
import { LIGHT_PARAMS, SHADOW_TYPES, SHADOW_SIZES, setShadowMapSize, cappedShadowSize } from '$lib/lightParams';
import { animatedObjects, setAnimationState } from '$lib/animatedImports';
import { captureAutoKey, playheadOf } from '$lib/animationPreview';
- import { moveObjectToGroup, selectObject, flyTo } from '$lib/objectActions';
+ import { moveObjectToGroup, selectObject, flyTo, setPickThrough } from '$lib/objectActions';
import { listPhysicsObjects, enablePhysicsOnSelection, setPhysicsFor, PHYSICS_MATERIALS } from '$lib/physics';
import {
sceneGravity,
@@ -167,6 +169,7 @@
selectedObjects,
backgroundColor,
globalCamera,
+ orbitControls,
viewMode,
showGrid, pokeScene } from '../../stores/sceneStore';
// 16-P3: grid + snapping prefs (LOCAL, like the clip planes)
@@ -591,6 +594,16 @@
if (object?.uuid) captureAutoKey(object.uuid, playheadOf(object.uuid));
}
}
+ // 30 P3: the module toolbox a Module content card can open (null = the module has none)
+ const moduleToolbox = $derived(
+ $moduleSelection ? $moduleToolboxes.find((/** @type {any} */ box) => box.moduleId === $moduleSelection.moduleId) ?? null : null
+ );
+ // 30 P2: every member is click-through (a mixed set reads unchecked; ticking it marks them all)
+ const pickThroughAll = $derived.by(() => {
+ $selectedObject;
+ $objectsGroup;
+ return insTargets.length > 0 && insTargets.every((/** @type {any} */ object) => object?.userData?.pick === 'through');
+ });
/** @param {string} label @param {(object:any)=>void} fn */
function fan(label, fn) {
fanOn(insTargets, label, fn);
@@ -1392,6 +1405,28 @@
if (!direction.lengthSq()) direction.set(-1, 1, 1).normalize();
flyTo(direction.multiplyScalar(distance).toArray(), [0, 0, 0]);
}
+ /**
+ * 30c: the play SPAWN from the editor view โ the point the view orbits around is where
+ * the player's feet go, and the camera's heading toward it is the way they face (yaw 0
+ * looks down -Z, the fixed start's direction).
+ */
+ function setSpawnFromView() {
+ /** @type {any} */
+ const camera = $globalCamera;
+ /** @type {any} */
+ const controls = $orbitControls;
+ const target = controls?.target;
+ if (!camera || !target) return;
+ const yaw = Math.atan2(-(target.x - camera.position.x), -(target.z - camera.position.z));
+ const r2 = (/** @type {number} */ v) => Math.round(v * 100) / 100;
+ setScenePhysics({ play: { spawn: { position: [r2(target.x), r2(target.y), r2(target.z)], yaw: Math.round(yaw * 1000) / 1000 } } });
+ }
+ /** @param {any} spawn */
+ function spawnText(spawn) {
+ if (!spawn) return 'Default โ (0, 2, 3), facing โZ';
+ const deg = Math.round((((spawn.yaw * 180) / Math.PI) % 360 + 360) % 360);
+ return '(' + spawn.position.map((/** @type {number} */ v) => v.toFixed(1)).join(', ') + '), facing ' + deg + 'ยฐ';
+ }
/** shared look for the small bookmark row buttons */
const bmBtn = 'shrink-0 rounded-sm bg-gray-700 px-1.5 py-0.5 text-xs text-gray-300 hover:bg-gray-600 disabled:opacity-40';
function resetView() {
@@ -2593,6 +2628,24 @@
>
Start the simulation when play mode opens
+
+
+ Spawn point
+ {spawnText($scenePlay.spawn)}
+
+
+
+ {#if $scenePlay.spawn}
+
+ {/if}
+
Shared: everyone entering play mode in this scene gets these.
+{/if}
+
+{#if menu}
+ (menu = null)} />
+{/if}
diff --git a/src/components/menu/Settings.svelte b/src/components/menu/Settings.svelte
index 496ad0be..be5b89b2 100644
--- a/src/components/menu/Settings.svelte
+++ b/src/components/menu/Settings.svelte
@@ -3,6 +3,9 @@
import { HardDrive, Lock, RotateCcw, X } from '@lucide/svelte';
import ThemedSelect from '../ui/ThemedSelect.svelte';
import SettingRow from './SettingRow.svelte';
+ // 30b (vr-play) C5: the two LOCAL game-audio volumes
+ import { gameSoundVolume } from '$lib/gameSfx';
+ import { gameMusicVolume } from '$lib/gameMusic';
import { showGrid, vrOverride, vrMenuHand, vrSnapAngle, vrMirrorSnapTurn, vrTeleportEnabled, vrSleeveEnabled, vrVertexHold, vrFlying, vrPassthrough, vrMenuHold, vrTargetHz, peerHandStyle } from '../../stores/sceneStore.js';
import { applyVRFrameRate } from '$lib/vrControls';
import { settingsOpen, settingsSection, hidePanels, restorePanels, advancedMode, showEnvInList, objectSearchEnabled, showSimControls, showToast, showRoomsButton, toastsInDrawerOnly, mobileUndockAllowed, enableShiftAdd, noteDoubleClickToOpen, duplicateCarriesAnimation, duplicateCarriesFlow, duplicateCarriesShader, touchTools, floatingToolbar, toolbarAlwaysOnTop } from '../../stores/appStore.js';
@@ -767,6 +770,17 @@
{/if}
+
Sound
+
+
+ How loud a game's effects are on this device โ coins, goals, hits, the clicks a
+ game makes. Local to you; {Math.round($gameSoundVolume * 100)}%
+
+
+
+ The background music a game plays while you are in Interact or Play (it stops when
+ you go back to editing). Local to you; {Math.round($gameMusicVolume * 100)}%
+
Notifications
diff --git a/src/components/play/PlayReticle.svelte b/src/components/play/PlayReticle.svelte
index 993276a6..e8db4c9f 100644
--- a/src/components/play/PlayReticle.svelte
+++ b/src/components/play/PlayReticle.svelte
@@ -5,6 +5,7 @@
// playInteract.js.
import { isLocked, isVRMode } from '../../stores/sceneStore';
import { playInteractState } from '$lib/playInteract';
+ import { playCursorFree } from '$lib/playCursor';
import { safeStorage } from '$lib/safeStorage';
// the scroll hint is worth exactly one showing, so it is a LOCAL pref and
@@ -14,7 +15,10 @@
);
const reticle = $derived($playInteractState);
- const visible = $derived($isLocked && !$isVRMode && reticle.mode !== 'off');
+ // 30 P3: a free-cursor game aims with the real cursor, so there is no crosshair to draw.
+ // $playInteractState is the dependency that re-reads it (it moves on every aim change,
+ // and playCursorFree reads its stores through get()).
+ const visible = $derived($isLocked && !$isVRMode && reticle.mode !== 'off' && !playCursorFree());
const carrying = $derived(reticle.mode === 'carrying');
$effect(() => {
diff --git a/src/components/play/PointerLockControls.svelte b/src/components/play/PointerLockControls.svelte
index 018c6484..c8f9b900 100644
--- a/src/components/play/PointerLockControls.svelte
+++ b/src/components/play/PointerLockControls.svelte
@@ -1,12 +1,15 @@
diff --git a/src/components/play/VRSelectionShell.svelte b/src/components/play/VRSelectionShell.svelte
index 24258eed..faa46c19 100644
--- a/src/components/play/VRSelectionShell.svelte
+++ b/src/components/play/VRSelectionShell.svelte
@@ -5,6 +5,8 @@
import { selectedObject, isVRMode, objectsGroup, vrWireframeSelection } from '../../stores/sceneStore'
import { editingObject } from '$lib/meshEdit'
import { faceEditObject } from '$lib/faceEdit'
+ // 30b P1: a selection indicator is editor scaffolding - none in Interact/Play
+ import { editorHelpersShown } from '$lib/helperLayer'
// VR selection indicator (101/110): the desktop outline is a postprocessing
// composer and does NOT render in WebXR. Default is a two-tone wireframe โ
@@ -106,7 +108,7 @@
// the live structure โ a static indicator would just lag behind edits
const editing = !!$editingObject || !!$faceEditObject
const active =
- $isVRMode && !editing && !!target?.uuid && !!$objectsGroup?.getObjectByProperty('uuid', target.uuid)
+ $isVRMode && $editorHelpersShown && !editing && !!target?.uuid && !!$objectsGroup?.getObjectByProperty('uuid', target.uuid)
group.visible = active
if (!active) return
const style = $vrWireframeSelection ? 'wire' : 'shell'
diff --git a/src/extensions/Grid.svelte b/src/extensions/Grid.svelte
index 8fb6b9af..1abce886 100644
--- a/src/extensions/Grid.svelte
+++ b/src/extensions/Grid.svelte
@@ -2,6 +2,11 @@
import { Grid } from '@threlte/extras'
import { T, useThrelte, useTask } from '@threlte/core'
import { orbitControls } from '../stores/sceneStore'
+ // 30 P3: the grid is an EDITOR helper โ it drew under/through the ground in five of the
+ // seven Games-tab games. Hidden while playing, on every scene; the existing "Show
+ // helpers in Play (debug)" toggle is the one way to keep it (no new setting).
+ // 30b P1: ...and in Interact, the same one predicate every editor helper answers to
+ import { editorHelpersShown } from '../lib/helperLayer'
import { gridSettings, effectiveCell } from '../lib/gridSettings'
import { snapSettings } from '../lib/snapping'
let { showGrid } = $props()
@@ -71,8 +76,9 @@
const fadeDistance = $derived($gridSettings.fadeMode === 'auto' ? fade : $gridSettings.fadeDistance)
- {#if showGrid}
+ {#if showGrid && $editorHelpersShown}
feet + `height`.
+ * Returns the resolved world step and the new feet height. Owns the module's vertical
+ * state (vy / grounded / the jump edge): desktop play and a VR session never walk at once.
+ *
+ * 30b (asked by the dungeon lane): the dungeon RASTER now also clamps the rapier tier.
+ * A Kit dungeon's walls are module InstancedMeshes, never rapier colliders, so with a
+ * simulation running the capsule found nothing to stop it and walked through every wall.
+ * @param {{x: number, y: number, z: number}} feetPos
+ * @param {number} height eye/capsule height in metres
+ * @param {number} dt seconds
+ * @param {{dx: number, dz: number, dy?: number}} desired the wanted step, world units
+ * (`dy` only without gravity โ a flier's vertical intent)
+ * @param {{gravity?: boolean, jumpHeight?: number}} [opts]
+ * @returns {{dx: number, dy: number, dz: number, feet: number, grounded: boolean, vy: number, source: 'rapier'|'dungeon'|'plane'}}
+ */
+export function resolveWalk(feetPos, height, dt, desired, opts = {}) {
+ const step = Math.max(0, Math.min(dt, 0.1)); // a tab that was backgrounded
+ const eyeHeight = Math.max(0.1, Number(height) || 1.7);
+ const useGravity = opts.gravity !== false;
+ const g = Math.abs(Number(get(sceneGravity)) || 9.81);
+ _worldPos.set(feetPos.x, feetPos.y + eyeHeight, feetPos.z);
+ let feet = feetPos.y;
// gravity + the jump edge. The jump is spent only while we are ON something, so a
// press in mid-air is DROPPED rather than queued โ a queued one fires on landing,
@@ -321,7 +361,7 @@ export function tickWalker(rig, settings, dt, desired) {
if (useGravity) {
if (jumpRequested && grounded) {
jumpRequested = false;
- vy = Math.sqrt(2 * g * Math.max(0, Number(settings?.jumpHeight ?? 0) || 0));
+ vy = Math.sqrt(2 * g * Math.max(0, Number(opts.jumpHeight ?? 0) || 0));
}
vy -= g * step;
} else {
@@ -333,7 +373,7 @@ export function tickWalker(rig, settings, dt, desired) {
let source = 'plane';
let dx = desired?.dx ?? 0;
let dz = desired?.dz ?? 0;
- let dy = useGravity ? vy * step : 0;
+ let dy = useGravity ? vy * step : Number(desired?.dy ?? 0) || 0;
const built = ensureCapsule(physicsRuntime(), eyeHeight);
if (built && capsule && controller) {
@@ -345,6 +385,13 @@ export function tickWalker(rig, settings, dt, desired) {
dx = moved.x;
dy = moved.y;
dz = moved.z;
+ // 30b: a dungeon raster clamps the capsule's step too (see resolveWalk)
+ const raster = dungeonData(get(globalScene));
+ if (raster && (dx || dz)) {
+ const slid = slideMove(raster, _worldPos.x, _worldPos.z, dx, dz, CAPSULE_RADIUS);
+ dx = slid.x - _worldPos.x;
+ dz = slid.z - _worldPos.z;
+ }
grounded = !!controller.computedGrounded();
feet += dy;
if (grounded && vy < 0) vy = 0;
@@ -356,7 +403,7 @@ export function tickWalker(rig, settings, dt, desired) {
console.log('character controller step failed', error);
dropCapsule();
source = 'plane';
- dy = useGravity ? vy * step : 0;
+ dy = useGravity ? vy * step : Number(desired?.dy ?? 0) || 0;
}
}
@@ -373,8 +420,15 @@ export function tickWalker(rig, settings, dt, desired) {
const floor = raster ? 0 : floorHeight();
feet += dy;
if (!useGravity) {
- feet = floor;
- grounded = true;
+ // a flier (30b: `desired.dy` given) keeps its height above the floor; the walker
+ // with gravity off never passes dy, so it still stands ON the floor, unchanged
+ if (desired?.dy == null) {
+ feet = floor;
+ grounded = true;
+ } else {
+ feet = Math.max(floor, feet);
+ grounded = feet <= floor + 1e-4;
+ }
} else if (feet <= floor + 1e-4) {
feet = floor;
if (vy < 0) vy = 0;
@@ -384,12 +438,6 @@ export function tickWalker(rig, settings, dt, desired) {
}
}
- // write back through the rig's PARENT: the camera lives in a group at y = 0.9, so a
- // world target has to be converted rather than assigned
- _target.set(_worldPos.x + dx, feet + eyeHeight, _worldPos.z + dz);
- if (rig.parent) rig.parent.worldToLocal(_target);
- rig.position.copy(_target);
-
const state = { vy, grounded, ground: feet, source };
const previous = get(walkerState);
if (
@@ -399,7 +447,47 @@ export function tickWalker(rig, settings, dt, desired) {
Math.abs(previous.ground - state.ground) > 1e-4
)
walkerState.set(state);
- return { grounded, vy, source };
+ return { dx, dy, dz, feet, grounded, vy, source };
+}
+
+/** the head-sized capsule the built-in flier collides with: eye .. eye - FLY_BODY */
+const FLY_BODY = 1.0;
+
+/**
+ * 30b P3: DESKTOP PLAY'S BUILT-IN FLIER COLLIDES. With no Character Controller node,
+ * PointerLockControls moves the camera rig freely (WASD/QE/the pad) and the only wall it
+ * ever respected was a dungeon raster, so a player flew through every wall of every game.
+ * After the built-in step, the rig's world displacement since `before` is resolved through
+ * the walker's rapier capsule (a 1 m body hanging below the eye โ a wall stops you, a floor
+ * holds you up, a low rim can still be flown over). Only while a simulation RUNS, because
+ * only then does a world with colliders exist; otherwise nothing happens and the step is
+ * byte-for-byte the old one (the 21-E6 parity contract: an empty scene flies as before).
+ * @param {any} rig the camera object PointerLockControls drives
+ * @param {{x: number, y: number, z: number}} before the rig's WORLD position before the step
+ * @returns {boolean} whether a collision pass ran
+ */
+export function collideRigStep(rig, before) {
+ if (!rig) return false;
+ const rt = physicsRuntime();
+ if (!rt) return false;
+ if (!ensureCapsule(rt, FLY_BODY) || !capsule || !controller) return false;
+ rig.getWorldPosition(_worldPos);
+ const d = { x: _worldPos.x - before.x, y: _worldPos.y - before.y, z: _worldPos.z - before.z };
+ if (Math.abs(d.x) + Math.abs(d.y) + Math.abs(d.z) < 1e-7) return false;
+ try {
+ capsule.setTranslation({ x: before.x, y: before.y - FLY_BODY / 2, z: before.z });
+ controller.computeColliderMovement(capsule, d);
+ const moved = controller.computedMovement();
+ _target.set(before.x + moved.x, before.y + moved.y, before.z + moved.z);
+ capsule.setTranslation({ x: _target.x, y: _target.y - FLY_BODY / 2, z: _target.z });
+ if (rig.parent) rig.parent.worldToLocal(_target);
+ rig.position.copy(_target);
+ return true;
+ } catch (error) {
+ console.log('fly collision failed', error);
+ dropCapsule();
+ return false;
+ }
}
/** Forget the walker's motion (a mode change, the controller removed, a test starting
diff --git a/src/lib/colliderHelpers.js b/src/lib/colliderHelpers.js
index 5c7aaeb4..5205f0f9 100644
--- a/src/lib/colliderHelpers.js
+++ b/src/lib/colliderHelpers.js
@@ -7,6 +7,7 @@ import { colliderSpecOf } from './colliderSpec';
import { wireframeActive } from './viewMode';
import { scenePhysicsGround } from './scenePhysics';
import { safeStorage } from './safeStorage';
+import { helpersHidden } from './helperLayer';
// CL-A A7: collider visualization (the lightHelpers pattern). Per tracked
// object a wireframe built FROM colliderSpecOf โ the SAME spec physics
@@ -233,7 +234,8 @@ const followQuat = new THREE.Quaternion();
* entirely in wireframe view mode (they'd render as junk). */
export function updateColliderHelpers() {
if (!proxyRoot) return;
- proxyRoot.visible = (entries.size > 0 || !!groundProxy) && !wireframeActive();
+ // 30b P1: collider/trigger wireframes are editor scaffolding โ gone in Interact and Play
+ proxyRoot.visible = (entries.size > 0 || !!groundProxy) && !wireframeActive() && !helpersHidden();
if (!proxyRoot.visible) return;
entries.forEach((entry) => {
entry.object.updateMatrixWorld();
diff --git a/src/lib/commandsHandler.svelte.js b/src/lib/commandsHandler.svelte.js
index 6b8f1332..a40bac20 100644
--- a/src/lib/commandsHandler.svelte.js
+++ b/src/lib/commandsHandler.svelte.js
@@ -17,6 +17,7 @@ import { hasAnimatedImport, sendAnimatedImport, setAnimationState, dropAllAnimat
import { dropAllAnimations } from '$lib/animationPreview'
import { parkAnimatedAtBase } from '$lib/flowRuntime'
import { stripEditOverlays } from '$lib/editOverlays'
+import { isPristinePackRef, stubElementOf } from '$lib/packRefs'
import { runSceneClearHandlers } from '$lib/moduleSDK'
import { annotations } from '$lib/annotationsHandler'
import { isViewer, warnViewerReadOnly } from '$lib/objectPermissions'
@@ -601,6 +602,14 @@ export async function objectParameters(data) {
pokeScene(); // collider viz re-syncs
physicsShapeChanged(data.uuid); // CL-A A2: live mid-sim rebuild
}
+ } else if (data.parameter == 'pick') {
+ // 30 P2: click-through in the viewport. null = cleared (the default).
+ let mesh = sceneObjects.getObjectByProperty('uuid', data.uuid);
+ if (mesh) {
+ if (data.pick === 'through') mesh.userData.pick = 'through';
+ else delete mesh.userData.pick;
+ pokeScene();
+ }
} else if (data.parameter == 'origin') {
// 17-D: userData.origin is the per-object transform ORIGIN โ a local-space
// pivot offset the tools transform around. null = the object's own zero.
@@ -935,6 +944,12 @@ async function applyCreateObject(object, uuid, override, groupuuid, pos, rot, sc
*/
export function sendObjects(peerId, element, opts = {}) {
let groupid;
+ if (peerId === null && isPristinePackRef(element)) {
+ // 30c: a placed KIT PIECE reaches every peer as its stub; each refills it from
+ // the pack (no GLTF of a 1 MB piece per placement โ see packRefs.js)
+ peer.send({ type: 'object', element: stubElementOf(element), groupuuid: element.parent?.uuid, ...(opts.override ? { override: true } : {}) });
+ return;
+ }
if (peerId === null) {
groupid = element.uuid;
peer.send({type: 'group', name: element.name, uuid: element.uuid, groupparent: null,
@@ -1031,6 +1046,19 @@ export function sendObject(conn, element, groupuuid, opts = {}) {
objects.forEach(element => {
// viewer perms: never sync a viewer's local-only objects to a peer
if (element.userData && element.userData.__localOnly) return;
+ if (isPristinePackRef(element)) {
+ // 30c: a pristine KIT PIECE travels as a stub (one small message) and the
+ // receiver refills it from its pack โ packRefs.js carries the measurement (one
+ // wall is 7.9 MB as toJSON). ObjectLoader path: the element's matrix is LOCAL,
+ // and the receiver adds it under `groupuuid`.
+ conn.send({
+ type: 'object',
+ element: stubElementOf(element),
+ groupuuid: element.parent?.uuid,
+ ...heal
+ });
+ return;
+ }
if (hasAnimatedImport(element.uuid)) {
// rigs travel as their original file bytes, one message
sendAnimatedImport(conn, element, opts);
@@ -1163,7 +1191,8 @@ function countObjects(element, sink) {
objects = sceneObjects.children;
}
objects.forEach(element => {
- if (element.type == "Group" && !hasAnimatedImport(element.uuid)) {
+ // 30c: a kit-piece stub is ONE message; its children are refilled, never sent
+ if (element.type == "Group" && !hasAnimatedImport(element.uuid) && !isPristinePackRef(element)) {
countObjects(element, sink);
}
sink.push(element.uuid)
diff --git a/src/lib/effectsBurst.js b/src/lib/effectsBurst.js
new file mode 100644
index 00000000..02d98344
--- /dev/null
+++ b/src/lib/effectsBurst.js
@@ -0,0 +1,315 @@
+// 30b (vr-play) C6 โ `api.effects.burst([x,y,z], {kind, color, count})`: a short-lived
+// particle burst for a game's moments โ the sparkle when a Towers ring is reached, the
+// confetti on a goal, smoke off an explosion, sparks off a hit.
+//
+// POOLED: a fixed set of THREE.Points systems (POOL_SIZE x MAX_COUNT particles) built on
+// first use and recycled oldest-first, so a burst per frame costs no allocation and no GPU
+// upload of a new geometry โ only the attribute ranges it rewrites. LOCAL by contract:
+// nothing is sent (a module that wants peers to see it broadcasts its own op), and the
+// systems live at the SCENE ROOT under fixed names (golden rule 5: objectsGroup is
+// replicated, the scene root is local), so no burst can leak into a save or a GLTF sync.
+//
+// Ticked from the flow runtime's frame loop (the module frame-task list โ it runs on the
+// desktop AND in a headset, where window rAF stops), and parked invisible between bursts.
+// `depthWrite: false` is right for soft sprites; a burst is short enough that the AO/outline
+// trade-off (the documented depthWrite trap) does not matter.
+//
+// Kinds (the motion model per kind is the only thing that differs):
+// sparkle gold twinkling stars that drift up, additive
+// confetti many-coloured flakes thrown up, falling under gravity, normal blending
+// smoke grey puffs that rise, swell and thin
+// sparks fast orange streaks, gravity, very short
+import * as THREE from 'three';
+import { get } from 'svelte/store';
+import { globalScene, globalRenderer, globalCamera } from '../stores/sceneStore';
+import { moduleFrameTasks } from './moduleSDK';
+
+export const BURST_KINDS = ['sparkle', 'confetti', 'smoke', 'sparks'];
+export const POOL_SIZE = 12;
+export const MAX_COUNT = 96;
+
+/**
+ * The per-kind recipe. Pure data; `life` in seconds, speeds in m/s, `size` in metres.
+ * @type {Record}
+ */
+export const BURST_RECIPES = {
+ sparkle: { count: 40, life: 1.1, speed: 1.2, up: 0.8, gravity: -0.4, drag: 1.6, size: 0.09, grow: -0.5, additive: true, color: '#ffd76a' },
+ confetti: { count: 80, life: 2.2, speed: 3.2, up: 3.2, gravity: -5.5, drag: 1.2, size: 0.07, grow: 0, additive: false, color: '#ffffff', palette: ['#ef4444', '#f59e0b', '#22c55e', '#3b82f6', '#a855f7', '#ec4899'] },
+ smoke: { count: 28, life: 2.4, speed: 0.5, up: 0.9, gravity: 0.3, drag: 0.8, size: 0.35, grow: 1.4, additive: false, color: '#9ca3af' },
+ sparks: { count: 60, life: 0.55, speed: 5.5, up: 1.5, gravity: -9.8, drag: 0.6, size: 0.045, grow: -0.6, additive: true, color: '#ffa53a' }
+};
+
+/**
+ * Where each particle starts heading: a deterministic spread over the sphere (a golden-
+ * angle spiral), so a burst looks the same every time and the maths is testable. Returns
+ * unit directions with the upward kick of the recipe folded into y later.
+ * @param {number} n @returns {number[][]}
+ */
+export function burstDirections(n) {
+ /** @type {number[][]} */
+ const out = [];
+ const golden = Math.PI * (3 - Math.sqrt(5));
+ for (let i = 0; i < n; i++) {
+ const y = 1 - (2 * (i + 0.5)) / n;
+ const r = Math.sqrt(Math.max(0, 1 - y * y));
+ const a = golden * i;
+ out.push([Math.cos(a) * r, y, Math.sin(a) * r]);
+ }
+ return out;
+}
+
+/** @type {THREE.Texture | null} */
+let sprite = null;
+/** a soft round dot, drawn once (a square point reads as a pixel, not a particle) */
+function spriteTexture() {
+ if (sprite) return sprite;
+ const canvas = document.createElement('canvas');
+ canvas.width = canvas.height = 64;
+ const g = /** @type {CanvasRenderingContext2D} */ (canvas.getContext('2d'));
+ const grad = g.createRadialGradient(32, 32, 0, 32, 32, 32);
+ grad.addColorStop(0, 'rgba(255,255,255,1)');
+ grad.addColorStop(0.35, 'rgba(255,255,255,0.85)');
+ grad.addColorStop(1, 'rgba(255,255,255,0)');
+ g.fillStyle = grad;
+ g.fillRect(0, 0, 64, 64);
+ sprite = new THREE.CanvasTexture(canvas);
+ sprite.colorSpace = THREE.SRGBColorSpace;
+ return sprite;
+}
+
+/**
+ * @typedef {object} BurstSystem
+ * @property {THREE.Points} points
+ * @property {Float32Array} pos @property {Float32Array} vel @property {Float32Array} col
+ * @property {Float32Array} base the per-particle start colour (col fades from it)
+ * @property {number} count @property {number} age @property {number} life
+ * @property {any} recipe @property {number} startedAt
+ */
+
+/** @type {BurstSystem[]} */
+const pool = [];
+let lastTick = 0;
+let ticking = false;
+const stats = { fired: 0, recycled: 0 };
+
+/** @param {number} index @returns {BurstSystem} */
+function makeSystem(index) {
+ const geometry = new THREE.BufferGeometry();
+ const pos = new Float32Array(MAX_COUNT * 3);
+ const col = new Float32Array(MAX_COUNT * 3);
+ geometry.setAttribute('position', new THREE.BufferAttribute(pos, 3).setUsage(THREE.DynamicDrawUsage));
+ geometry.setAttribute('color', new THREE.BufferAttribute(col, 3).setUsage(THREE.DynamicDrawUsage));
+ geometry.setDrawRange(0, 0);
+ const material = new THREE.PointsMaterial({
+ size: 0.1,
+ map: spriteTexture(),
+ vertexColors: true,
+ transparent: true,
+ depthWrite: false,
+ sizeAttenuation: true
+ });
+ const points = new THREE.Points(geometry, material);
+ points.name = 'effects-burst-' + index;
+ points.frustumCulled = false; // the particles fly out of any bounds computed at t=0
+ points.visible = false;
+ points.renderOrder = 996;
+ points.userData.localOnly = true;
+ return {
+ points,
+ pos,
+ vel: new Float32Array(MAX_COUNT * 3),
+ col,
+ base: new Float32Array(MAX_COUNT * 3),
+ count: 0,
+ age: 0,
+ life: 0,
+ recipe: null,
+ startedAt: 0
+ };
+}
+
+/**
+ * Compile the burst material NOW, while nothing is showing. three builds a program on the
+ * first draw that needs it, and on a loaded device (a headset mid-game) that stall ate the
+ * first burst of a session whole โ measured: the first burst on a page changed 0 pixels,
+ * every later one ~1500. One material, one program; cheap.
+ * @param {any} scene
+ */
+function warmUp(scene) {
+ const renderer = /** @type {any} */ (get(globalRenderer));
+ const camera = /** @type {any} */ (get(globalCamera));
+ const probe = pool[0]?.points;
+ if (!renderer?.compile || !camera || !probe) return;
+ const was = probe.visible;
+ probe.visible = true; // compile() walks VISIBLE objects only
+ try {
+ renderer.compile(probe, camera, scene);
+ } catch {
+ /* a lost context compiles nothing; the first draw will */
+ }
+ probe.visible = was;
+}
+
+/** the free system, or the oldest running one @returns {BurstSystem | null} */
+function takeSystem() {
+ const scene = /** @type {any} */ (get(globalScene));
+ if (!scene) return null;
+ const fresh = pool.length === 0;
+ while (pool.length < POOL_SIZE) pool.push(makeSystem(pool.length));
+ for (const sys of pool) if (sys.points.parent !== scene) scene.add(sys.points);
+ if (fresh) warmUp(scene);
+ const idle = pool.find((sys) => !sys.points.visible);
+ if (idle) return idle;
+ stats.recycled++;
+ return pool.reduce((a, b) => (a.startedAt <= b.startedAt ? a : b));
+}
+
+/** @param {string | undefined} kind */
+function recipeOf(kind) {
+ return BURST_RECIPES[kind && BURST_RECIPES[kind] ? kind : 'sparkle'];
+}
+
+/**
+ * Fire a burst at a world position. LOCAL. Returns the system's name, or null when there
+ * is no scene or the position is not three finite numbers.
+ * @param {number[]} position @param {{kind?: string, color?: string, count?: number}} [options]
+ * @returns {string | null}
+ */
+export function burst(position, options = {}) {
+ if (!Array.isArray(position) || position.length < 3 || !position.slice(0, 3).every((n) => Number.isFinite(Number(n)))) return null;
+ const sys = takeSystem();
+ if (!sys) return null;
+ const recipe = recipeOf(options?.kind);
+ const wanted = Number(options?.count);
+ const count = Math.max(1, Math.min(MAX_COUNT, Math.round(Number.isFinite(wanted) && wanted > 0 ? wanted : recipe.count)));
+ const dirs = burstDirections(count);
+ const tint = new THREE.Color();
+ const custom = typeof options?.color === 'string' && options.color ? options.color : null;
+ for (let i = 0; i < count; i++) {
+ const d = dirs[i];
+ // a small per-particle speed variation from the index (deterministic)
+ const speed = recipe.speed * (0.55 + 0.45 * (((i * 7919) % 97) / 97));
+ sys.pos[i * 3] = Number(position[0]);
+ sys.pos[i * 3 + 1] = Number(position[1]);
+ sys.pos[i * 3 + 2] = Number(position[2]);
+ sys.vel[i * 3] = d[0] * speed;
+ sys.vel[i * 3 + 1] = d[1] * speed + recipe.up;
+ sys.vel[i * 3 + 2] = d[2] * speed;
+ const colour = custom ?? (recipe.palette ? recipe.palette[i % recipe.palette.length] : recipe.color);
+ tint.set(colour);
+ sys.base[i * 3] = sys.col[i * 3] = tint.r;
+ sys.base[i * 3 + 1] = sys.col[i * 3 + 1] = tint.g;
+ sys.base[i * 3 + 2] = sys.col[i * 3 + 2] = tint.b;
+ }
+ sys.count = count;
+ sys.age = 0;
+ sys.life = recipe.life;
+ sys.recipe = recipe;
+ sys.startedAt = performance.now();
+ const material = /** @type {THREE.PointsMaterial} */ (sys.points.material);
+ material.blending = recipe.additive ? THREE.AdditiveBlending : THREE.NormalBlending;
+ material.size = recipe.size;
+ material.opacity = 1;
+ material.needsUpdate = true;
+ const geometry = sys.points.geometry;
+ geometry.setDrawRange(0, count);
+ geometry.attributes.position.needsUpdate = true;
+ geometry.attributes.color.needsUpdate = true;
+ sys.points.visible = true;
+ stats.fired++;
+ ensureTicking();
+ return sys.points.name;
+}
+
+/**
+ * Advance every live burst by `dt` seconds. Exported so the suites step it
+ * deterministically; the frame task calls it with the real frame time.
+ * @param {number} dt
+ */
+export function stepBursts(dt) {
+ // the AGE runs on the real clock (a burst lives its life whatever the frame rate โ a
+ // throttled tab ticks five times a second); only the INTEGRATION step is capped, so a
+ // long frame cannot fling a particle across the room
+ const elapsed = Math.min(1, Math.max(0, dt));
+ const step = Math.min(0.1, elapsed);
+ for (const sys of pool) {
+ if (!sys.points.visible) continue;
+ sys.age += elapsed;
+ const r = sys.recipe;
+ if (sys.age >= sys.life) {
+ sys.points.visible = false;
+ sys.points.geometry.setDrawRange(0, 0);
+ continue;
+ }
+ const damp = Math.exp(-r.drag * step);
+ const fade = 1 - sys.age / sys.life;
+ for (let i = 0; i < sys.count; i++) {
+ const k = i * 3;
+ sys.vel[k] *= damp;
+ sys.vel[k + 1] = sys.vel[k + 1] * damp + r.gravity * step;
+ sys.vel[k + 2] *= damp;
+ sys.pos[k] += sys.vel[k] * step;
+ sys.pos[k + 1] += sys.vel[k + 1] * step;
+ sys.pos[k + 2] += sys.vel[k + 2] * step;
+ // additive particles fade by DARKENING (black adds nothing); a sparkle twinkles
+ const twinkle = r === BURST_RECIPES.sparkle ? 0.6 + 0.4 * Math.sin(sys.age * 24 + i * 1.7) : 1;
+ const k2 = r.additive ? fade * twinkle : 1;
+ sys.col[k] = sys.base[k] * k2;
+ sys.col[k + 1] = sys.base[k + 1] * k2;
+ sys.col[k + 2] = sys.base[k + 2] * k2;
+ }
+ const material = /** @type {THREE.PointsMaterial} */ (sys.points.material);
+ if (!r.additive) material.opacity = Math.min(1, fade * 1.6);
+ material.size = Math.max(0.005, r.size * (1 + r.grow * (sys.age / sys.life)));
+ sys.points.geometry.attributes.position.needsUpdate = true;
+ sys.points.geometry.attributes.color.needsUpdate = true;
+ }
+}
+
+function frameTask() {
+ const now = performance.now();
+ const dt = lastTick ? (now - lastTick) / 1000 : 0;
+ lastTick = now;
+ stepBursts(dt);
+ if (!pool.some((sys) => sys.points.visible)) {
+ // nothing alive: leave the frame loop until the next burst. AFTER the loop that is
+ // calling us โ a splice inside its forEach would skip the next module's task
+ ticking = false;
+ lastTick = 0;
+ queueMicrotask(() => {
+ if (ticking) return; // a burst fired in the meantime
+ const i = moduleFrameTasks.indexOf(frameTask);
+ if (i >= 0) moduleFrameTasks.splice(i, 1);
+ });
+ }
+}
+
+function ensureTicking() {
+ if (ticking) return;
+ ticking = true;
+ lastTick = 0;
+ if (!moduleFrameTasks.includes(frameTask)) moduleFrameTasks.push(frameTask);
+}
+
+/** live systems for the suites @returns {{fired: number, recycled: number, live: {name: string, count: number, age: number, kind: string}[]}} */
+export function burstDebug() {
+ return {
+ ...stats,
+ live: pool
+ .filter((sys) => sys.points.visible)
+ .map((sys) => ({
+ name: sys.points.name,
+ count: sys.count,
+ age: sys.age,
+ kind: Object.keys(BURST_RECIPES).find((k) => BURST_RECIPES[k] === sys.recipe) ?? ''
+ }))
+ };
+}
+
+/** Drop every live burst (a scene clear) */
+export function clearBursts() {
+ for (const sys of pool) {
+ sys.points.visible = false;
+ sys.points.geometry.setDrawRange(0, 0);
+ }
+}
diff --git a/src/lib/environment.js b/src/lib/environment.js
index 8eaedb53..607d8a6d 100644
--- a/src/lib/environment.js
+++ b/src/lib/environment.js
@@ -88,6 +88,7 @@ export const ENV_ROOT = 'environment-root';
const RIG_HEMI = 'env-rig-hemi';
const RIG_SUN = 'env-rig-sun';
const CATCHER = 'env-shadow-catcher';
+const GROUND = 'env-ground';
const EXTRA_PREFIX = 'env-extra-';
let userLightFactor = 1;
@@ -159,6 +160,73 @@ function shadowCatcher(scene, create) {
return disc;
}
+/**
+ * 30 author-kit: a custom payload may carry two ADDITIVE sky fields, both absent on every
+ * stock preset and every older save, so those render exactly as before:
+ * gradient: {top, bottom} โ a vertical background gradient. `background` stays a plain
+ * colour beside it (the `backgroundColor` store and an older peer, which ignores the
+ * gradient, read that one โ authors set it to the horizon colour).
+ * ground: {color, roughness?} โ a solid ground disc under the scene that RECEIVES the sun's
+ * shadows (so the ShadowMaterial catcher stands down while it shows).
+ * Both are read through these two normalizers only โ a malformed value reads as absent.
+ * @param {any} preset @returns {{top: string, bottom: string} | null} */
+export function skyGradientOf(preset) {
+ const g = preset?.gradient;
+ return g && typeof g.top === 'string' && typeof g.bottom === 'string' ? { top: g.top, bottom: g.bottom } : null;
+}
+
+/** @param {any} preset @returns {{color: string, roughness: number} | null} */
+export function skyGroundOf(preset) {
+ const g = preset?.ground;
+ if (!g || typeof g.color !== 'string') return null;
+ return { color: g.color, roughness: Number.isFinite(g.roughness) ? g.roughness : 1 };
+}
+
+/** @type {{key: string, texture: THREE.CanvasTexture} | null} one cached gradient texture */
+let gradientCache = null;
+
+/** A 2x256 vertical gradient as a screen-filling background texture (three stretches a
+ * plain texture background over the viewport). Cached by its two colours.
+ * @param {{top: string, bottom: string}} gradient */
+function gradientTexture(gradient) {
+ const key = gradient.top + '|' + gradient.bottom;
+ if (gradientCache?.key === key) return gradientCache.texture;
+ if (typeof document === 'undefined') return null;
+ const canvas = document.createElement('canvas');
+ canvas.width = 2;
+ canvas.height = 256;
+ const ctx = canvas.getContext('2d');
+ if (!ctx) return null;
+ const fill = ctx.createLinearGradient(0, 0, 0, 256);
+ fill.addColorStop(0, gradient.top);
+ fill.addColorStop(1, gradient.bottom);
+ ctx.fillStyle = fill;
+ ctx.fillRect(0, 0, 2, 256);
+ const texture = new THREE.CanvasTexture(canvas);
+ texture.colorSpace = THREE.SRGBColorSpace;
+ gradientCache?.texture.dispose();
+ gradientCache = { key, texture };
+ return texture;
+}
+
+/** The solid ground disc (a scene-root mesh in ENV_ROOT, like the catcher, so it never
+ * enters objectsGroup, sync or a save โ the payload's `ground` field is what travels).
+ * @param {any} scene @param {boolean} create */
+function groundDisc(scene, create) {
+ const root = envRoot(scene);
+ let disc = scene.getObjectByName(GROUND);
+ if (!disc && create) {
+ disc = new THREE.Mesh(new THREE.CircleGeometry(1, 64), new THREE.MeshStandardMaterial({ color: '#808080', roughness: 1 }));
+ disc.name = GROUND;
+ disc.rotation.x = -Math.PI / 2;
+ // under the catcher's plane and a hair under y = 0, so a floor authored AT 0 wins
+ disc.position.y = -0.01;
+ disc.receiveShadow = true;
+ root.add(disc);
+ }
+ return disc;
+}
+
/** Create/update/remove `env-extra-*` lights to mirror state.lights @param {any} scene @param {any[]} defs */
function reconcileExtraLights(scene, defs) {
const root = envRoot(scene);
@@ -215,7 +283,9 @@ export function applyEnvironment() {
scene.background = null;
scene.fog = null;
} else {
- scene.background = new THREE.Color(preset.background);
+ // 30 author-kit: a gradient sky when the payload carries one, else the flat colour
+ const gradient = skyGradientOf(preset);
+ scene.background = (gradient && gradientTexture(gradient)) || new THREE.Color(preset.background);
// fog never swallows a big scene: its reach grows with the scene bounds
scene.fog = preset.fog
? new THREE.Fog(
@@ -283,9 +353,24 @@ export function applyEnvironment() {
// object to a real table (the sky/fog lift above is the whole AR stand-down;
// the sun rig keeps casting untouched)
const shadowsOff = shadowsDisabled();
+ // 30 author-kit: an authored solid ground receives the shadows itself, so the catcher
+ // (which only darkens) stands down while the ground shows
+ // (and, like the sky, the ground lifts in passthrough: it would cover the real room)
+ const ground = skyGroundOf(preset);
+ const groundShown = !!ground && !wireframeActive() && !get(passthroughActive);
+ const disc = groundDisc(scene, !!ground);
+ if (disc) {
+ disc.visible = groundShown;
+ if (ground) {
+ disc.material.color.set(ground.color);
+ disc.material.roughness = ground.roughness;
+ }
+ const span = Math.max(200, sceneRadius() * 4);
+ disc.scale.set(span, span, span);
+ }
const catcher = shadowCatcher(scene, !!(preset.sun && !shadowsOff));
if (catcher) {
- catcher.visible = !!(preset.sun && !shadowsOff) && !wireframeActive();
+ catcher.visible = !!(preset.sun && !shadowsOff) && !groundShown && !wireframeActive();
const span = Math.max(60, sceneRadius() * 2);
catcher.scale.set(span, span, span);
}
diff --git a/src/lib/explorerDrop.js b/src/lib/explorerDrop.js
index 8eba91af..b3de320d 100644
--- a/src/lib/explorerDrop.js
+++ b/src/lib/explorerDrop.js
@@ -6,6 +6,7 @@ import { peers, showToast, toastStore, stackOnDrop } from '../stores/appStore';
import { explorerItems, itemBlob } from './explorer';
import { prefabs, instantiatePrefab } from './prefabs';
import { importFile } from './fileHandler.svelte';
+import { packRefFromUrl } from './packRefs';
import { setObjectTexture } from './materialsHandler';
import { topLevelObjectOf } from './objectActions';
import { sceneHits, hitWorldNormal } from './scenePick';
@@ -205,7 +206,11 @@ async function placeExplorerPayload(payload, target) {
dismiss();
return showToast('Could not fetch the pack item');
}
- importFile(new File([await res.blob()], name + '.glb'), name, undefined, target.point ?? undefined);
+ // 30c: the placed piece carries its pack reference, so a save and the wire write it
+ // as a small stub instead of the whole model (packRefs.js)
+ importFile(new File([await res.blob()], name + '.glb'), name, undefined, target.point ?? undefined, undefined, {
+ packRef: packRefFromUrl(payload.url, { item: name })
+ });
dismiss();
} catch {
dismiss();
diff --git a/src/lib/fileHandler.svelte.js b/src/lib/fileHandler.svelte.js
index a79248e6..2a085ab8 100644
--- a/src/lib/fileHandler.svelte.js
+++ b/src/lib/fileHandler.svelte.js
@@ -23,6 +23,8 @@ import { createGltfLoader, registerAnimatedImport, recordAnimatedImport, sendAni
import { environment } from './environment';
import { parkAnimatedAtBase } from '$lib/flowRuntime';
import { stripEditOverlays } from '$lib/editOverlays';
+import { stampPackRef } from '$lib/packRefs';
+import { hashBytes } from '$lib/explorer';
import { saveFileBase } from '$lib/saveName';
import { peers, fixLight, loadingFile, showToast, showInfoToast, dismissToastById } from '../stores/appStore';
import { safeStorage } from './safeStorage';
@@ -666,7 +668,10 @@ function defaultImportName(extension, name) {
* @param {any} file @param {string=} name @param {string=} ext - explicit extension when the blob has no name (Library)
* @param {number[]=} position - world drop point (Explorer drag-out, 96)
* @param {any[]=} extras - companion files picked/dropped alongside (.mtl + its textures)
- * @param {{reduce?: boolean | import('./importBudget').ReductionPlan}} [opts] 26-F: import REDUCED
+ * @param {{reduce?: boolean | import('./importBudget').ReductionPlan, packRef?: import('./packRefs').PackRef | null}} [opts]
+ * 26-F: `reduce` imports REDUCED. 30c: `packRef` names the PACK ITEM this file is โ the
+ * placed root then carries the reference (packRefs.js), so a save and the wire write it
+ * as a small stub. Ignored for an animated or a reduced import (neither IS the file).
* @returns {Promise} the placed root's uuid, or null when nothing was placed
*/
export async function importFile(file, name, ext, position, extras, opts = {}) {
@@ -703,6 +708,9 @@ export async function importFile(file, name, ext, position, extras, opts = {}) {
return null;
}
try {
+ // 30c: stamp the pack reference BEFORE addImported, which is what replicates it
+ if (opts.packRef && !parsed.animated && typeof file?.arrayBuffer === 'function')
+ stampPackRef(parsed.root, opts.packRef, await hashBytes(await file.arrayBuffer()));
if (parsed.animated) addAnimatedImport(parsed.animated.result, parsed.animated.buffer, label, parsed.animated.kind);
else addImported(parsed.root, label, position);
for (const note of parsed.notes) showToast(note);
diff --git a/src/lib/flowRuntime.js b/src/lib/flowRuntime.js
index a6ba1e78..81bdbecf 100644
--- a/src/lib/flowRuntime.js
+++ b/src/lib/flowRuntime.js
@@ -68,6 +68,10 @@ import {
// so a user can hand over what happened (hardening audit H4). A zero-import leaf.
import { log } from './diagnostics';
import { safeStorage } from './safeStorage';
+// 30 P4: the device-local store Store Value / Stored Value read and write (a leaf)
+import { sceneStorageKey, readStored, writeStored } from './gameStorage';
+// 30b (core-games): the game-feel nodes' runtime half (announce/sound/burst/haptic/music)
+import { GAME_FEEL_ACTIONS, runGameFeelAction, updateGameMusicNodes, primeGameFeelActions } from './gameFeelActions';
// H3: inputRuntime is reached via a PRIMED dynamic import (the moduleSDK
// pattern) โ a static edge would close the TDZ cycle history -> flowRuntime ->
@@ -1115,6 +1119,40 @@ function updateLeaderboardNodes(time, ctx) {
/** @type {Map} */
const hudSetActed = new Map();
+/** 30 P4: a Store Value / Stored Value key in THIS scene's namespace
+ * (`tp:scene::`). The name is the scene's own (levels'
+ * `currentLevel`), so a best score belongs to its game. @param {string} key */
+function storedKey(key) {
+ let name = null;
+ try {
+ name = levelsRef ? get(levelsRef.currentLevel)?.name ?? null : null;
+ } catch {}
+ return sceneStorageKey(name, key);
+}
+
+/** 30 P4: the Store Value write. `set` keeps whatever arrived (a number or text); `max`,
+ * `min` and `add` are numeric against what this device already holds, and an unchanged
+ * best writes nothing. @param {any} data */
+function storeValue(data) {
+ const key = String(data.key ?? '').trim();
+ if (!key) return;
+ const full = storedKey(key);
+ const mode = data.mode ?? 'set';
+ if (mode === 'set') {
+ writeStored(full, typeof data.value === 'string' ? data.value : num(data.value ?? 0));
+ return;
+ }
+ const value = num(data.value ?? 0);
+ const held = Number(readStored(full, undefined));
+ const has = Number.isFinite(held);
+ const next =
+ mode === 'max' ? (has ? Math.max(held, value) : value)
+ : mode === 'min' ? (has ? Math.min(held, value) : value)
+ : mode === 'add' ? (has ? held : 0) + value
+ : value;
+ if (!has || next !== held) writeStored(full, next);
+}
+
/** The element behind an id, across every HUD document. An input's OPTIONS and its
* `shared` flag live on the element, and a node names only the id. @param {string} id */
function findHudElement(id) {
@@ -1154,7 +1192,7 @@ function updateGameNodes(time, ctx) {
// 1. the ACTIONS, on a fresh trigger stamp only
for (const node of nodes) {
const type = node.type;
- if (type !== 'setgamestate' && type !== 'setcamera' && type !== 'setvariable' && type !== 'setlook' && type !== 'travel')
+ if (type !== 'setgamestate' && type !== 'setcamera' && type !== 'setvariable' && type !== 'setlook' && type !== 'travel' && type !== 'storevalue' && !GAME_FEEL_ACTIONS.includes(type))
continue;
seeActionNode(node, time);
const stamp = triggerStampFor(node.id, ctx);
@@ -1197,6 +1235,17 @@ function updateGameNodes(time, ctx) {
const hash = typeof data.level === 'string' ? data.level : '';
if (sceneName && levelsRef?.travelToScene) levelsRef.travelToScene(sceneName);
else if (hash && levelsRef) levelsRef.travelToLevel(hash, String(data.levelName ?? ''));
+ } else if (GAME_FEEL_ACTIONS.includes(type)) {
+ // 30b (core-games): a banner, a sound, a burst or a buzz โ LOCAL on every peer from
+ // the replicated stamp, inside the actionSeenAt family above like storevalue (a
+ // fresh node adopting an old stamp must not announce on connect)
+ runGameFeelAction(type, data);
+ } else if (type === 'storevalue') {
+ // 30 P4: a LOCAL write on the stamp edge, inside the actionSeenAt family above (a
+ // fresh node adopting an old stamp must not overwrite a best on connect). It
+ // sends NOTHING: every peer that sees the trigger acts for its own device, which
+ // is the whole semantics of "saved on this device".
+ storeValue(data);
} else if (type === 'setcamera') {
const uuid = typeof data.camera === 'string' ? data.camera : '';
if (uuid) lookThroughCamera(uuid);
@@ -1254,6 +1303,9 @@ function updateGameNodes(time, ctx) {
for (const id of [...gameActed.keys()])
if (!nodes.some((/** @type {any} */ n) => n.id === id)) gameActed.delete(id);
+ // 1a. 30b (core-games): Game Music is a DECLARATION (present = wanted), read per frame
+ updateGameMusicNodes(nodes, (/** @type {any} */ node) => resolveInputs(node, nodes, edges, time, ctx), game.state);
+
// 1b. 21-F4 ALLPLAYERS โ the group-travel gate. Each peer evaluates the wired
// condition for ITSELF (my answer about my player), publishes the verdict through
// the presence channel ON CHANGE, and derives "everyone in play says yes" from the
@@ -1817,6 +1869,7 @@ export const valueTypes = [
'gamepadbutton', // 21-E5: pad trigger โ the keypress model verbatim
'gamepadaxis', // 21-E5: a stick, read LOCALLY (never streamed)
'onimpact', // PFX-C: physics impact trigger
+ 'ongrab', // 30b (core-games): a player picked it up
'onhit', // 24-A A2: the knock's trigger โ a handle map: __default pulse + speed/byMe
'onenter', 'onexit', // CL-C: sensor overlap triggers
'velocity', // CL-C: live speed readout (m/s)
@@ -1828,6 +1881,7 @@ export const valueTypes = [
'hudinput', // 21-D4: the HUD as a SOURCE - what the player set on a slider/toggle/etc
// 21-D6 the game shell
'ongamestate', 'getvariable', 'gametime',
+ 'storedvalue', // 30 P4: what Store Value saved on this device
// 24-A A4: `peervariable` was MISSING here since 21-G4, and the omission was silent in
// every direction that is easy to look at โ it has an OUTPUT type in flowSockets, an
// evaluator case below, and the editor draws its source handle โ but `resolveInputs`
@@ -2189,6 +2243,7 @@ function evalNodeBody(node, allNodes, allEdges, time, seen, ctx) {
}
case 'animfinished': // 17-E: fired locally when a clip reaches its end
case 'animmarker': // 17-E F5: fired locally when the playhead crosses one
+ case 'ongrab': // 30b (core-games): the On Click window, fired on a player's grab
case 'onclick': {
const trig = ctx && ctx.triggers ? ctx.triggers[node.id] : null;
const dt = trig ? time - trig.lastT : Infinity;
@@ -2368,6 +2423,16 @@ function evalNodeBody(node, allNodes, allEdges, time, seen, ctx) {
// LOCAL read of REPLICATED state, so every peer computes the same number and
// nothing about the read goes on the wire
return num(gameVar(String(d.name ?? ''), d.fallback ?? 0));
+ // --- 30 P4: what THIS device saved (never replicated, legitimately per-peer) ---
+ case 'storedvalue': {
+ const key = String(d.key ?? '').trim();
+ const held = key ? readStored(storedKey(key), undefined) : undefined;
+ if (d.output === 'text') {
+ if (held === undefined) return String(d.fallback ?? '');
+ return typeof held === 'string' ? held : JSON.stringify(held);
+ }
+ return held === undefined ? num(d.fallback ?? 0) : num(held);
+ }
// --- 21-G4: the PER-PLAYER half of the same idea ---
case 'peervariable': {
// Also a LOCAL read of REPLICATED state โ every peer holds every peer's row โ
@@ -2699,14 +2764,46 @@ export function fireModuleTrigger(type, match, opts) {
}
export function fireObjectClick(uuid) {
+ let fired = 0; // 30b: how many On Click nodes this reached (additive return)
nodes.forEach((node) => {
if (node.type !== 'onclick') return;
// H1: an unwired OnClick inside the clicked object's own graph also fires
- if (reachesObjectSelector(node.id, uuid) || implicitOwnerOf(node) === uuid)
+ if (reachesObjectSelector(node.id, uuid) || implicitOwnerOf(node) === uuid) {
// 21-G4: a perPlayer On Click keeps its pulse LOCAL โ that one bit is the
// whole per-player collectible (see replicatesPulse)
applyNodeTrigger(node.id, syncedNow(), replicatesPulse(node));
+ fired++;
+ }
+ });
+ return fired;
+}
+
+/**
+ * 30b (core-games): a PLAYER picked `uuid` up โ the desktop play/interact carry
+ * (playInteract.beginGrab) or a VR Interact grip. Pulses every On Grab node wired to the
+ * object (or unwired inside its own graph), exactly as fireObjectClick does for a click; the
+ * stamp replicates (unless the node is perPlayer), so every peer hears the crate lift.
+ * Returns how many nodes it reached. @param {string} uuid @returns {number}
+ */
+export function fireObjectGrab(uuid) {
+ let fired = 0;
+ nodes.forEach((node) => {
+ if (node.type !== 'ongrab') return;
+ if (reachesObjectSelector(node.id, uuid) || implicitOwnerOf(node) === uuid) {
+ applyNodeTrigger(node.id, syncedNow(), replicatesPulse(node));
+ fired++;
+ }
});
+ return fired;
+}
+
+/** 30b (vr-play): would a click on `uuid` reach an On Click node? The same two rules
+ * fireObjectClick fires by โ the VR hover tap and the sweep ask it before touching
+ * anything. @param {string} uuid @returns {boolean} */
+export function objectHasOnClick(uuid) {
+ return nodes.some(
+ (node) => node.type === 'onclick' && (reachesObjectSelector(node.id, uuid) || implicitOwnerOf(node) === uuid)
+ );
}
/**
@@ -3568,6 +3665,7 @@ export function startFlowRuntime() {
// knock.js imports physics, which imports this module.
import('./knock').then((m) => m.registerHitListener((hit, local) => fireObjectHit(hit, local)));
import('./gamePresence').then((m) => (presenceRef = m));
+ primeGameFeelActions(); // 30b: effectsBurst + vrControls, primed (see gameFeelActions)
flowGraphs.subscribe(() => {
nodes = allNodes();
edges = allEdges();
diff --git a/src/lib/flowSockets.js b/src/lib/flowSockets.js
index 96a5eaab..a3b4f2c7 100644
--- a/src/lib/flowSockets.js
+++ b/src/lib/flowSockets.js
@@ -18,6 +18,7 @@ const OUTPUT = {
colorpicker: 'color',
objectselector: 'object',
onclick: 'event',
+ ongrab: 'event', // 30b (core-games)
keypress: 'event', // H3
// 21-E5: a pad button is an EVENT (the keypress channel), a stick is a NUMBER. Both
// therefore reach every existing consumer with no coercion of their own.
@@ -61,6 +62,9 @@ const OUTPUT = {
ongamestate: 'event',
getvariable: 'number',
gametime: 'number',
+ // 30 P4: the readable half of Store Value (a number socket; `output: text` carries a
+ // string down the same channel, the hudtext precedent)
+ storedvalue: 'number',
// 21-F3's `collectcount` MOVED to the collectible module (R3a); a module value
// node's socket type comes from its registerValueNode `vtype`, so no entry here.
// 21-G4: one player's own number (mine / a named peer / the sum / the max)
@@ -168,6 +172,15 @@ const INPUT = {
setcamera: { trigger: 'event', camera: 'object' },
setlook: { trigger: 'event', camera: 'object', on: 'boolean' },
setvariable: { trigger: 'event', value: 'number' },
+ storevalue: { trigger: 'event', value: 'number' }, // 30 P4
+ // 30b (core-games): the Game Feel family โ an event, a wired number for {v}, a PLACE
+ // (an object: an undeclared handle types as 'number' and would refuse an Object
+ // Selector), and Game Music's on/off
+ announce: { trigger: 'event', value: 'number' },
+ gamesound: { trigger: 'event', at: 'object' },
+ effectburst: { trigger: 'event', at: 'object' },
+ hapticpulse: { trigger: 'event' },
+ gamemusic: { on: 'boolean' },
gamestart: { camera: 'object' },
// 21-F4: travel fires on its trigger edge; allplayers takes each player's own
// boolean answer (a Latch, a Gate, a Compare โ anything true/false)
diff --git a/src/lib/gameAnnounce.js b/src/lib/gameAnnounce.js
new file mode 100644
index 00000000..2aea5559
--- /dev/null
+++ b/src/lib/gameAnnounce.js
@@ -0,0 +1,49 @@
+// 30b (vr-play) โ `api.announce(text, {sub, ms, color})`: a BIG centred banner for a
+// game's moments โ "GOAL!", "Level 3", "Ring 2 reached". The user, on Towers: "When I
+// reach some of the rings and go to the top, it should dynamically tell me".
+//
+// A LEAF holding one store; two renderers read it โ HudLayer on the desktop (DOM) and the
+// VR game panel's head-locked banner in a headset (DOM is invisible there). LOCAL: nothing
+// is sent (a module announces on the peer that saw the moment, or broadcasts its own op).
+// A new announcement REPLACES the one showing โ a banner queue would still be showing
+// "Ring 1" when the player reached ring 3.
+import { writable, get } from 'svelte/store';
+
+/** @typedef {{id: number, text: string, sub: string, color: string, ms: number, at: number}} Announcement */
+
+/** the banner on screen, or null @type {import('svelte/store').Writable} */
+export const gameAnnouncement = writable(null);
+
+const DEFAULT_MS = 1800;
+const MAX_MS = 15000;
+let nextId = 1;
+/** @type {any} */
+let timer = null;
+
+/**
+ * Show a banner. Returns its id (0 for empty text). `ms` is clamped to 300..15000.
+ * @param {string} text @param {{sub?: string, ms?: number, color?: string}} [options]
+ * @returns {number}
+ */
+export function announce(text, options = {}) {
+ const line = String(text ?? '').trim().slice(0, 80);
+ if (!line) return 0;
+ const raw = Number(options?.ms);
+ const ms = Number.isFinite(raw) ? Math.min(MAX_MS, Math.max(300, raw)) : DEFAULT_MS;
+ const color = typeof options?.color === 'string' && /^#?[0-9a-z(),.% -]{3,40}$/i.test(options.color) ? options.color : '#ffd76a';
+ const id = nextId++;
+ gameAnnouncement.set({ id, text: line, sub: String(options?.sub ?? '').slice(0, 120), color, ms, at: Date.now() });
+ if (timer) clearTimeout(timer);
+ timer = setTimeout(() => {
+ timer = null;
+ if (get(gameAnnouncement)?.id === id) gameAnnouncement.set(null);
+ }, ms);
+ return id;
+}
+
+/** Take the banner down now (a scene clear, leaving the game). */
+export function clearAnnouncement() {
+ if (timer) clearTimeout(timer);
+ timer = null;
+ gameAnnouncement.set(null);
+}
diff --git a/src/lib/gameFeel.js b/src/lib/gameFeel.js
new file mode 100644
index 00000000..400353ae
--- /dev/null
+++ b/src/lib/gameFeel.js
@@ -0,0 +1,38 @@
+// 30b (vr-play): "IS THE PLAYER PLAYING?" โ the ONE predicate behind every piece of game
+// feel: controller haptics, game music, the VR game panel and the hold-and-sweep.
+//
+// The user's rule, verbatim from the Quest report: "The vibration should be only
+// interactive mode, not in edit mode." A game feels like a game in INTERACT and in PLAY,
+// and the editor stays quiet. Both halves are LOCAL state on this device (`editorMode` is
+// never sent, `isLocked` is this peer's play press), so nothing here replicates.
+//
+// `isLocked` is THREE-state (null editor / true playing / false the exit transient), so
+// playing is `=== true` โ the transient must read "not playing" (the 21-E3 rule).
+// VR has no pointer lock: a headset session enters INTERACT (30b-vr-modes' C1), which is
+// why `editorMode === 'interact'` alone is also enough.
+//
+// A LEAF: sceneStore + svelte/store only, so vrControls, moduleSDK, the audio leaves and
+// the HUD layer can all read it without reaching the history cycle family.
+import { derived, get } from 'svelte/store';
+import { isLocked, editorMode } from '../stores/sceneStore';
+
+/** true while this device is in Interact or Play @returns {boolean} */
+export function gameFeelActive() {
+ return get(isLocked) === true || get(editorMode) === 'interact';
+}
+
+/** the same answer as a store โ a falling edge is "the player left the game" */
+export const gameFeelOn = derived(
+ [isLocked, editorMode],
+ ([$locked, $mode]) => $locked === true || $mode === 'interact'
+);
+
+/**
+ * The click MODE a game-side press dispatches with (moduleSDK's CLICK_MODES): 'play'
+ * under desktop Play, 'interact' in Interact (VR Play enters Interact), 'edit' otherwise.
+ * @returns {'edit' | 'interact' | 'play'}
+ */
+export function gameClickMode() {
+ if (get(isLocked) === true) return 'play';
+ return get(editorMode) === 'interact' ? 'interact' : 'edit';
+}
diff --git a/src/lib/gameFeelActions.js b/src/lib/gameFeelActions.js
new file mode 100644
index 00000000..fe3dcbc3
--- /dev/null
+++ b/src/lib/gameFeelActions.js
@@ -0,0 +1,223 @@
+// 30b (core-games): GAME FEEL AS FLOW NODES. 30b-vr-play gave MODULES the game-feel kit
+// (api.playSound's procedural set, api.music, api.hapticPattern, api.effects.burst,
+// api.announce), but the three core games โ Towers, Stars Room, Jam Room โ are authored as
+// FLOW GRAPHS, and a graph had no way to reach any of it: a ring reached could not say so,
+// sparkle, chime or buzz. These are the five nodes that close that gap, and this file is
+// their runtime half (flowRuntime owns the stamp edge; this owns what happens on it).
+//
+// THE SYNC MODEL, one rule for all five: every one is LOCAL, acted on by EVERY peer from
+// the REPLICATED trigger stamp โ the setcamera / storevalue / hudscreen house rule. The
+// trigger already travelled, so a banner, a sound, a burst and a buzz happen on each
+// device from the same pulse with NO message of their own. A per-player trigger (a
+// perPlayer On Click) keeps its pulse on the machine that pressed it, so only that player
+// gets the feedback โ which composes rather than special-cases.
+//
+// Haptics and music keep the core's own gate (gameFeel: Interact or Play, silent in Edit),
+// because both funnel through the functions that own it (vrControls.hapticPattern,
+// gameMusic.playGameMusic). A sound, a banner and a burst are not gated: a spectator in
+// the editor watching a peer's round sees the ring light and hears it, like every other
+// consequence of a replicated pulse.
+//
+// Deliberately NOT a leaf in the strict sense: it reaches effectsBurst (which imports
+// moduleSDK) and vrControls through PRIMED dynamic imports, the flowRuntime rule โ a static
+// edge from a module flowRuntime imports into either closes a cycle into history.
+import * as THREE from 'three';
+import { get } from 'svelte/store';
+import { globalCamera, objectsGroup } from '../stores/sceneStore';
+import { playGameSound, isGameSound, GAME_SOUNDS } from './gameSfx';
+import { announce } from './gameAnnounce';
+import { playGameMusic, stopGameMusic, gameMusicState, MUSIC_PRESET_IDS } from './gameMusic';
+import { gameFeelActive } from './gameFeel';
+import { HAPTIC_PATTERN_NAMES } from './hapticPatterns';
+
+export { GAME_SOUNDS, MUSIC_PRESET_IDS, HAPTIC_PATTERN_NAMES };
+export const BURST_KIND_NAMES = ['sparkle', 'confetti', 'smoke', 'sparks'];
+
+/** the flow node types this file acts for on a trigger's stamp edge */
+export const GAME_FEEL_ACTIONS = ['announce', 'gamesound', 'effectburst', 'hapticpulse'];
+
+/** @type {any} */ let burstRef = null;
+/** @type {any} */ let vrRef = null;
+
+/** flowRuntime's init: resolve the two primed modules (see the header). */
+export function primeGameFeelActions() {
+ import('./effectsBurst').then((m) => (burstRef = m));
+ import('./vrControls').then((m) => (vrRef = m));
+}
+
+/** what each node type did โ the suites' view (no headset, no ears) */
+const debug = {
+ /** @type {Record} */ fired: {},
+ /** per sound name, so a suite can count coins among the clicks @type {Record} */ sounds: {},
+ /** @type {any[]} */ last: [],
+ musicOwner: /** @type {string | null} */ (null),
+ musicStarts: 0,
+ musicStops: 0
+};
+
+/**
+ * Put a wired number into a text: every `{v}` becomes the value, `decimals` places (a
+ * non-number passes through as text). PURE โ the hudtext format rule, so an author who
+ * knows one knows the other.
+ * @param {string} text @param {any} value @param {number} [decimals]
+ * @returns {string}
+ */
+export function fillValue(text, value, decimals = 0) {
+ const s = String(text ?? '');
+ if (!s.includes('{v}')) return s;
+ const n = Number(value);
+ const d = Math.max(0, Math.min(6, Math.round(Number(decimals) || 0)));
+ const shown = value === undefined || value === null || value === '' ? '' : Number.isFinite(n) ? n.toFixed(d) : String(value);
+ return s.split('{v}').join(shown);
+}
+
+/** a named object's world position, or null @param {any} uuid @returns {number[] | null} */
+function worldPositionOf(uuid) {
+ if (typeof uuid !== 'string' || !uuid) return null;
+ const object = get(objectsGroup)?.getObjectByProperty?.('uuid', uuid);
+ if (!object) return null;
+ object.updateWorldMatrix(true, false);
+ const p = object.getWorldPosition(new THREE.Vector3());
+ return [p.x, p.y, p.z];
+}
+
+/** a point `metres` in front of the viewer's eyes at eye height โ where a banner-sized
+ * burst belongs when nothing names a place (the confetti at a round's end) */
+function inFrontOfPlayer(metres = 1.6) {
+ /** @type {any} */
+ const camera = get(globalCamera);
+ if (!camera?.getWorldPosition) return null;
+ camera.updateMatrixWorld?.(true);
+ const eye = camera.getWorldPosition(new THREE.Vector3());
+ const dir = camera.getWorldDirection(new THREE.Vector3());
+ dir.y = Math.max(-0.2, Math.min(0.2, dir.y));
+ dir.normalize();
+ const p = eye.addScaledVector(dir, metres);
+ return [p.x, p.y, p.z];
+}
+
+/** @param {string} type @param {any} what */
+function note(type, what) {
+ debug.fired[type] = (debug.fired[type] ?? 0) + 1;
+ debug.last.push({ type, ...what, at: Date.now() });
+ if (debug.last.length > 120) debug.last.shift();
+}
+
+/**
+ * ONE stamp edge of a game-feel action node, already past flowRuntime's freshness and
+ * staleness checks. `data` is the node's resolved inputs.
+ * @param {string} type @param {any} data
+ * @returns {boolean} whether it did something
+ */
+export function runGameFeelAction(type, data) {
+ if (type === 'announce') {
+ const text = fillValue(data.text ?? '', data.value, data.decimals);
+ const sub = fillValue(data.sub ?? '', data.value, data.decimals);
+ const id = announce(text, { sub, ms: Number(data.seconds ?? 1.8) * 1000, color: data.color || undefined });
+ if (id) note(type, { text, sub });
+ return id > 0;
+ }
+ if (type === 'gamesound') {
+ const name = String(data.sound ?? 'click');
+ if (!isGameSound(name)) return false;
+ const at = worldPositionOf(data.at);
+ const ok = playGameSound(name, at);
+ debug.sounds[name] = (debug.sounds[name] ?? 0) + 1;
+ note(type, { sound: name, spatial: !!at, played: ok });
+ return ok;
+ }
+ if (type === 'effectburst') {
+ const at = worldPositionOf(data.at);
+ const lift = Number(data.lift ?? 0) || 0;
+ const where = at ? [at[0], at[1] + lift, at[2]] : inFrontOfPlayer();
+ if (!where || !burstRef) return false;
+ const kind = BURST_KIND_NAMES.includes(data.kind) ? data.kind : 'sparkle';
+ const count = Number(data.count);
+ const name = burstRef.burst(where, {
+ kind,
+ ...(typeof data.color === 'string' && data.color ? { color: data.color } : {}),
+ ...(Number.isFinite(count) && count > 0 ? { count } : {})
+ });
+ note(type, { kind, where, fired: !!name });
+ return !!name;
+ }
+ if (type === 'hapticpulse') {
+ const pattern = String(data.pattern ?? 'tap');
+ const hand = data.hand === 'left' || data.hand === 'right' ? data.hand : undefined;
+ // gated inside (silent in Edit, the user's rule) โ the count still says we asked
+ const ok = vrRef?.hapticPattern?.(pattern, hand) ?? false;
+ note(type, { pattern, hand: hand ?? 'both', felt: !!ok });
+ return !!ok;
+ }
+ return false;
+}
+
+/**
+ * Which Game Music node wants to play right now โ PURE (the stores stay in the caller), so
+ * the rule is unit-tested: the FIRST node in graph order whose `on` is not false (unwired
+ * = on) and whose `while` allows the shell's state ('round' = playing or paused), with a
+ * known preset. @param {any[]} nodes @param {(node: any) => any} resolve
+ * @param {string} gameStateName @returns {{id: string, preset: string, volume: number} | null}
+ */
+export function musicWanted(nodes, resolve, gameStateName) {
+ for (const node of nodes) {
+ if (node?.type !== 'gamemusic') continue;
+ const data = resolve(node) ?? {};
+ if (data.on === false || data.on === 0) continue;
+ const during = data.while ?? 'always';
+ if (during === 'round' && gameStateName !== 'playing' && gameStateName !== 'paused') continue;
+ const preset = String(data.preset ?? '');
+ if (!MUSIC_PRESET_IDS.includes(preset)) continue;
+ const v = Number(data.volume);
+ return { id: node.id, preset, volume: Number.isFinite(v) ? Math.min(1, Math.max(0, v)) : 0.7 };
+ }
+ return null;
+}
+
+/**
+ * THE MUSIC NODE is a DECLARATION, not an action (the Character Controller's shape): a
+ * Game Music node that WANTS to play โ its `on` input true or unwired, and during a round
+ * when `while` says so โ plays its preset while this device is in Interact or Play, and
+ * stops when nothing wants it. Level-triggered, so leaving and re-entering Interact brings
+ * the music back with no pulse, and a late joiner hears it the moment it arrives.
+ * The FIRST wanting node in graph order wins; music a MODULE started is never stopped
+ * here (we stop only what we started).
+ * @param {any[]} nodes every flow node @param {(node: any) => any} resolve resolved inputs
+ * @param {string} gameStateName the shell's state ('menu' | 'playing' | ...)
+ */
+export function updateGameMusicNodes(nodes, resolve, gameStateName) {
+ const want = musicWanted(nodes, resolve, gameStateName);
+ const playing = get(gameMusicState);
+ if (want && gameFeelActive()) {
+ if (!playing || playing.preset !== want.preset || Math.abs((playing.volume ?? 1) - want.volume) > 1e-3) {
+ // a preset someone else started is left alone unless we are the owner already
+ if (playing && debug.musicOwner === null) return;
+ if (playGameMusic(want.preset, { volume: want.volume })) {
+ if (!playing || playing.preset !== want.preset) debug.musicStarts++;
+ debug.musicOwner = want.id;
+ }
+ }
+ return;
+ }
+ if (debug.musicOwner !== null) {
+ if (playing) {
+ stopGameMusic();
+ debug.musicStops++;
+ }
+ debug.musicOwner = null;
+ }
+}
+
+/** the suites' view @returns {any} */
+export function gameFeelActionsDebug() {
+ return { fired: { ...debug.fired }, sounds: { ...debug.sounds }, last: debug.last.map((e) => ({ ...e })), musicOwner: debug.musicOwner, musicStarts: debug.musicStarts, musicStops: debug.musicStops };
+}
+
+/** forget the counters (a suite section's clean slate) */
+export function resetGameFeelActionsDebug() {
+ debug.fired = {};
+ debug.sounds = {};
+ debug.last = [];
+ debug.musicStarts = 0;
+ debug.musicStops = 0;
+}
diff --git a/src/lib/gameKit.js b/src/lib/gameKit.js
new file mode 100644
index 00000000..8306fbbb
--- /dev/null
+++ b/src/lib/gameKit.js
@@ -0,0 +1,13 @@
+// 30b (vr-play): the game-feel leaves under ONE debug-hook name (`__stores.gameKit`), so
+// this lane adds one entry to App.svelte's three tails instead of one per leaf. Nothing in
+// the app imports this file; it exists for the suites.
+export * as gameFeel from './gameFeel';
+export * as gameSfx from './gameSfx';
+export * as gameMusic from './gameMusic';
+export * as gameMusicPresets from './gameMusicPresets';
+export * as hapticPatterns from './hapticPatterns';
+export * as vrGameInput from './vrGameInput';
+export * as effectsBurst from './effectsBurst';
+export * as gameAnnounce from './gameAnnounce';
+export * as vrGamePanel from './vrGamePanel';
+export * as gameFeelActions from './gameFeelActions'; // 30b (core-games): the Game Feel flow nodes
diff --git a/src/lib/gameMusic.js b/src/lib/gameMusic.js
new file mode 100644
index 00000000..40fdbde4
--- /dev/null
+++ b/src/lib/gameMusic.js
@@ -0,0 +1,236 @@
+// 30b (vr-play) C5 โ GAME MUSIC: `api.music.play(preset, {volume})` / `api.music.stop()`.
+// Seven procedural looping presets (gameMusicPresets.js is the tune as data), scheduled on
+// the app's one AudioContext and LOCAL to this device โ a peer's music is theirs.
+//
+// THREE RULES, each one the user's:
+// ยท it plays only while the player is PLAYING (Interact or Play โ gameFeel.js). A call
+// from the editor is refused, and leaving the game stops it: "music stops on leaving
+// Play/Interact". Refusing in Edit is stricter than "stop on leave" and is on purpose โ
+// a module that started its music from register() would otherwise score the editor.
+// ยท it sits UNDER the effects: one "Music" volume in Settings (persisted via safeStorage)
+// times a quiet MIX constant, into the engine's `music` bus.
+// ยท it is TEMPO-SYNCED to the SESSION clock: the step that plays is floor(sessionNow /
+// step length), never "steps since the button" โ two peers who start one preset hear
+// the same bar at the same moment, and a restart lands back on the beat.
+//
+// The scheduler is the classic lookahead: a 25 ms timer schedules every step whose time
+// falls inside the next LOOKAHEAD seconds on the audio clock (audioTimeFor maps a session
+// stamp through the engine's filtered clock). A TIMER, not a rAF: an immersive session
+// stops window rAF on the Quest, and music must not.
+import { writable, get } from 'svelte/store';
+import { ensureAudioContext, bus, audioTimeFor } from './audioEngine';
+import { sessionNow } from './sessionClock';
+import { safeStorage } from './safeStorage';
+import { gameFeelOn, gameFeelActive } from './gameFeel';
+import { sfxTone, sfxNoise } from './gameSfx';
+import { musicPreset, stepSeconds, stepEvents, MUSIC_PRESET_IDS } from './gameMusicPresets';
+
+export { MUSIC_PRESET_IDS };
+
+const VOLUME_KEY = 'game:musicVolume';
+/** music under effects: the slider's full scale is this loud */
+const MIX = 0.55;
+const LOOKAHEAD = 0.18;
+const TICK_MS = 25;
+
+/** @param {string | null} raw @param {number} fallback */
+function readVolume(raw, fallback) {
+ const n = raw === null ? NaN : Number(raw);
+ return Number.isFinite(n) ? Math.min(1, Math.max(0, n)) : fallback;
+}
+
+/** "Music" volume, 0..1, LOCAL per device (Settings โธ Interface โธ Sound). */
+export const gameMusicVolume = writable(readVolume(safeStorage.getItem(VOLUME_KEY), 0.6));
+
+/** what is playing now: `{preset, volume}` or null @type {import('svelte/store').Writable<{preset: string, volume: number} | null>} */
+export const gameMusicState = writable(null);
+
+/**
+ * The running session. `gain` is PER PLAY: stopping fades and disconnects it, so any note
+ * already scheduled into it goes silent with it โ no voice bookkeeping needed.
+ * @type {{preset: any, volume: number, gain: GainNode, timer: any, lastStep: number, scheduled: number} | null}
+ */
+let current = null;
+const debug = { steps: 0, notes: 0, refused: 0, stops: 0 };
+
+/** @param {number} volume the per-call volume */
+function levelFor(volume) {
+ return MIX * get(gameMusicVolume) * volume;
+}
+
+// Declared ABOVE the subscribes (they run synchronously at module evaluation).
+gameMusicVolume.subscribe((v) => {
+ safeStorage.setItem(VOLUME_KEY, String(v));
+ if (current) current.gain.gain.value = levelFor(current.volume);
+});
+// "music stops on leaving Play/Interact" โ the falling edge of the one predicate
+gameFeelOn.subscribe((on) => {
+ if (!on && current) stopGameMusic();
+});
+
+/** @param {number} midi */
+const hz = (midi) => 440 * Math.pow(2, (midi - 69) / 12);
+
+/**
+ * Build one step's notes into `dest` at audio time `t`. Shared by the live scheduler and
+ * the offline render, so what the suite measures is what a player hears.
+ * @param {BaseAudioContext} ctx @param {AudioNode} dest @param {any} preset
+ * @param {number} step @param {number} t @returns {number} notes built
+ */
+export function buildMusicStep(ctx, dest, preset, step, t) {
+ const beat = 60 / preset.bpm;
+ let n = 0;
+ for (const event of stepEvents(preset, step)) {
+ const g = preset.drumGain;
+ switch (event.kind) {
+ case 'kick':
+ sfxTone(ctx, dest, { freq: 140, to: 44, t0: t, dur: 0.24, peak: 0.55 * g, attack: 0.002 });
+ break;
+ case 'snare':
+ sfxNoise(ctx, dest, { t0: t, dur: 0.14, peak: 0.22 * g, filter: 'bandpass', freq: 1800, q: 0.7, attack: 0.002 });
+ sfxTone(ctx, dest, { freq: 190, to: 140, t0: t, dur: 0.08, peak: 0.12 * g, attack: 0.002 });
+ break;
+ case 'hat':
+ sfxNoise(ctx, dest, { t0: t, dur: 0.04, peak: 0.08 * g, filter: 'highpass', freq: 7000, attack: 0.001 });
+ break;
+ case 'bass': {
+ const dur = Math.max(0.12, (event.beats ?? 0.5) * beat * 0.95);
+ const v = preset.bassVoice;
+ const lp = ctx.createBiquadFilter();
+ lp.type = 'lowpass';
+ lp.frequency.value = v.cutoff;
+ lp.connect(dest);
+ sfxTone(ctx, lp, { freq: hz(/** @type {number} */ (event.midi)), type: v.type, t0: t, dur, peak: v.gain, attack: 0.01 });
+ break;
+ }
+ case 'pad': {
+ const dur = (event.beats ?? 4) * beat;
+ for (const m of /** @type {number[]} */ (event.midi))
+ sfxTone(ctx, dest, { freq: hz(m), type: preset.pad.type, t0: t, dur, peak: preset.pad.gain, attack: Math.min(0.6, dur * 0.3) });
+ break;
+ }
+ case 'arp':
+ sfxTone(ctx, dest, { freq: hz(/** @type {number} */ (event.midi)), type: preset.arp.type, t0: t, dur: Math.max(0.1, beat * 0.45), peak: preset.arp.gain, attack: 0.004 });
+ break;
+ }
+ n++;
+ }
+ return n;
+}
+
+function tick() {
+ if (!current) return;
+ const ctx = ensureAudioContext();
+ const stepMs = stepSeconds(current.preset) * 1000;
+ const now = sessionNow();
+ const horizon = now + LOOKAHEAD * 1000;
+ // the first tick starts at the NEXT step boundary โ a step already begun would land
+ // part-way through its own notes
+ let step = current.lastStep < 0 ? Math.floor(now / stepMs) + 1 : current.lastStep + 1;
+ // a stalled timer (a background tab) never replays the backlog: skip to now
+ if (step * stepMs < now - 200) step = Math.floor(now / stepMs) + 1;
+ for (; step * stepMs <= horizon; step++) {
+ const at = audioTimeFor(step * stepMs);
+ if (at < ctx.currentTime) continue;
+ debug.notes += buildMusicStep(ctx, current.gain, current.preset, step, at);
+ debug.steps++;
+ current.lastStep = step;
+ }
+}
+
+/**
+ * Start (or switch to) a preset. Refused โ false โ outside Interact/Play, and for an
+ * unknown preset name. The same preset already playing only takes the new volume.
+ * @param {string} presetId @param {{volume?: number}} [options]
+ * @returns {boolean}
+ */
+export function playGameMusic(presetId, options = {}) {
+ const preset = musicPreset(presetId);
+ if (!preset || !gameFeelActive()) {
+ debug.refused++;
+ return false;
+ }
+ const raw = Number(options?.volume);
+ const volume = Number.isFinite(raw) ? Math.min(1, Math.max(0, raw)) : 1;
+ if (current && current.preset.id === preset.id) {
+ current.volume = volume;
+ current.gain.gain.value = levelFor(volume);
+ gameMusicState.set({ preset: preset.id, volume });
+ return true;
+ }
+ if (current) stopGameMusic();
+ /** @type {AudioContext} */
+ let ctx;
+ try {
+ ctx = ensureAudioContext();
+ } catch {
+ return false;
+ }
+ if (ctx.state === 'suspended') ctx.resume().catch(() => {});
+ const gain = ctx.createGain();
+ gain.gain.value = levelFor(volume);
+ gain.connect(bus('music'));
+ current = { preset, volume, gain, timer: setInterval(tick, TICK_MS), lastStep: -1, scheduled: 0 };
+ tick();
+ gameMusicState.set({ preset: preset.id, volume });
+ return true;
+}
+
+/** Stop whatever is playing (a short fade โ a hard cut clicks). Safe to call anytime. */
+export function stopGameMusic() {
+ if (!current) return;
+ const { gain, timer } = current;
+ current = null;
+ clearInterval(timer);
+ debug.stops++;
+ try {
+ const ctx = ensureAudioContext();
+ gain.gain.cancelScheduledValues(ctx.currentTime);
+ gain.gain.setValueAtTime(gain.gain.value, ctx.currentTime);
+ gain.gain.linearRampToValueAtTime(0, ctx.currentTime + 0.3);
+ } catch {
+ /* a closed context has nothing left to fade */
+ }
+ setTimeout(() => {
+ try {
+ gain.disconnect();
+ } catch {}
+ }, 450);
+ gameMusicState.set(null);
+}
+
+/**
+ * Render `bars` bars of a preset OFFLINE and measure them (the suite's proof that every
+ * preset plays a real loop).
+ * @param {string} presetId @param {number} [bars] @param {number} [sampleRate]
+ * @returns {Promise<{preset: string, seconds: number, rms: number, peak: number, notes: number} | null>}
+ */
+export async function renderGameMusic(presetId, bars = 1, sampleRate = 22050) {
+ const preset = musicPreset(presetId);
+ const Offline = typeof window !== 'undefined' ? /** @type {any} */ (window).OfflineAudioContext : null;
+ if (!preset || !Offline) return null;
+ const step = stepSeconds(preset);
+ const seconds = bars * 16 * step + 1;
+ const ctx = new Offline(1, Math.ceil(seconds * sampleRate), sampleRate);
+ let notes = 0;
+ for (let i = 0; i < bars * 16; i++) notes += buildMusicStep(ctx, ctx.destination, preset, i, i * step);
+ const buffer = await ctx.startRendering();
+ const data = buffer.getChannelData(0);
+ let sum = 0;
+ let peak = 0;
+ for (let i = 0; i < data.length; i++) {
+ sum += data[i] * data[i];
+ peak = Math.max(peak, Math.abs(data[i]));
+ }
+ return { preset: preset.id, seconds, rms: Math.sqrt(sum / data.length), peak, notes };
+}
+
+/** the live gain's value, or null when nothing plays */
+export function gameMusicGainValue() {
+ return current ? current.gain.gain.value : null;
+}
+
+/** counts for the suites */
+export function gameMusicDebug() {
+ return { ...debug, playing: current?.preset.id ?? null, lastStep: current?.lastStep ?? -1 };
+}
diff --git a/src/lib/gameMusicPresets.js b/src/lib/gameMusicPresets.js
new file mode 100644
index 00000000..35082b13
--- /dev/null
+++ b/src/lib/gameMusicPresets.js
@@ -0,0 +1,202 @@
+// 30b (vr-play) C5 โ the game MUSIC presets as DATA, plus the pure arithmetic that turns
+// a preset and a step number into the notes that step plays. Imports NOTHING: the
+// scheduler (gameMusic.js) is the only thing that touches WebAudio, so everything a
+// listener would call "the tune" is testable with no browser.
+//
+// A preset is a four-bar loop in sixteenth notes: a chord PROGRESSION (scale degrees, one
+// per bar), drum lanes as 16-char strings ('x' hit, '.' rest), a bass lane ('r' root,
+// 'o' octave, 'f' fifth, 't' third), an optional pad (the bar's triad, held) and an
+// optional ARP lane of chord-tone indices ('0'-'5' = triad tones up two octaves).
+//
+// Everything is keyed to a GLOBAL step count (the session clock divided by the step
+// length), never to "when play was pressed" โ so two peers who start the same preset hear
+// the same bar at the same moment, and "tempo-synced" means synced to the session.
+
+/** @typedef {{kind: 'kick'|'snare'|'hat'|'bass'|'pad'|'arp', midi?: number[] | number, beats?: number}} MusicEvent */
+
+/**
+ * @typedef {object} MusicPreset
+ * @property {string} id @property {string} name
+ * @property {number} bpm
+ * @property {number} root midi note of the key's tonic (octave 4-ish)
+ * @property {number[]} scale semitone offsets of the mode
+ * @property {number[]} progression scale degrees (0-based), one chord per bar
+ * @property {{kick?: string, snare?: string, hat?: string}} drums 16 chars each
+ * @property {string} bass 16 chars: r o f t .
+ * @property {{type: OscillatorType, gain: number} | null} pad
+ * @property {{lane: string, type: OscillatorType, gain: number, octave: number} | null} arp
+ * @property {{type: OscillatorType, gain: number, cutoff: number}} bassVoice
+ * @property {number} drumGain
+ */
+
+const MAJOR = [0, 2, 4, 5, 7, 9, 11];
+const MINOR = [0, 2, 3, 5, 7, 8, 10];
+const DORIAN = [0, 2, 3, 5, 7, 9, 10];
+const PHRYGIAN = [0, 1, 3, 5, 7, 8, 10];
+const LYDIAN = [0, 2, 4, 6, 7, 9, 11];
+
+/** @type {MusicPreset[]} */
+export const MUSIC_PRESETS = [
+ {
+ id: 'arcade',
+ name: 'Arcade',
+ bpm: 138,
+ root: 60,
+ scale: MAJOR,
+ progression: [0, 5, 3, 4],
+ drums: { kick: 'x...x...x...x...', snare: '....x.......x...', hat: '..x...x...x...x.' },
+ bass: 'r.r.o.r.r.r.o.f.',
+ pad: null,
+ arp: { lane: '0.1.2.3.2.1.0.1.', type: 'square', gain: 0.045, octave: 1 },
+ bassVoice: { type: 'square', gain: 0.07, cutoff: 900 },
+ drumGain: 0.8
+ },
+ {
+ id: 'ambient',
+ name: 'Ambient',
+ bpm: 72,
+ root: 62,
+ scale: LYDIAN,
+ progression: [0, 3, 5, 4],
+ drums: {},
+ bass: 'r...............',
+ pad: { type: 'triangle', gain: 0.05 },
+ arp: { lane: '0.......2.......', type: 'sine', gain: 0.04, octave: 2 },
+ bassVoice: { type: 'sine', gain: 0.09, cutoff: 500 },
+ drumGain: 0
+ },
+ {
+ id: 'dungeon',
+ name: 'Dungeon',
+ bpm: 84,
+ root: 57,
+ scale: PHRYGIAN,
+ progression: [0, 0, 1, 6],
+ drums: { kick: 'x.......x.x.....', snare: '............x...', hat: '' },
+ bass: 'r.......r...f...',
+ pad: { type: 'sawtooth', gain: 0.022 },
+ arp: { lane: '....2.......1...', type: 'triangle', gain: 0.035, octave: 1 },
+ bassVoice: { type: 'sawtooth', gain: 0.07, cutoff: 350 },
+ drumGain: 0.7
+ },
+ {
+ id: 'stadium',
+ name: 'Stadium',
+ bpm: 120,
+ root: 55,
+ scale: MAJOR,
+ progression: [0, 4, 5, 3],
+ drums: { kick: 'x...x...x...x...', snare: '....x.......x.x.', hat: 'x.x.x.x.x.x.x.x.' },
+ bass: 'r.r.r.r.f.f.o.o.',
+ pad: { type: 'sawtooth', gain: 0.025 },
+ arp: null,
+ bassVoice: { type: 'sawtooth', gain: 0.07, cutoff: 700 },
+ drumGain: 0.9
+ },
+ {
+ id: 'space',
+ name: 'Space',
+ bpm: 100,
+ root: 64,
+ scale: DORIAN,
+ progression: [0, 6, 5, 4],
+ drums: { kick: 'x.........x.....', snare: '....x.......x...', hat: '..x...x...x...xx' },
+ bass: 'r.....r.....f...',
+ pad: { type: 'triangle', gain: 0.035 },
+ arp: { lane: '0.2.4.2.1.3.5.3.', type: 'triangle', gain: 0.04, octave: 1 },
+ bassVoice: { type: 'triangle', gain: 0.09, cutoff: 600 },
+ drumGain: 0.6
+ },
+ {
+ id: 'puzzle',
+ name: 'Puzzle',
+ bpm: 96,
+ root: 65,
+ scale: MAJOR,
+ progression: [0, 3, 1, 4],
+ drums: { kick: 'x.......x.......', snare: '', hat: '....x.......x...' },
+ bass: 'r...f...r...t...',
+ pad: null,
+ arp: { lane: '0..2..1..3..2...', type: 'triangle', gain: 0.05, octave: 1 },
+ bassVoice: { type: 'triangle', gain: 0.08, cutoff: 800 },
+ drumGain: 0.5
+ },
+ {
+ id: 'studio',
+ name: 'Studio',
+ bpm: 88,
+ root: 55,
+ scale: MINOR,
+ progression: [0, 3, 6, 4],
+ drums: { kick: 'x......x..x.....', snare: '....x.......x...', hat: 'x.xxx.xxx.xxx.xx' },
+ bass: 'r.....r.f.....o.',
+ pad: { type: 'sine', gain: 0.045 },
+ arp: null,
+ bassVoice: { type: 'sine', gain: 0.1, cutoff: 500 },
+ drumGain: 0.6
+ }
+];
+
+/** @param {string} id @returns {MusicPreset | null} */
+export function musicPreset(id) {
+ return MUSIC_PRESETS.find((p) => p.id === String(id)) ?? null;
+}
+
+/** the names a module can ask for */
+export const MUSIC_PRESET_IDS = MUSIC_PRESETS.map((p) => p.id);
+
+/** seconds per sixteenth note @param {MusicPreset} preset */
+export function stepSeconds(preset) {
+ return 60 / preset.bpm / 4;
+}
+
+/**
+ * The triad (three midi notes) on scale degree `degree`, stacked in thirds inside the
+ * mode โ so a minor key's iv is minor and a major key's V is major, with no chord table.
+ * @param {MusicPreset} preset @param {number} degree @returns {number[]}
+ */
+export function triadOn(preset, degree) {
+ const s = preset.scale;
+ /** @param {number} i */
+ const note = (i) => preset.root + s[((i % 7) + 7) % 7] + 12 * Math.floor(i / 7);
+ return [note(degree), note(degree + 2), note(degree + 4)];
+}
+
+/** @param {string | undefined} lane @param {number} i */
+const hitAt = (lane, i) => !!lane && lane[i] !== undefined && lane[i] !== '.';
+
+/**
+ * What global step `step` plays. Pure: same preset + same step = same events, on every
+ * device. The chord changes per BAR (16 steps), cycling the progression.
+ * @param {MusicPreset} preset @param {number} step @returns {MusicEvent[]}
+ */
+export function stepEvents(preset, step) {
+ const i = ((step % 16) + 16) % 16;
+ const bar = Math.floor(step / 16);
+ const degree = preset.progression[((bar % preset.progression.length) + preset.progression.length) % preset.progression.length];
+ const triad = triadOn(preset, degree);
+ /** @type {MusicEvent[]} */
+ const out = [];
+ if (hitAt(preset.drums.kick, i)) out.push({ kind: 'kick' });
+ if (hitAt(preset.drums.snare, i)) out.push({ kind: 'snare' });
+ if (hitAt(preset.drums.hat, i)) out.push({ kind: 'hat' });
+ const b = preset.bass[i];
+ if (b && b !== '.') {
+ const base = triad[0] - 24;
+ const midi = b === 'o' ? base + 12 : b === 'f' ? base + 7 : b === 't' ? triad[1] - 24 : base;
+ // a bass note lasts until the next one in its lane
+ let len = 1;
+ while (len < 16 && (preset.bass[(i + len) % 16] ?? '.') === '.' && i + len < 16) len++;
+ out.push({ kind: 'bass', midi, beats: len / 4 });
+ }
+ if (preset.pad && i === 0) out.push({ kind: 'pad', midi: triad, beats: 4 });
+ if (preset.arp) {
+ const c = preset.arp.lane[i];
+ if (c && c !== '.') {
+ const k = Number(c);
+ const tone = triad[k % 3] + 12 * (Math.floor(k / 3) + preset.arp.octave);
+ out.push({ kind: 'arp', midi: tone, beats: 0.25 });
+ }
+ }
+ return out;
+}
diff --git a/src/lib/gamePresence.js b/src/lib/gamePresence.js
index 3f38f0ca..3771d284 100644
--- a/src/lib/gamePresence.js
+++ b/src/lib/gamePresence.js
@@ -25,11 +25,15 @@
// history's import subtree, and nothing here registers a history kind.
import { writable, get } from 'svelte/store';
-import { peers } from '../stores/appStore';
+import { peers, showToast } from '../stores/appStore';
import { isLocked } from '../stores/sceneStore';
import { rolesInfo } from './cloudHooks';
import { sessionHost } from './connectionState';
import { gameState, resetGame } from './gameState';
+// 30 P2: both leaves (hudDocs: svelte/store + gameState + hudKinds + safeStorage; playMode:
+// sceneStore + appStore), so Test play lives here beside the reset permission it obeys
+import { hudIsGame, hudScreenOverride } from './hudDocs';
+import { requestPlay } from './playMode';
/** The two modes. A third would be a protocol change, so the reader treats anything it
* does not recognise as `editor` (the normalize-at-the-boundary rule). */
@@ -275,8 +279,11 @@ export function isSessionWriter() {
*/
export function tickAbandonWatch(now = Date.now()) {
const game = get(gameState);
- if (game.state !== 'playing') {
- // nothing to abandon; forget the arming so the NEXT round starts clean
+ // 30 P2 (roadmap 30 fork 5): EVERY state but `menu` is a round a player can leave โ a
+ // paused round and a finished one (`over`, the victory screen) used to linger on the
+ // editor forever, because only `playing` was watched. `menu` is the one state with
+ // nothing to abandon, and reaching it forgets the arming so the NEXT round starts clean.
+ if (game.state === 'menu') {
armedRound = null;
return 'idle';
}
@@ -287,7 +294,11 @@ export function tickAbandonWatch(now = Date.now()) {
}
// a round nobody has entered yet is not an abandoned round
if (armedRound !== game.round) return 'idle';
- if (now - lastPlayingAt < ABANDON_MS) return 'waiting';
+ // 30 P2: ALONE, leaving IS abandoning โ there is nobody whose reload or connect dance
+ // the ten-second window protects, and the round must not sit `playing` with its in-game
+ // HUD waiting for a player who is back in the editor. With peers the window stands.
+ const grace = livePeers().length ? ABANDON_MS : 0;
+ if (now - lastPlayingAt < grace) return 'waiting';
if (!isSessionWriter()) return 'notwriter';
// re-read through the single write path: `resetGame` is what the admin button calls,
// so the two ways a game ends up back at its menu are literally one function
@@ -342,6 +353,41 @@ export function requestResetGame() {
/** @type {(()=>void)[]} */
let disposers = [];
+/** was this peer in play at the last isLocked notification (the exit EDGE, not a state) */
+let wasPlaying = false;
+/** @type {any} */ let pokeTimer = null;
+
+/** One deferred watch pass; several notifications in one task coalesce into it. */
+function pokeWatch() {
+ if (pokeTimer || typeof setTimeout === 'undefined') return;
+ pokeTimer = setTimeout(() => {
+ pokeTimer = null;
+ tickAbandonWatch();
+ }, 0);
+}
+
+/**
+ * 30 P2 โ โถ TEST PLAY (roadmap 30 fork 4). What an author means by "try my game": back to
+ * the menu, into Play, the Start screen in front of them. Reached from the game chip and
+ * from the play button's right-click menu.
+ *
+ * The reset goes through `requestResetGame`, so it obeys the same rule as the Users
+ * popover's admin entry: the host (or anyone alone) may, an admin under a roles plugin
+ * may, and anybody else JOINS the game as it is โ told so, never silently. This peer's
+ * own screen overrides are cleared either way (LOCAL), so the state-bound screen decides
+ * what shows. Play itself is the ordinary `requestPlay`, synchronously inside the click, so
+ * an immersive session is still requested within the gesture that asked for it.
+ * @returns {{ok: boolean, reason?: string}} the reset's verdict
+ */
+export function testPlay() {
+ const game = get(gameState);
+ const pristine = game.state === 'menu' && !game.startedAt;
+ const verdict = pristine ? { ok: true } : requestResetGame();
+ if (!verdict.ok) showToast((verdict.reason ?? 'The game cannot be reset.') + ' Joining the game as it is.');
+ hudScreenOverride.set({});
+ requestPlay();
+ return verdict;
+}
/** Install the presence broadcast + the abandon watch. Idempotent. */
export function startGamePresence() {
@@ -349,7 +395,26 @@ export function startGamePresence() {
// the subscribe lives HERE and not at module scope: a module-level subscribe runs
// its callback SYNCHRONOUSLY at module eval, and anything it reads that is declared
// below TDZ-crashes the SSR prerender (the documented meshEdit/faceEdit trap)
- disposers.push(isLocked.subscribe(() => publishPlayMode()));
+ disposers.push(
+ isLocked.subscribe((v) => {
+ publishPlayMode();
+ // 30 P2: a screen THIS peer chose during play (the pause menu a P press opened, a
+ // hudscreen node's override) is play state โ it must not follow the player back
+ // into the editor, or the next Play lands on a stale pause screen over a round
+ // that has since returned to its menu. Games only: a plain HUD's overrides are
+ // authored behaviour and keep their old lifetime.
+ if (v !== true && wasPlaying && get(hudIsGame)) hudScreenOverride.set({});
+ wasPlaying = v === true;
+ pokeWatch();
+ })
+ );
+ // 30 P2: the watch also runs on the EDGES, not only on its 1s beat: entering play arms
+ // the round even if the player leaves inside the first second, and the last player
+ // leaving resets a solo round on the spot. Deferred a macrotask, because a tick may
+ // WRITE the game state and these are store notifications (never write a store from
+ // inside a subscriber).
+ disposers.push(gameState.subscribe(() => pokeWatch()));
+ disposers.push(peerPlayModes.subscribe(() => pokeWatch()));
watchTimer = setInterval(() => tickAbandonWatch(), WATCH_MS);
disposers.push(() => {
clearInterval(watchTimer);
@@ -361,6 +426,9 @@ export function startGamePresence() {
export function stopGamePresence() {
for (const dispose of disposers) dispose();
disposers = [];
+ if (pokeTimer) clearTimeout(pokeTimer);
+ pokeTimer = null;
+ wasPlaying = false;
}
/** Test/debug view โ `abandonWrites` is the only way to tell WHICH peer wrote a
diff --git a/src/lib/gameSfx.js b/src/lib/gameSfx.js
new file mode 100644
index 00000000..702caca5
--- /dev/null
+++ b/src/lib/gameSfx.js
@@ -0,0 +1,378 @@
+// 30b (vr-play) C5 โ THE GAME SOUND SET. Twenty procedural WebAudio effects a game can
+// name (`api.playSound('coin')`), with NO asset files: every sound is oscillators, a
+// shared noise buffer, filters and envelopes, built fresh per play on the app's one
+// AudioContext (audioEngine โ a second context would have its own listener and clock).
+//
+// Why procedural and not samples: a module is a self-contained zip, the sound set must be
+// the same on every peer with nothing to fetch, and "the user asked for sounds in every
+// game" is not worth a megabyte of wav per build. The ping chimes (pingAudio.js) set the
+// precedent; this is the same idea grown to a game's vocabulary.
+//
+// ROUTING: every game sound goes through ONE gain (the "Game sounds" volume in Settings,
+// persisted LOCALLY through safeStorage) into the engine's `sfx` bus, so the mixer and the
+// limiter see it like any other effect. A `position` spatialises it through a PannerNode
+// (the engine keeps the listener on the camera/headset whatever spatialVoice says).
+//
+// CAPPED: a sweep across a keyboard or a round of explosions must not stack hundreds of
+// live voices โ past MAX_LIVE the oldest-ending ones are simply not started (a dropped
+// sound is inaudible under that many; a stalled audio thread is not).
+//
+// A LEAF: svelte/store + audioEngine + safeStorage. `renderGameSound` renders one into an
+// OfflineAudioContext and MEASURES it โ the suite's proof that every name makes a sound.
+import { writable, get } from 'svelte/store';
+import { ensureAudioContext, bus } from './audioEngine';
+import { safeStorage } from './safeStorage';
+
+/** The names a game can play. Anything else is a quiet no-op (never an error). */
+export const GAME_SOUNDS = [
+ 'click',
+ 'pop',
+ 'whoosh',
+ 'success',
+ 'fail',
+ 'hit',
+ 'kick',
+ 'shoot',
+ 'laser',
+ 'explosion',
+ 'coin',
+ 'levelup',
+ 'goal',
+ 'whistle',
+ 'cheer',
+ 'step',
+ 'ring',
+ 'sparkle',
+ 'hurt',
+ 'portal'
+];
+const SOUND_SET = new Set(GAME_SOUNDS);
+
+/** @param {string} name */
+export function isGameSound(name) {
+ return SOUND_SET.has(String(name));
+}
+
+const VOLUME_KEY = 'game:soundVolume';
+const MAX_LIVE = 24;
+
+/** @param {string | null} raw @param {number} fallback */
+function readVolume(raw, fallback) {
+ const n = raw === null ? NaN : Number(raw);
+ // Number.isFinite, never `|| fallback`: a slider dragged to ZERO is a real volume
+ return Number.isFinite(n) ? Math.min(1, Math.max(0, n)) : fallback;
+}
+
+/** "Game sounds" volume, 0..1, LOCAL per device (Settings โธ Interface โธ Sound). */
+export const gameSoundVolume = writable(readVolume(safeStorage.getItem(VOLUME_KEY), 0.8));
+
+/** @type {GainNode | null} */
+let gameGain = null;
+
+// Declared ABOVE the subscribe (the module-level subscribe runs synchronously โ TDZ rule).
+gameSoundVolume.subscribe((v) => {
+ safeStorage.setItem(VOLUME_KEY, String(v));
+ if (gameGain) gameGain.gain.value = v;
+});
+
+/** the game-sound gain on the sfx bus, built once @returns {GainNode} */
+function gameOut() {
+ const ctx = ensureAudioContext();
+ if (!gameGain) {
+ gameGain = ctx.createGain();
+ gameGain.gain.value = get(gameSoundVolume);
+ gameGain.connect(bus('sfx'));
+ }
+ return gameGain;
+}
+
+/* ------------------------------------------------------------------ the synth ---- */
+
+/** @type {WeakMap} one second of white noise per context */
+const noiseBuffers = new WeakMap();
+
+/** @param {BaseAudioContext} ctx @returns {AudioBuffer} */
+function noiseBuffer(ctx) {
+ let buffer = noiseBuffers.get(ctx);
+ if (buffer) return buffer;
+ buffer = ctx.createBuffer(1, ctx.sampleRate, ctx.sampleRate);
+ const data = buffer.getChannelData(0);
+ // a fixed LCG, so the same sound renders the same bytes on every device
+ let seed = 0x2f6b1d;
+ for (let i = 0; i < data.length; i++) {
+ seed = (seed * 1664525 + 1013904223) >>> 0;
+ data[i] = (seed / 0xffffffff) * 2 - 1;
+ }
+ noiseBuffers.set(ctx, buffer);
+ return buffer;
+}
+
+/** exponential ramps cannot reach zero */
+const FLOOR = 0.0001;
+
+/**
+ * One enveloped oscillator, optionally gliding.
+ * @param {BaseAudioContext} ctx @param {AudioNode} dest
+ * @param {{freq: number, to?: number, type?: OscillatorType, t0: number, dur: number,
+ * peak?: number, attack?: number, vibrato?: {rate: number, depth: number}}} o
+ */
+function tone(ctx, dest, o) {
+ const { freq, to, type = 'sine', t0, dur, peak = 0.25, attack = 0.008, vibrato } = o;
+ const osc = ctx.createOscillator();
+ const gain = ctx.createGain();
+ osc.type = type;
+ osc.frequency.setValueAtTime(freq, t0);
+ if (to) osc.frequency.exponentialRampToValueAtTime(Math.max(20, to), t0 + dur);
+ if (vibrato) {
+ const lfo = ctx.createOscillator();
+ const depth = ctx.createGain();
+ lfo.frequency.value = vibrato.rate;
+ depth.gain.value = vibrato.depth;
+ lfo.connect(depth).connect(osc.frequency);
+ lfo.start(t0);
+ lfo.stop(t0 + dur + 0.05);
+ }
+ gain.gain.setValueAtTime(FLOOR, t0);
+ gain.gain.exponentialRampToValueAtTime(peak, t0 + attack);
+ gain.gain.exponentialRampToValueAtTime(FLOOR, t0 + dur);
+ osc.connect(gain).connect(dest);
+ osc.start(t0);
+ osc.stop(t0 + dur + 0.05);
+}
+
+/**
+ * A filtered noise burst, optionally sweeping its filter.
+ * @param {BaseAudioContext} ctx @param {AudioNode} dest
+ * @param {{t0: number, dur: number, peak?: number, attack?: number,
+ * filter?: BiquadFilterType, freq?: number, to?: number, q?: number, flutter?: number}} o
+ */
+function noise(ctx, dest, o) {
+ const { t0, dur, peak = 0.3, attack = 0.005, filter = 'lowpass', freq = 2000, to, q = 0.8, flutter } = o;
+ const src = ctx.createBufferSource();
+ src.buffer = noiseBuffer(ctx);
+ src.loop = true;
+ const biquad = ctx.createBiquadFilter();
+ biquad.type = filter;
+ biquad.Q.value = q;
+ biquad.frequency.setValueAtTime(freq, t0);
+ if (to) biquad.frequency.exponentialRampToValueAtTime(Math.max(20, to), t0 + dur);
+ const gain = ctx.createGain();
+ gain.gain.setValueAtTime(FLOOR, t0);
+ gain.gain.exponentialRampToValueAtTime(peak, t0 + attack);
+ gain.gain.exponentialRampToValueAtTime(FLOOR, t0 + dur);
+ src.connect(biquad).connect(gain);
+ if (flutter) {
+ // a crowd is not one steady hiss: an amplitude wobble makes it read as voices
+ const wobble = ctx.createGain();
+ wobble.gain.value = 0.6;
+ const lfo = ctx.createOscillator();
+ const depth = ctx.createGain();
+ lfo.frequency.value = flutter;
+ depth.gain.value = 0.4;
+ lfo.connect(depth).connect(wobble.gain);
+ lfo.start(t0);
+ lfo.stop(t0 + dur + 0.05);
+ gain.connect(wobble).connect(dest);
+ } else gain.connect(dest);
+ src.start(t0, (t0 * 7.13) % 0.5);
+ src.stop(t0 + dur + 0.05);
+}
+
+/** equal-tempered frequency of a midi note @param {number} midi */
+const hz = (midi) => 440 * Math.pow(2, (midi - 69) / 12);
+
+/**
+ * Build one sound into `dest` starting at `t0`. Returns its length in seconds, or -1 for
+ * a name that is not in the set. Pure WebAudio graph building โ the live player and the
+ * offline measurement share it, so what the suite measures is what a player hears.
+ * @param {BaseAudioContext} ctx @param {AudioNode} dest @param {string} name @param {number} t0
+ * @returns {number}
+ */
+export function buildGameSound(ctx, dest, name, t0) {
+ switch (name) {
+ case 'click':
+ tone(ctx, dest, { freq: 1900, type: 'square', t0, dur: 0.035, peak: 0.12, attack: 0.002 });
+ noise(ctx, dest, { t0, dur: 0.03, peak: 0.12, filter: 'highpass', freq: 3000 });
+ return 0.05;
+ case 'pop':
+ tone(ctx, dest, { freq: 380, to: 1100, t0, dur: 0.09, peak: 0.3, attack: 0.004 });
+ return 0.1;
+ case 'whoosh':
+ noise(ctx, dest, { t0, dur: 0.45, peak: 0.35, attack: 0.12, filter: 'bandpass', freq: 300, to: 2600, q: 1.4 });
+ return 0.5;
+ case 'success':
+ [72, 76, 79, 84].forEach((m, i) =>
+ tone(ctx, dest, { freq: hz(m), type: 'triangle', t0: t0 + i * 0.08, dur: i === 3 ? 0.5 : 0.18, peak: 0.28 })
+ );
+ return 0.8;
+ case 'fail':
+ tone(ctx, dest, { freq: 330, type: 'square', t0, dur: 0.22, peak: 0.12 });
+ tone(ctx, dest, { freq: 247, type: 'square', t0: t0 + 0.2, dur: 0.22, peak: 0.12 });
+ tone(ctx, dest, { freq: 196, to: 150, type: 'sawtooth', t0: t0 + 0.4, dur: 0.45, peak: 0.12 });
+ return 0.9;
+ case 'hit':
+ tone(ctx, dest, { freq: 160, to: 55, t0, dur: 0.18, peak: 0.5, attack: 0.002 });
+ noise(ctx, dest, { t0, dur: 0.12, peak: 0.4, freq: 1400, to: 300, attack: 0.002 });
+ return 0.22;
+ case 'kick':
+ tone(ctx, dest, { freq: 150, to: 42, t0, dur: 0.3, peak: 0.7, attack: 0.002 });
+ noise(ctx, dest, { t0, dur: 0.02, peak: 0.15, filter: 'highpass', freq: 2000, attack: 0.001 });
+ return 0.32;
+ case 'shoot':
+ tone(ctx, dest, { freq: 900, to: 140, type: 'square', t0, dur: 0.16, peak: 0.16, attack: 0.002 });
+ noise(ctx, dest, { t0, dur: 0.1, peak: 0.3, freq: 3000, to: 500, attack: 0.001 });
+ return 0.2;
+ case 'laser':
+ tone(ctx, dest, { freq: 1500, to: 280, type: 'sawtooth', t0, dur: 0.26, peak: 0.14, attack: 0.003 });
+ tone(ctx, dest, { freq: 3000, to: 560, type: 'sine', t0, dur: 0.2, peak: 0.08, attack: 0.003 });
+ return 0.3;
+ case 'explosion':
+ noise(ctx, dest, { t0, dur: 1.3, peak: 0.7, freq: 2200, to: 120, attack: 0.004, q: 0.5 });
+ tone(ctx, dest, { freq: 90, to: 28, t0, dur: 0.9, peak: 0.6, attack: 0.004 });
+ return 1.35;
+ case 'coin':
+ tone(ctx, dest, { freq: hz(83), type: 'square', t0, dur: 0.08, peak: 0.13, attack: 0.002 });
+ tone(ctx, dest, { freq: hz(88), type: 'square', t0: t0 + 0.07, dur: 0.35, peak: 0.13, attack: 0.002 });
+ return 0.45;
+ case 'levelup':
+ [60, 64, 67, 72, 76, 79, 84, 88].forEach((m, i) =>
+ tone(ctx, dest, { freq: hz(m), type: 'triangle', t0: t0 + i * 0.055, dur: i === 7 ? 0.6 : 0.12, peak: 0.24 })
+ );
+ return 1.05;
+ case 'goal':
+ // a bright major stab that swells, with a rising run on top โ a stadium moment
+ [60, 64, 67, 72].forEach((m) =>
+ tone(ctx, dest, { freq: hz(m), type: 'sawtooth', t0, dur: 1.1, peak: 0.07, attack: 0.05 })
+ );
+ [79, 84, 88, 91].forEach((m, i) =>
+ tone(ctx, dest, { freq: hz(m), type: 'triangle', t0: t0 + 0.05 + i * 0.07, dur: 0.4, peak: 0.16 })
+ );
+ noise(ctx, dest, { t0: t0 + 0.1, dur: 1.4, peak: 0.12, attack: 0.3, filter: 'bandpass', freq: 1600, q: 0.7, flutter: 9 });
+ return 1.5;
+ case 'whistle':
+ // a referee's pea whistle: a high tone with a fast trill
+ tone(ctx, dest, { freq: 2900, type: 'sine', t0, dur: 0.55, peak: 0.2, attack: 0.02, vibrato: { rate: 32, depth: 160 } });
+ return 0.6;
+ case 'cheer':
+ noise(ctx, dest, { t0, dur: 1.8, peak: 0.3, attack: 0.35, filter: 'bandpass', freq: 1200, to: 1700, q: 0.6, flutter: 7 });
+ noise(ctx, dest, { t0: t0 + 0.1, dur: 1.6, peak: 0.14, attack: 0.4, filter: 'bandpass', freq: 2600, q: 1.2, flutter: 11 });
+ return 1.9;
+ case 'step':
+ noise(ctx, dest, { t0, dur: 0.08, peak: 0.3, freq: 500, to: 180, attack: 0.002 });
+ return 0.1;
+ case 'ring':
+ tone(ctx, dest, { freq: 1320, t0, dur: 1.0, peak: 0.2, attack: 0.003 });
+ tone(ctx, dest, { freq: 1320 * 2.76, t0, dur: 0.5, peak: 0.07, attack: 0.003 });
+ tone(ctx, dest, { freq: 1320 * 5.4, t0, dur: 0.25, peak: 0.04, attack: 0.003 });
+ return 1.05;
+ case 'sparkle': {
+ // seven high pings on a fixed pattern (deterministic โ no Math.random)
+ const notes = [96, 100, 103, 98, 105, 101, 108];
+ notes.forEach((m, i) => tone(ctx, dest, { freq: hz(m), t0: t0 + i * 0.045, dur: 0.22, peak: 0.1, attack: 0.002 }));
+ return 0.55;
+ }
+ case 'hurt':
+ tone(ctx, dest, { freq: 320, to: 110, type: 'sawtooth', t0, dur: 0.32, peak: 0.2, attack: 0.004 });
+ noise(ctx, dest, { t0, dur: 0.15, peak: 0.2, freq: 900, attack: 0.002 });
+ return 0.36;
+ case 'portal':
+ tone(ctx, dest, { freq: 180, to: 1300, t0, dur: 0.9, peak: 0.18, attack: 0.3, vibrato: { rate: 9, depth: 40 } });
+ tone(ctx, dest, { freq: 270, to: 1950, type: 'triangle', t0: t0 + 0.05, dur: 0.85, peak: 0.08, attack: 0.3 });
+ noise(ctx, dest, { t0, dur: 0.9, peak: 0.08, attack: 0.4, filter: 'bandpass', freq: 800, to: 4000, q: 2 });
+ return 0.95;
+ default:
+ return -1;
+ }
+}
+
+/* ------------------------------------------------------------------ the player --- */
+
+/** end times (context seconds) of the sounds still ringing @type {number[]} */
+const live = [];
+const debug = { played: 0, dropped: 0, unknown: 0, last: '' };
+
+/**
+ * Play a game sound, LOCAL to this device. Returns true when a sound started; false for
+ * an unknown name, a missing audio context, or the voice cap.
+ * @param {string} name @param {number[] | null} [position] world-space, spatialised
+ * @returns {boolean}
+ */
+export function playGameSound(name, position = null) {
+ if (!isGameSound(name)) {
+ debug.unknown++;
+ return false;
+ }
+ /** @type {AudioContext} */
+ let ctx;
+ try {
+ ctx = ensureAudioContext();
+ } catch {
+ return false;
+ }
+ if (ctx.state === 'suspended') ctx.resume().catch(() => {});
+ const now = ctx.currentTime;
+ while (live.length && live[0] <= now) live.shift();
+ if (live.length >= MAX_LIVE) {
+ debug.dropped++;
+ return false;
+ }
+ /** @type {AudioNode} */
+ let dest = gameOut();
+ if (Array.isArray(position) && position.length >= 3 && position.every((n) => Number.isFinite(n))) {
+ const panner = ctx.createPanner();
+ panner.panningModel = 'HRTF';
+ panner.distanceModel = 'inverse';
+ panner.refDistance = 2;
+ if (panner.positionX) {
+ panner.positionX.value = position[0];
+ panner.positionY.value = position[1];
+ panner.positionZ.value = position[2];
+ } else panner.setPosition(position[0], position[1], position[2]);
+ panner.connect(dest);
+ dest = panner;
+ }
+ const length = buildGameSound(ctx, dest, name, now + 0.005);
+ live.push(now + length);
+ live.sort((a, b) => a - b);
+ debug.played++;
+ debug.last = name;
+ return true;
+}
+
+/**
+ * Render one sound OFFLINE and measure it (the suite's "does every name make a sound").
+ * @param {string} name @param {number} [sampleRate]
+ * @returns {Promise<{name: string, seconds: number, rms: number, peak: number} | null>}
+ */
+export async function renderGameSound(name, sampleRate = 22050) {
+ const Offline = typeof window !== 'undefined' ? /** @type {any} */ (window).OfflineAudioContext : null;
+ if (!Offline) return null;
+ const probe = new Offline(1, 64, sampleRate);
+ const seconds = buildGameSound(probe, probe.destination, name, 0);
+ if (seconds < 0) return { name, seconds: -1, rms: 0, peak: 0 };
+ const ctx = new Offline(1, Math.ceil((seconds + 0.1) * sampleRate), sampleRate);
+ buildGameSound(ctx, ctx.destination, name, 0);
+ const buffer = await ctx.startRendering();
+ const data = buffer.getChannelData(0);
+ let sum = 0;
+ let peak = 0;
+ for (let i = 0; i < data.length; i++) {
+ sum += data[i] * data[i];
+ peak = Math.max(peak, Math.abs(data[i]));
+ }
+ return { name, seconds, rms: Math.sqrt(sum / data.length), peak };
+}
+
+/** the live gain's value (null before the first sound) โ the volume's measurable end */
+export function gameSoundGainValue() {
+ return gameGain ? gameGain.gain.value : null;
+}
+
+/** counts for the suites @returns {{played: number, dropped: number, unknown: number, last: string, live: number}} */
+export function gameSfxDebug() {
+ return { ...debug, live: live.length };
+}
+
+// the two building blocks, shared with the music scheduler (gameMusic.js) so a game's
+// music and its effects are made of the same parts
+export { tone as sfxTone, noise as sfxNoise };
diff --git a/src/lib/gameStorage.js b/src/lib/gameStorage.js
new file mode 100644
index 00000000..e68d6544
--- /dev/null
+++ b/src/lib/gameStorage.js
@@ -0,0 +1,197 @@
+// 30 P4 โ WHAT A GAME REMEMBERS ON THIS DEVICE (roadmap 30 fork 7).
+//
+// Two consumers, one rule: a module's `api.storage` and the Store Value / Stored Value flow
+// nodes. Both are LOCAL per device by design โ a best score, unlocked levels, a "seen the
+// tutorial" flag โ so nothing here replicates, nothing enters a scene file and nothing is
+// undone. A peer's best is theirs; the card says so ("Saved on this device only").
+//
+// THE KEYS ARE A CONTRACT, byte for byte, because a module that must run on an older core
+// (untangle's fallback for a 1.16 client) writes the SAME key itself so its data survives
+// the upgrade:
+//
+// tp:mod:: a module's own namespace (api.storage)
+// tp:scene:: a flow graph's namespace, per scene ('untitled'
+// when the scene has no name)
+//
+// VALUES ARE JSON. `set(key, value)` stores `JSON.stringify(value)`; `get` parses it back
+// and hands the fallback over on a missing key OR a value that does not parse (a hand-edited
+// entry, a truncated write), because a broken save must never crash a game's boot.
+//
+// THE CAP is per MODULE (256 KB), measured as the JSON text of every entry in the namespace
+// plus its key โ what the browser actually spends. A write that would cross it returns
+// false and nothing is written; the caller hears it once per session through `onOverCap`
+// (moduleSDK turns that into ONE toast), never once per frame.
+//
+// A DELIBERATE LEAF: it imports safeStorage (itself a leaf) and nothing else, so moduleSDK,
+// flowRuntime and a unit test can all reach it. safeStorage is what makes the promise
+// honest: in Safari private mode, a sandboxed frame or a full quota the write falls back to
+// memory for the session instead of throwing.
+
+import { safeStorage } from './safeStorage';
+
+/** bytes (UTF-16 code units, i.e. JSON text length) one module may keep */
+export const MODULE_STORAGE_CAP = 256 * 1024;
+
+/** @param {string} moduleId */
+export function modulePrefix(moduleId) {
+ return 'tp:mod:' + String(moduleId) + ':';
+}
+
+/** @param {string|null|undefined} sceneName */
+export function scenePrefix(sceneName) {
+ const name = typeof sceneName === 'string' && sceneName.trim() ? sceneName.trim() : 'untitled';
+ return 'tp:scene:' + name + ':';
+}
+
+/** The full key a module writes. @param {string} moduleId @param {string} key */
+export function moduleStorageKey(moduleId, key) {
+ return modulePrefix(moduleId) + String(key);
+}
+
+/** The full key a Store Value node writes. @param {string|null|undefined} sceneName @param {string} key */
+export function sceneStorageKey(sceneName, key) {
+ return scenePrefix(sceneName) + String(key);
+}
+
+/** Parsed-value cache keyed by the FULL key. Every write through this module keeps it
+ * current, so a Stored Value node read every frame costs a Map lookup, not a
+ * localStorage read and a JSON.parse. @type {Map} */
+const cache = new Map();
+
+/** @param {string} full @returns {{text: string|null, value: any}} */
+function read(full) {
+ const hit = cache.get(full);
+ if (hit) return hit;
+ const text = safeStorage.getItem(full);
+ /** @type {any} */
+ let value;
+ let ok = text !== null;
+ if (ok) {
+ try {
+ value = JSON.parse(/** @type {string} */ (text));
+ } catch {
+ ok = false;
+ }
+ }
+ const entry = { text: ok ? text : null, value: ok ? value : undefined };
+ cache.set(full, entry);
+ return entry;
+}
+
+/** Read a full key: the parsed value, or `fallback` when it is missing or unreadable.
+ * @param {string} full @param {any} [fallback] */
+export function readStored(full, fallback = undefined) {
+ const entry = read(full);
+ return entry.text === null ? fallback : entry.value;
+}
+
+/** Does a full key hold a readable value? @param {string} full */
+export function hasStored(full) {
+ return read(full).text !== null;
+}
+
+/** Write a full key (JSON). `undefined` removes it. Returns the JSON text written, or
+ * null when the value cannot be serialized (a cycle, a BigInt).
+ * @param {string} full @param {any} value @returns {string|null} */
+export function writeStored(full, value) {
+ if (value === undefined) {
+ removeStored(full);
+ return '';
+ }
+ /** @type {string} */
+ let text;
+ try {
+ const out = JSON.stringify(value);
+ if (typeof out !== 'string') return null; // a function, a symbol
+ text = out;
+ } catch {
+ return null;
+ }
+ safeStorage.setItem(full, text);
+ cache.set(full, { text, value: JSON.parse(text) });
+ return text;
+}
+
+/** @param {string} full */
+export function removeStored(full) {
+ safeStorage.removeItem(full);
+ cache.delete(full);
+}
+
+/** Every full key under a prefix. @param {string} prefix @returns {string[]} */
+function fullKeysUnder(prefix) {
+ return safeStorage.keys().filter((key) => key.startsWith(prefix));
+}
+
+/** The JSON bytes a namespace holds, optionally pretending one key held `replace` instead.
+ * @param {string} prefix @param {string} [except] a full key to leave out */
+function usedBytes(prefix, except) {
+ let total = 0;
+ for (const full of fullKeysUnder(prefix)) {
+ if (full === except) continue;
+ const text = safeStorage.getItem(full);
+ if (text !== null) total += full.length + text.length;
+ }
+ return total;
+}
+
+/**
+ * The `api.storage` object for one module.
+ * @param {string} moduleId
+ * @param {{cap?: number, onOverCap?: (info: {key: string, bytes: number, cap: number}) => void}} [options]
+ */
+export function makeModuleStorage(moduleId, options = {}) {
+ const prefix = modulePrefix(moduleId);
+ const cap = options.cap ?? MODULE_STORAGE_CAP;
+ return {
+ /** @param {string} key @param {any} [fallback] */
+ get(key, fallback = undefined) {
+ return readStored(prefix + String(key), fallback);
+ },
+ /** @param {string} key @param {any} value @returns {boolean} written */
+ set(key, value) {
+ const full = prefix + String(key);
+ if (value === undefined) {
+ removeStored(full);
+ return true;
+ }
+ /** @type {string|undefined} */
+ let text;
+ try {
+ text = JSON.stringify(value);
+ } catch {
+ return false;
+ }
+ if (typeof text !== 'string') return false;
+ const bytes = usedBytes(prefix, full) + full.length + text.length;
+ if (bytes > cap) {
+ options.onOverCap?.({ key: String(key), bytes, cap });
+ return false;
+ }
+ return writeStored(full, value) !== null;
+ },
+ /** @param {string} key */
+ remove(key) {
+ removeStored(prefix + String(key));
+ },
+ /** this module's keys, without the prefix, sorted @returns {string[]} */
+ keys() {
+ return fullKeysUnder(prefix)
+ .map((full) => full.slice(prefix.length))
+ .sort();
+ },
+ /** forget everything this module stored โ the module's own reset */
+ clear() {
+ for (const full of fullKeysUnder(prefix)) removeStored(full);
+ },
+ /** how much of the cap is spent (JSON text of every entry + its key) */
+ bytes() {
+ return usedBytes(prefix);
+ }
+ };
+}
+
+/** TEST SEAM: forget the parsed-value cache (a suite that writes localStorage directly). */
+export function debugResetGameStorage() {
+ cache.clear();
+}
diff --git a/src/lib/hapticPatterns.js b/src/lib/hapticPatterns.js
new file mode 100644
index 00000000..f39b99e8
--- /dev/null
+++ b/src/lib/hapticPatterns.js
@@ -0,0 +1,78 @@
+// 30b (vr-play) C4 โ HAPTIC PATTERNS as data. `api.hapticPattern(name, hand)` plays one
+// of these on the Quest's controller actuators; core fires the defaults in Interact/Play
+// (hover-enter `tap`, a press `bump`, a grab `hit`, a knock scaled by its impulse).
+//
+// A pattern is a list of PULSES, each `{at, intensity, ms}` with `at` in milliseconds
+// from the start. A GamepadHapticActuator plays one pulse at a time and a new pulse
+// REPLACES the one running, so a pattern is timed pulses, never overlapping ones โ the
+// schedule below is what vrControls plays through setTimeout.
+//
+// Imports NOTHING: the shapes are tested with no browser and no headset. How they FEEL is
+// the on-device check (owed) โ the numbers are a first pass at "make it cool".
+
+/** @typedef {{at: number, intensity: number, ms: number}} HapticPulse */
+
+/** @type {Record} */
+export const HAPTIC_PATTERNS = {
+ // a fingertip on a button: the lightest thing that is still felt
+ tap: [{ at: 0, intensity: 0.18, ms: 12 }],
+ // a press landing: short and firm
+ bump: [{ at: 0, intensity: 0.45, ms: 28 }],
+ // a grab or a solid contact: a hard edge, then a short settle
+ hit: [
+ { at: 0, intensity: 0.85, ms: 35 },
+ { at: 55, intensity: 0.3, ms: 25 }
+ ],
+ // a win: three rising pulses
+ success: [
+ { at: 0, intensity: 0.3, ms: 40 },
+ { at: 90, intensity: 0.5, ms: 40 },
+ { at: 180, intensity: 0.8, ms: 70 }
+ ],
+ // a miss: two heavy, flat pulses
+ fail: [
+ { at: 0, intensity: 0.7, ms: 90 },
+ { at: 160, intensity: 0.7, ms: 140 }
+ ],
+ // an engine / an explosion: a long grinding buzz, fading
+ rumble: [
+ { at: 0, intensity: 1, ms: 120 },
+ { at: 120, intensity: 0.75, ms: 120 },
+ { at: 240, intensity: 0.5, ms: 120 },
+ { at: 360, intensity: 0.28, ms: 140 }
+ ],
+ // lub-dub
+ heartbeat: [
+ { at: 0, intensity: 0.65, ms: 45 },
+ { at: 150, intensity: 0.4, ms: 55 }
+ ]
+};
+
+/** the names `api.hapticPattern` accepts */
+export const HAPTIC_PATTERN_NAMES = Object.keys(HAPTIC_PATTERNS);
+
+/**
+ * A pattern's pulses, clamped to what an actuator takes (0..1, 1..1000 ms). Unknown -> [].
+ * `scale` multiplies every intensity โ how a knock's impulse sizes a `hit`.
+ * @param {string} name @param {number} [scale] @returns {HapticPulse[]}
+ */
+export function hapticSchedule(name, scale = 1) {
+ const pattern = HAPTIC_PATTERNS[String(name)];
+ if (!pattern) return [];
+ const k = Number.isFinite(scale) ? Math.max(0, scale) : 1;
+ return pattern.map((p) => ({
+ at: Math.max(0, p.at),
+ intensity: Math.min(1, Math.max(0, p.intensity * k)),
+ ms: Math.min(1000, Math.max(1, p.ms))
+ }));
+}
+
+/**
+ * The strength a KNOCK buzzes with: a light brush is a tap, a real swing a full hit.
+ * `speed` is the closing speed in m/s (knockMath's `approach`).
+ * @param {number} speed @returns {number} 0.2..1
+ */
+export function knockHapticScale(speed) {
+ const s = Number.isFinite(speed) ? Math.max(0, speed) : 0;
+ return Math.min(1, 0.2 + s / 6);
+}
diff --git a/src/lib/helperLayer.js b/src/lib/helperLayer.js
index 1a5c55d2..3123ab54 100644
--- a/src/lib/helperLayer.js
+++ b/src/lib/helperLayer.js
@@ -1,6 +1,6 @@
// 24-E2: THE HELPER LAYER โ "the editor sees it, the game does not". Light helpers and
// their pick proxies, camera frustums and (while hidden) camera MARKERS live on render
-// layer 1; the editor camera enables it outside Play (and in Play with the debug
+// layer HELPER_LAYER (30, see below); the editor camera enables it outside Play (and in Play with the debug
// toggle), and every camera that renders FOR someone โ the play camera, a camera-object
// preview, PiP, thumbnails, captureThroughCamera โ is a fresh THREE camera on layer 0
// only, so it never sees them.
@@ -20,12 +20,26 @@
// Bits are flipped with enable/disable, never `set`: the outline passes mark a
// selected object with a layer bit of their own, and `set` would wipe it.
//
+//
+// 30b P0 โ THE LAYER NUMBER IS NOT FREE. three's WebXRManager renders each eye through
+// its own sub-camera and RESERVES layers 1 and 2 for them: every frame it writes
+// `cameraL.mask = (camera.mask | 0b110) & ~0b100` and `cameraR.mask = (camera.mask |
+// 0b110) & ~0b010` (WebXRManager.updateCamera, "1 = left, 2 = right"). So on layer 1 a
+// helper was ALWAYS drawn by the left eye โ whatever the editor camera enabled โ and never
+// by the right one: the light-source helper a Quest user saw "with one eye" in Towers,
+// Stars Room and Football. Any number the XR eye cameras only ever INHERIT (3..30; 31 is
+// overloadGuard's REDUCED_LAYER, and postprocessing's Selection ids count up from 2) is
+// correct; 30 keeps well clear of both.
+//
+// 30b P0/P1 โ Interact hides helpers too: Edit is the only mode that draws the editor's
+// scaffolding, in both eyes; Interact (desktop or VR) and Play draw none of it.
+//
// Imports sceneStore only (the lightHelpers/cameraHelpers family), no THREE.
-import { get, writable } from 'svelte/store';
-import { isLocked, editorCam, globalCamera, objectsGroup } from '../stores/sceneStore';
+import { derived, get, writable } from 'svelte/store';
+import { isLocked, editorCam, globalCamera, objectsGroup, editorMode } from '../stores/sceneStore';
import { safeStorage } from './safeStorage';
-export const HELPER_LAYER = 1;
+export const HELPER_LAYER = 30;
/** "Show helpers in Play (debug)" โ LOCAL pref, default off. While on, helpers render
* in Play and a DEBUG chip sits in the play HUD so a screenshot cannot be mistaken for
@@ -54,11 +68,25 @@ export function applyHelperLayer(camera, on) {
else camera.layers.disable(HELPER_LAYER);
}
-/** Should helpers be hidden right now (Play without the debug toggle)? */
+/** 30b P1: the ONE predicate every editor helper answers to โ Play (`isLocked === true`)
+ * or Interact hides them, unless the "Show helpers in Play (debug)" toggle keeps them.
+ * Pure so a suite can pin the table. @param {any} locked @param {any} mode @param {boolean} debug */
+export function helpersHiddenFor(locked, mode, debug) {
+ return (locked === true || mode === 'interact') && !debug;
+}
+
+/** Should helpers be hidden right now (Play or Interact, without the debug toggle)? */
export function helpersHidden() {
- return !!get(isLocked) && !get(helpersInPlay);
+ return helpersHiddenFor(get(isLocked), get(editorMode), get(helpersInPlay));
}
+/** 30b P1: the same answer as a store, for components (the grid, the VR selection shell)
+ * and for anything that toggles `visible` per frame. */
+export const editorHelpersShown = derived(
+ [isLocked, editorMode, helpersInPlay],
+ ([$locked, $mode, $debug]) => !helpersHiddenFor($locked, $mode, $debug)
+);
+
/** the marker-hop state the last `setMarkersHidden` applied (reads for the test seam) */
let markersHidden = false;
export function markersAreHidden() {
@@ -119,5 +147,6 @@ export function startHelperLayer() {
started = true;
editorCam.subscribe(applyToEditorCamera);
isLocked.subscribe(applyToEditorCamera);
+ editorMode.subscribe(applyToEditorCamera);
helpersInPlay.subscribe(applyToEditorCamera);
}
diff --git a/src/lib/history.js b/src/lib/history.js
index 6f0d3734..26e57bd4 100644
--- a/src/lib/history.js
+++ b/src/lib/history.js
@@ -185,6 +185,24 @@ export function recordTransformSet(items) {
// --- create/delete: object presence, restored from a serialized snapshot ---
+/** @type {((object: any) => any) | null} */
+let referenceSnapshot = null;
+
+/**
+ * 30b integrate: content that can be REBUILT from where it came from is kept in history as
+ * that reference, not as its serialized bytes. A kit piece placed from a pack is ~0.8 MB of
+ * GLB and ~7.9 MB as toJSON (its textures come back as PNG data URLs), so every placement
+ * toasted "too large for undo history" and level building with the kits had no undo. The
+ * provider returns a toJSON element (packRefs' stub) or null for "not a reference"; the
+ * restore needs nothing new, because a stub added back to the scene is refilled by the
+ * same scan that refills a loaded file's or a peer's. Registered, never imported: packRefs
+ * reaches the Explorer, and nothing in history's import subtree may grow that edge.
+ * @param {((object: any) => any) | null} fn
+ */
+export function registerReferenceSnapshot(fn) {
+ referenceSnapshot = fn;
+}
+
/**
* Serialize an object (ObjectLoader JSON round-trip, same format the
* `object` peer message uses for lights/parents) for create/delete entries.
@@ -199,7 +217,8 @@ export function captureObjectSnapshot(object, quiet = false) {
// re-broadcast hands one to every peer (editOverlays.js)
const unpark = parkEditOverlays(object);
try {
- const element = object.toJSON();
+ const reference = referenceSnapshot ? referenceSnapshot(object) : null;
+ const element = reference ?? object.toJSON();
if (JSON.stringify(element).length > SNAPSHOT_LIMIT) {
if (!quiet) showToast('Object is too large for undo history โ this step will not be undoable');
return null;
diff --git a/src/lib/hudActions.js b/src/lib/hudActions.js
index 5579f1c1..efb51344 100644
--- a/src/lib/hudActions.js
+++ b/src/lib/hudActions.js
@@ -115,6 +115,10 @@ export const HUD_ACTIONS = [
// entry calls, so the two ways a game is reset are one function.
{ key: 'resetgame', label: 'Reset the game', group: 'Game', role: 'press', node: 'setgamestate', data: { state: 'menu', reset: true }, handle: 'trigger', hint: 'Back to the menu AND the round clock to zero โ collectibles read un-collected again.' },
{ key: 'setvar', label: 'Set a variable', group: 'Game', role: 'press', node: 'setvariable', data: { name: 'score', op: 'add', value: 1 }, handle: 'trigger', hint: 'Add to, subtract from or set a shared number.' },
+ // 30 P4: keep the best on THIS device. The value is fed from the `score` variable by a
+ // `via` source (additive to the press role: absent everywhere else, so every other
+ // press action builds exactly what it built before).
+ { key: 'savebest', label: 'Save best score', group: 'Game', role: 'press', node: 'storevalue', data: { key: 'best', mode: 'max' }, handle: 'trigger', via: { node: 'getvariable', data: { name: 'score' }, handle: 'value' }, hint: 'Keeps the highest โscoreโ seen on this device (Store Value, max) โ each player keeps their own.' },
// 21-F4: LEVEL COMPLETE โ travel to a level. The destination is the AUTHOR'S PICK
// on the Travel card, deliberately not a "next by folder order": the Explorer
// library is LOCAL, so two peers can hold different orders and a computed "next"
@@ -309,6 +313,10 @@ export function describeNode(node, handle = null) {
return 'Only once';
case 'getvariable':
return 'Variable โ' + (d.name ?? '') + 'โ';
+ case 'storevalue':
+ return 'Save โ' + (d.key ?? '') + 'โ on this device (' + (d.mode ?? 'set') + ')';
+ case 'storedvalue':
+ return 'Saved โ' + (d.key ?? '') + 'โ (this device)';
case 'gametime':
return 'Round time (' + (d.read ?? 'elapsed') + ')';
case 'hudtext':
@@ -521,6 +529,13 @@ export function addBinding(elementId, actionKey) {
const actionNode = makeNode(action.node, baseX + 220, baseY, action.data);
created.push(actionNode);
createdEdges.push(makeEdge(press, actionNode, action.handle));
+ // 30 P4: a press action may name the VALUE it acts on too (Save best score reads
+ // the score) โ the drives role's `via`, one socket over
+ if (action.via) {
+ const source = makeNode(action.via.node, baseX, baseY + 120, action.via.data);
+ created.push(source);
+ createdEdges.push(makeEdge(source, actionNode, action.via.handle));
+ }
}
} else {
const displayType = /** @type {any} */ (DISPLAY_NODE)[String(action.role === 'drives' ? currentKindOf(elementId) : '')] ?? 'hudtext';
diff --git a/src/lib/hudDocs.js b/src/lib/hudDocs.js
index 8b38b473..81c83f03 100644
--- a/src/lib/hudDocs.js
+++ b/src/lib/hudDocs.js
@@ -21,7 +21,7 @@
// computes the same string with no message of its own. Screen visibility is per-peer ON
// PURPOSE โ one player on the start menu while another plays.
-import { writable, get } from 'svelte/store';
+import { writable, derived, get } from 'svelte/store';
import { sessionNow } from './sessionClock'; // 25-E: stamps another peer compares
// 21-D1: the kind REGISTRY. hudKinds imports nothing, so this stays a leaf.
import { HUD_KINDS as REGISTERED_KINDS, defaultsForKind, styleDefaultsForKind, kindDef } from './hudKinds';
@@ -75,6 +75,28 @@ export const activeHudDoc = writable(null);
* @type {import('svelte/store').Writable>} */
export const hudScreenOverride = writable({});
+/**
+ * 30 P1: IS THIS SCENE A GAME? The rule, and why it is this one: a scene is a game when ANY
+ * of its HUD documents holds a screen bound to a game state (`showWhile` set). That binding
+ * is the one thing that makes the editor MASQUERADE as the running game โ the shared state
+ * picks the screen, so a menu whose state is `menu` paints itself over the editor with
+ * nobody in Play. Every Games-tab template has one; a hand-made HUD with no state-bound
+ * screen is a plain overlay and keeps its old behaviour (drawn in the editor, buttons live).
+ * A `hudbutton -> setgamestate` wiring alone was considered and NOT taken: with no
+ * state-bound screen nothing masquerades, and reading the graph here would put a flow edge
+ * into a leaf that keeps none.
+ * @param {Record} docs @returns {boolean}
+ */
+export function isGameHud(docs) {
+ for (const doc of Object.values(docs ?? {}))
+ for (const screen of Array.isArray(doc?.screens) ? doc.screens : [])
+ if (typeof screen?.showWhile === 'string' && screen.showWhile) return true;
+ return false;
+}
+
+/** 30 P1: the rule above as a store โ HudLayer and the game chip both read it. */
+export const hudIsGame = derived(hudDocs, (docs) => isGameHud(docs));
+
/** The editor's current selection, per document key -> element ids. LOCAL.
* @type {import('svelte/store').Writable>} */
export const hudSelection = writable({});
diff --git a/src/lib/knock.js b/src/lib/knock.js
index 0775625a..9544f88b 100644
--- a/src/lib/knock.js
+++ b/src/lib/knock.js
@@ -1,6 +1,6 @@
import * as THREE from 'three';
import { writable, get } from 'svelte/store';
-import { isLocked, isVRMode, objectsGroup } from '../stores/sceneStore';
+import { isLocked, isVRMode, objectsGroup, editorMode } from '../stores/sceneStore';
import { peers } from '../stores/appStore';
import { sceneKnock } from './scenePhysics';
import { sessionNow } from './sessionClock'; // 25-E: `at` crosses the wire, so it is SESSION time
@@ -141,7 +141,10 @@ function armed() {
const cfg = get(sceneKnock);
if (!cfg?.enabled) return false;
if (!get(simulating) && !get(remoteSimulating)) return false;
- return get(isLocked) === true || get(isVRMode) === true;
+ // 30b P2: in VR only a PLAYER's hands knock โ an Edit hand is placing things, and a
+ // knock would fling the object it is reaching for (contract C1: grips grab/knock in
+ // Interact/Play; Edit moves things as it always has)
+ return get(isLocked) === true || (get(isVRMode) === true && get(editorMode) === 'interact');
}
/** @param {number} now @param {any} group */
diff --git a/src/lib/locomotionPolicy.js b/src/lib/locomotionPolicy.js
new file mode 100644
index 00000000..e59afbd8
--- /dev/null
+++ b/src/lib/locomotionPolicy.js
@@ -0,0 +1,114 @@
+// 30b P3/P4: HOW A PLAYER MAY MOVE, and WHERE THEY START โ a pure leaf (imports nothing),
+// so the rule table and the spawn maths are unit-tested with no headset.
+//
+// Contract C1: EDIT keeps the editor's movement (fly, teleport, the world gestures).
+// INTERACT and PLAY walk like a game โ the stick walks, a capsule stops at walls, gravity
+// pulls you down, a ~0.3 m step is climbed, snap turn stays โ and NEITHER fly NOR teleport
+// 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."
+
+/**
+ * @param {'edit' | 'interact'} mode
+ * @param {{teleport?: boolean, fly?: 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) {
+ if (mode !== 'interact') {
+ // the editor: movement exactly as it has always been, walls included
+ return { walk: false, fly: true, teleport: true, collide: false, gravity: false, worldGestures: true };
+ }
+ const fly = locomotion?.fly === true;
+ return {
+ walk: true,
+ fly,
+ teleport: locomotion?.teleport === true,
+ collide: true,
+ gravity: !fly,
+ worldGestures: false
+ };
+}
+
+/**
+ * `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}
+ */
+export function normalizeLocomotion(raw) {
+ if (!raw || typeof raw !== 'object') return null;
+ /** @type {{teleport?: boolean, fly?: boolean}} */
+ const out = {};
+ if (typeof raw.teleport === 'boolean') out.teleport = raw.teleport;
+ if (typeof raw.fly === 'boolean') out.fly = raw.fly;
+ return Object.keys(out).length ? out : null;
+}
+
+const SPAWN_LIMIT = 100000;
+
+/**
+ * A spawn point at a store/api boundary: `{position: [x, y, z], yaw}` with finite numbers
+ * (y is the FEET, yaw in radians, three's rotation.y: 0 faces -Z). Accepts the api's
+ * `(position, yaw)` pair too. Anything else is null.
+ * @param {any} raw @param {any} [yawArg]
+ * @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
+ 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);
+ if (!p.every((v) => Number.isFinite(v) && Math.abs(v) <= SPAWN_LIMIT)) return null;
+ const yawRaw = Array.isArray(raw) ? yawArg : raw?.yaw;
+ const yaw = Number(yawRaw ?? 0);
+ return {
+ position: /** @type {[number, number, number]} */ (p),
+ yaw: Number.isFinite(yaw) ? yaw : 0,
+ // 30b (core-games): a VR-ONLY spawn โ the headset stands on it, desktop Play and
+ // Interact keep their own camera (the Jam Room puts a VR player INSIDE the band, where
+ // a level desktop eye would see only the piano). Additive, kept only when true.
+ ...(!Array.isArray(raw) && raw?.vrOnly === true ? { vrOnly: true } : {})
+ };
+}
+
+/** forward (the direction you face) for a yaw: (-sin yaw, 0, -cos yaw) @param {number} yaw */
+export function yawForward(yaw) {
+ return { x: -Math.sin(yaw), y: 0, z: -Math.cos(yaw) };
+}
+
+/**
+ * The yaw you face from a forward vector (the inverse of yawForward; y is ignored).
+ * @param {{x: number, z: number}} dir
+ */
+export function yawOf(dir) {
+ return Math.atan2(-dir.x, -dir.z);
+}
+
+/**
+ * THE VR SPAWN as two XR reference-space offsets (WebXR: a pose in the NEW space is
+ * `inverse(originOffset) * pose in the old one`, so an offset rotating by `a` turns the
+ * viewer by `-a`, and one translating by `t` moves the viewer by `-t`).
+ * 1. `turn`: rotate about the viewer's head so they face `yaw` (the snap-turn shape),
+ * 2. `move`: translate so the FEET land on the spawn point.
+ * @param {{x: number, y: number, z: number}} head the head in the current space
+ * @param {number} headYaw the yaw the head currently faces
+ * @param {number} headHeight the head's height above the physical floor
+ * @param {{position: number[], yaw: number}} spawn
+ */
+export function vrSpawnOffsets(head, headYaw, headHeight, spawn) {
+ const a = headYaw - spawn.yaw; // the viewer turns by -a = spawn.yaw - headYaw
+ const s = Math.sin(a);
+ const c = Math.cos(a);
+ const turn = {
+ angle: a,
+ position: { x: head.x - (c * head.x + s * head.z), y: 0, z: head.z - (-s * head.x + c * head.z) },
+ orientation: { x: 0, y: Math.sin(a / 2), z: 0, w: Math.cos(a / 2) }
+ };
+ const feet = head.y - headHeight;
+ const move = {
+ x: head.x - spawn.position[0],
+ y: feet - spawn.position[1],
+ z: head.z - spawn.position[2]
+ };
+ return { turn, move };
+}
diff --git a/src/lib/moduleContent.js b/src/lib/moduleContent.js
new file mode 100644
index 00000000..580d76e9
--- /dev/null
+++ b/src/lib/moduleContent.js
@@ -0,0 +1,222 @@
+// 30 P3: MODULE CONTENT in the object list โ a LEAF (THREE + svelte/store + sceneStore +
+// helperLayer). moduleSDK writes the registry, the object list reads it, objectActions
+// clears the selection; none of them may import each other through here, which is why
+// it is its own file (moduleSDK primes objectActions DYNAMICALLY to dodge that cycle, and
+// a registry living in either one would close it).
+//
+// THE FINDING (roadmap 30, "Selection and the object list", cause 3): scene-root module
+// content โ the dungeon, the untangle board, the piano, the sabers โ lives outside
+// `objectsGroup` by golden rule 5, so it was never picked and never LISTED. Only the
+// advanced System filter polled `systemGroupNames`, and it named no module. Every module
+// group is listed now, READ-ONLY: the module owns it and regenerates it from its own
+// state, so a rename, a delete or a reparent here would be undone on the next rebuild
+// (or, worse, not undone and never replicated).
+//
+// Selecting one selects a PROXY, never the group: a scene-root box on the helper layer
+// sized to the group's bounds. It is not in objectsGroup, so it never replicates, never
+// saves and never enters the selection SET (applySelectionSet filters uuids it cannot
+// find there) โ the selection outline, the gizmo and every "act on the selection"
+// command stay exactly what they were.
+import * as THREE from 'three';
+import { writable, get } from 'svelte/store';
+import { globalScene } from '../stores/sceneStore';
+import { markHelper } from './helperLayer';
+
+/** named children listed per group, and how deep */
+export const LIST_CAP = 200;
+export const LIST_DEPTH = 2;
+
+/**
+ * @typedef {{ name: string, moduleId: string, moduleName: string, label: string,
+ * icon: string | null, kinds: Set }} ModuleGroup
+ */
+
+/** @type {Map} scene-root group name -> who registered it */
+const groups = new Map();
+
+/** bumps on every registry change so the list re-derives */
+export const moduleGroupsRevision = writable(0);
+
+/**
+ * Record a module's scene-root group. `kind` is which register* call named it
+ * ('interactive' | 'system' | 'listed'); the row lives while any kind still holds it.
+ * @param {string} name @param {{id: string, name?: string}} owner @param {string} kind
+ * @param {{label?: string, icon?: string}} [opts]
+ */
+export function noteModuleGroup(name, owner, kind, opts = {}) {
+ if (!name || !owner?.id) return;
+ const entry = groups.get(name) ?? {
+ name,
+ moduleId: owner.id,
+ moduleName: owner.name || owner.id,
+ label: '',
+ icon: null,
+ kinds: new Set()
+ };
+ entry.kinds.add(kind);
+ if (opts.label) entry.label = String(opts.label).slice(0, 80);
+ if (opts.icon) entry.icon = String(opts.icon);
+ groups.set(name, entry);
+ moduleGroupsRevision.update((n) => n + 1);
+}
+
+/** @param {string} name @param {string} kind */
+export function forgetModuleGroup(name, kind) {
+ const entry = groups.get(name);
+ if (!entry) return;
+ entry.kinds.delete(kind);
+ if (kind === 'listed') {
+ entry.label = '';
+ entry.icon = null;
+ }
+ if (!entry.kinds.size) groups.delete(name);
+ if (get(moduleSelection)?.name === name && !groups.has(name)) clearModuleSelection();
+ moduleGroupsRevision.update((n) => n + 1);
+}
+
+/** every registered module group, in a stable order (module, then name) */
+export function moduleGroupList() {
+ return [...groups.values()].sort(
+ (a, b) => a.moduleName.localeCompare(b.moduleName) || a.name.localeCompare(b.name)
+ );
+}
+
+/** @param {string} name */
+export function moduleGroupOf(name) {
+ return groups.get(name) ?? null;
+}
+
+/** A group name is an id ('untangle-module'); until the module names it with
+ * registerListedGroup, show it the way a person would write it ('Untangle module').
+ * @param {string} name */
+export function humanize(name) {
+ const words = String(name).replace(/[-_]+/g, ' ').trim();
+ return words ? words[0].toUpperCase() + words.slice(1) : String(name);
+}
+
+/**
+ * The rows the list draws: one per registered group PRESENT in the scene, with its named
+ * descendants down to LIST_DEPTH, capped at LIST_CAP (the rest counted, not dropped
+ * silently). A group a module registered but has not built yet is not listed โ there is
+ * nothing to frame or select.
+ * @param {any} scene
+ */
+export function moduleContentRows(scene) {
+ if (!scene) return [];
+ /** @type {any[]} */
+ const rows = [];
+ for (const entry of moduleGroupList()) {
+ const root = scene.getObjectByName(entry.name);
+ // 30b P5: a registered group is re-homed under the world rig's module root
+ // (moduleWorld.js โ named, not imported, because that leaf imports this one)
+ if (!root || (root.parent !== scene && root.parent?.name !== 'module-world-root')) continue;
+ /** @type {{name: string, depth: number, uuid: string}[]} */
+ const children = [];
+ let total = 0;
+ /** @param {any} node @param {number} depth */
+ const walk = (node, depth) => {
+ for (const child of node.children ?? []) {
+ if (child.userData?.isModuleProxy) continue;
+ if (child.name) {
+ total++;
+ if (children.length < LIST_CAP) children.push({ name: child.name, depth, uuid: child.uuid });
+ }
+ if (depth < LIST_DEPTH) walk(child, depth + 1);
+ }
+ };
+ walk(root, 1);
+ rows.push({
+ name: entry.name,
+ label: entry.label || humanize(entry.name),
+ moduleId: entry.moduleId,
+ moduleName: entry.moduleName,
+ icon: entry.icon,
+ object: root,
+ visible: root.visible !== false,
+ children,
+ more: Math.max(0, total - children.length)
+ });
+ }
+ return rows;
+}
+
+// --- the selection: a proxy, never the group ----------------------------------------
+
+/** @type {import('svelte/store').Writable<{name: string, moduleId: string, moduleName: string, label: string} | null>} */
+export const moduleSelection = writable(null);
+
+/** @type {any} */
+let proxy = null;
+const box = new THREE.Box3();
+let lastFit = 0;
+
+/** @param {any} root */
+function fitProxy(root) {
+ if (!proxy || !root) return false;
+ box.makeEmpty();
+ // bounds of the GROUP only โ the proxy is a scene-root sibling, never inside it
+ box.setFromObject(root);
+ if (box.isEmpty()) {
+ const at = root.getWorldPosition(new THREE.Vector3());
+ box.setFromCenterAndSize(at, new THREE.Vector3(0.5, 0.5, 0.5));
+ }
+ proxy.box.copy(box);
+ proxy.updateMatrixWorld(true);
+ return true;
+}
+
+/**
+ * Select a module group: park the proxy around it and publish the selection. Returns the
+ * group's world bounds (the caller frames them), or null when the group is not in the scene.
+ * @param {string} name
+ */
+export function selectModuleGroup(name) {
+ const scene = /** @type {any} */ (get(globalScene));
+ const root = scene?.getObjectByName(name);
+ const entry = groups.get(name);
+ if (!root || !entry) return null;
+ if (!proxy) {
+ proxy = new THREE.Box3Helper(new THREE.Box3(), new THREE.Color(0xf59e0b));
+ proxy.name = 'module-content-proxy';
+ proxy.userData.isModuleProxy = true;
+ proxy.material.depthTest = false;
+ proxy.material.transparent = true;
+ proxy.renderOrder = 999;
+ markHelper(proxy);
+ }
+ if (proxy.parent !== scene) scene.add(proxy);
+ fitProxy(root);
+ moduleSelection.set({ name, moduleId: entry.moduleId, moduleName: entry.moduleName, label: entry.label || humanize(name) });
+ return box.clone();
+}
+
+export function clearModuleSelection() {
+ if (proxy?.parent) proxy.parent.remove(proxy);
+ if (get(moduleSelection)) moduleSelection.set(null);
+}
+
+/** Per frame (Scene's task): module content moves on its own, so the box follows it โ
+ * at a few Hz, because setFromObject walks the whole group. */
+export function tickModuleProxy() {
+ const selection = get(moduleSelection);
+ if (!selection || !proxy?.parent) return;
+ const now = performance.now();
+ if (now - lastFit < 250) return;
+ lastFit = now;
+ const root = proxy.parent.getObjectByName(selection.name);
+ if (!root) {
+ clearModuleSelection();
+ return;
+ }
+ fitProxy(root);
+}
+
+/** test/debug view */
+export function moduleContentDebug() {
+ return {
+ groups: moduleGroupList().map((g) => ({ name: g.name, moduleId: g.moduleId, label: g.label, kinds: [...g.kinds] })),
+ selection: get(moduleSelection),
+ proxyInScene: !!proxy?.parent,
+ proxyBox: proxy?.parent ? { min: proxy.box.min.toArray(), max: proxy.box.max.toArray() } : null
+ };
+}
diff --git a/src/lib/moduleSDK.js b/src/lib/moduleSDK.js
index c81f3631..9eacc612 100644
--- a/src/lib/moduleSDK.js
+++ b/src/lib/moduleSDK.js
@@ -2,7 +2,10 @@ import { keyOf, letterOf } from './keyOf';
import { sessionNow } from './sessionClock'; // 25-E: stamps another peer compares
import * as THREE from 'three';
import { writable, get } from 'svelte/store';
-import { globalScene, objectsGroup, selectedObject, selectedObjects, globalCamera, isVRMode, isLocked } from '../stores/sceneStore';
+import { globalScene, objectsGroup, selectedObject, selectedObjects, globalCamera, isVRMode, isLocked, playPointerFree, editorMode } from '../stores/sceneStore';
+// 30 P4: the scene's play block decides whether play aims with a crosshair (a leaf chain)
+// 30 integrate: the ONE answer to "is this a free-cursor game" (30-core-flow's leaf)
+import { playCursorSetting } from './playCursor';
import { peers, showToast, modulesOpen, userdata } from '../stores/appStore';
import { syncedAnimations, flowGraphs, flowValues, flowTriggers, allNodes, findNodeAnyGraph, SCENE_GRAPH } from '../stores/flowStore';
import { customGeometryBuilders } from './customGeometries';
@@ -42,7 +45,26 @@ import { APP_VERSION } from './version.js';
import { ndcFromClient } from './canvasRect';
// 27-B: recovery paths report through the diagnostics ring (hardening audit H4)
import { log } from './diagnostics';
+// 30 P3: the object list's Module content registry โ a LEAF, so no cycle
+import { noteModuleGroup, forgetModuleGroup } from './moduleContent';
+export { moduleContentDebug } from './moduleContent';
import { safeStorage } from './safeStorage';
+// 30 P4: api.storage โ a LEAF (safeStorage only), shared with the Store Value flow node
+import { makeModuleStorage } from './gameStorage';
+// 30b (vr-play) C5: the game sound set and game music โ LEAVES (svelte/store, audioEngine,
+// safeStorage, sessionClock, sceneStore), so static edges close no cycle
+import { isGameSound, playGameSound } from './gameSfx';
+import { playGameMusic, stopGameMusic, gameMusicState, MUSIC_PRESET_IDS } from './gameMusic';
+// 30b (vr-play): api.announce's banner store โ a LEAF (svelte/store only)
+import { announce as announceBanner, clearAnnouncement } from './gameAnnounce';
+/** the ping chimes `api.playSound` still reaches (pingAudio's PING_SOUNDS ids) */
+const PING_NAMES = new Set(['ding', 'chime', 'pluck', 'bell']);
+import { runtimeSpawn, setRuntimeSpawn } from './playSettings'; // 30b P4 (a leaf)
+import { spawnDesktopPlayer, currentSpawn, desktopSpawn, spawnEyePose } from './playSpawn'; // 30b P4 (a leaf)
+
+/** modules already told they hit the storage cap this session (ONE toast each, never
+ * one per write โ a game saving every frame would otherwise bury the screen) */
+const storageCapWarned = new Set();
// Module SDK v1 โ in-repo modules under src/modules// register through
// the api object passed to their register(api). See MODULES.md for the guide.
@@ -69,8 +91,76 @@ export const moduleMenuItems = writable([]);
export const moduleEffects = {};
/** @type {Record} node type -> Svelte component */
export const moduleNodeComponents = {};
-/** @type {((object: any) => boolean)[]} */
+/** 30b: a handler also gets `ctx = {source, mode}` (see runClickHandlers)
+ * @type {((object: any, ctx?: {source: string, mode: string}) => boolean)[]} */
export const moduleClickHandlers = [];
+
+/** 30 P1: the three places a viewport click can come from. */
+export const CLICK_MODES = ['edit', 'interact', 'play'];
+/** What an SDK handler hears when it names no modes: Interact and Play, NOT Edit โ
+ * the behaviour change the user asked for (a piano or a puzzle piece used to swallow
+ * every EDITOR click, so it could never be selected). */
+export const DEFAULT_CLICK_MODES = ['interact', 'play'];
+/** handler -> the modes it runs in. A handler with NO entry runs everywhere: that is a
+ * core handler pushed straight into the array (vrPatch's plug click โ patching a cable
+ * is authoring, so it must keep working in Edit), never an SDK one.
+ * @type {WeakMap} */
+const clickHandlerModes = new WeakMap();
+
+/** Normalise a `{modes}` option: known names only, the default when nothing usable is
+ * left. @param {any} modes @returns {string[]} */
+export function normalizeClickModes(modes) {
+ const list = Array.isArray(modes) ? modes : typeof modes === 'string' ? [modes] : [];
+ const known = [...new Set(list.filter((mode) => CLICK_MODES.includes(mode)))];
+ return known.length ? known : [...DEFAULT_CLICK_MODES];
+}
+
+/** 30b (C3): handlers that asked NOT to be swept (`{sweep: false}`) โ a knob you drag, a
+ * dot you carry. They still hear the press itself; only the later entries of a held
+ * trigger skip them. @type {WeakSet} */
+const noSweepHandlers = new WeakSet();
+
+/** Does this handler run for a click in `mode`? A null mode is VR's trigger, which has
+ * no editor mode of its own yet and keeps offering every handler, as it always has.
+ * @param {Function} fn @param {string | null} mode */
+export function clickHandlerRunsIn(fn, mode) {
+ if (!mode) return true;
+ const modes = clickHandlerModes.get(fn);
+ return !modes || modes.includes(mode);
+}
+
+/**
+ * Offer a clicked mesh to every handler that runs in `mode`, in registration order; the
+ * first to return true consumes the click. ONE dispatch for the editor's pick, Interact
+ * and Play's tap, so the three can never disagree about who hears what.
+ *
+ * 30b (C3): every handler also gets `ctx = {source, mode}` โ `source` is 'click' (the
+ * desktop / a VR release), 'trigger' (the VR press itself, fired on the press) or 'sweep'
+ * (a later entry while the trigger is held). A 'sweep' skips handlers registered with
+ * `{sweep: false}`. Additive: a one-argument handler is byte-unchanged.
+ * @param {any} object @param {string | null} mode @param {{source?: string}} [ctx]
+ * @returns {boolean}
+ */
+export function runClickHandlers(object, mode, ctx = {}) {
+ const source = ctx?.source ?? 'click';
+ const info = { source, mode: mode ?? 'vr' };
+ for (const handler of [...moduleClickHandlers]) {
+ if (!clickHandlerRunsIn(handler, mode)) continue;
+ if (source === 'sweep' && noSweepHandlers.has(handler)) continue;
+ try {
+ if (handler(object, info)) return true;
+ } catch (error) {
+ log('warn', 'module', 'click handler failed', String(error));
+ }
+ }
+ return false;
+}
+
+/** The modes a handler was registered with (tests / the debug view).
+ * @param {Function} fn @returns {string[] | null} */
+export function clickHandlerModesOf(fn) {
+ return clickHandlerModes.get(fn) ?? null;
+}
/** 23-B1: a viewport click that hit NOTHING. `moduleClickHandlers` is only ever handed a
* MESH, so a gesture armed by a plug click had no way to hear "the user clicked the sky":
* the wire stayed armed for the rest of the session and a picked-up cable stayed HIDDEN
@@ -111,6 +201,9 @@ const sceneClearHandlers = [];
/** Called by the clear-scene path (local and remote) */
export function runSceneClearHandlers() {
+ // 30b: a cleared scene takes its game's bursts and banner with it
+ effectsRef?.clearBursts?.();
+ clearAnnouncement();
sceneClearHandlers.forEach((fn) => {
try {
fn();
@@ -188,6 +281,8 @@ let nodeCatalogRef = null;
let knockRef = null;
/** @type {Promise} */
let knockReady = Promise.resolve(null);
+/** 30b: primed for api.effects (effectsBurst imports moduleFrameTasks from here) @type {any} */
+let effectsRef = null;
if (typeof window !== 'undefined') {
knockReady = import('./knock').then((m) => (knockRef = m));
import('./inputRuntime').then((m) => (inputRuntimeRef = m));
@@ -208,6 +303,8 @@ if (typeof window !== 'undefined') {
import('./flowRuntime').then((m) => (flowRuntimeRef = m));
import('./flowGraphs').then((m) => (flowGraphsRef = m));
import('./nodeCatalog').then((m) => (nodeCatalogRef = m));
+ // 30b (C6): the burst pool reads moduleFrameTasks from here, so the edge back is dynamic
+ import('./effectsBurst').then((m) => (effectsRef = m));
}
// --- api.pointerRay (190): where the user is POINTING, as a world ray --------
@@ -228,11 +325,38 @@ if (typeof window !== 'undefined') {
pointerClient.seen = true;
});
}
+/** 30 P4: the CROSSHAIR ray, a fresh Raycaster through the centre of the view */
+const SCREEN_CENTRE = new THREE.Vector2(0, 0);
+
+/**
+ * 30 P4: does play aim with a CROSSHAIR right now? Under a pointer lock the cursor is
+ * pinned and its last client position is where the mouse happened to be when the lock
+ * began โ a STALE ray that never moves again (untangle's carried dot followed it, so a
+ * drag in play went nowhere). So while playing, the pointer locked, and not in the menu
+ * substate (the pointer is free there, over the HUD), the ray is the view's centre โ
+ * play mode's own NDC (0,0), the one playInteract aims with.
+ */
+function crosshairAims() {
+ if (get(isLocked) !== true || get(playPointerFree)) return false;
+ if (typeof document === 'undefined' || !document.pointerLockElement) return false;
+ // 30-core-flow: free cursor โ a scene whose play block says `cursor: 'free'` (or a
+ // module publishing it through userData.play) plays with the real cursor and no lock,
+ // so its ray IS the mouse ray. Asked through playCursor, the leaf playInteract, PLC and
+ // PlayReticle ask too, so the four cannot disagree about where the player aims.
+ return playCursorSetting() !== 'free';
+}
+
function pointerRayNow() {
if (get(isVRMode)) return vrControlsRef?.pointerHandRay?.() ?? null;
/** @type {any} */
const camera = get(globalCamera);
- if (!camera || !pointerClient.seen) return null;
+ if (!camera) return null;
+ if (crosshairAims()) {
+ const centre = new THREE.Raycaster();
+ centre.setFromCamera(SCREEN_CENTRE, camera);
+ return centre;
+ }
+ if (!pointerClient.seen) return null;
const fresh = new THREE.Raycaster();
const ndc = ndcFromClient(pointerClient.x, pointerClient.y);
fresh.setFromCamera(new THREE.Vector2(ndc.x, ndc.y), camera);
@@ -251,6 +375,30 @@ function physicsApi() {
return physicsRef;
}
+/**
+ * 30b P4: move the player to the spawn in force โ VR moves the rig (vrControls), desktop
+ * Play moves the play camera, desktop Interact the editor view. Edit is never moved.
+ * @returns {boolean}
+ */
+function respawnPlayerNow() {
+ const spawn = currentSpawn();
+ if (!spawn) return false;
+ if (get(isVRMode)) {
+ if (get(editorMode) !== 'interact') return false;
+ return !!vrControlsRef?.spawnPlayer?.();
+ }
+ // 30b (core-games): a VR-only spawn leaves the desktop view where it is
+ const desk = desktopSpawn();
+ if (!desk) return false;
+ if (get(isLocked) === true) return spawnDesktopPlayer(desk);
+ if (get(editorMode) === 'interact') {
+ const { eye, lookAt } = spawnEyePose(desk);
+ objectActionsRef?.flyTo?.(eye, lookAt);
+ return !!objectActionsRef;
+ }
+ return false;
+}
+
/** @param {string} moduleId @param {string} [moduleName] the DISPLAY name, needed while
* register() runs: loadedModules is not appended until it RETURNS, so anything reading the
* name from there during registration gets the raw id (which is how a module HUD kind was
@@ -259,6 +407,8 @@ function makeApi(moduleId, moduleName = moduleId) {
const disposals = (moduleDisposals[moduleId] ??= []);
/** record an undo thunk deactivateModule runs at teardown (A2) @param {() => void} fn */
const onDispose = (fn) => disposals.push(fn);
+ /** 30b P4: setSpawn journals its clear once per module */
+ let spawnDisposeHooked = false;
/** A value frozen for the undo stack, so a module mutating its patch object later
* cannot rewrite history. @param {any} v */
const frozen = (v) => {
@@ -463,9 +613,24 @@ function makeApi(moduleId, moduleName = moduleId) {
/**
* Intercept viewport clicks (desktop click + VR trigger). Receives the
* exact mesh hit; return true to consume the click (no selection).
- * @param {(object: any) => boolean} fn
+ *
+ * 30 P1: `{modes}` says WHERE it runs โ any of 'edit' | 'interact' | 'play'.
+ * Absent means ['interact', 'play']: a handler that is part of the GAME (a key,
+ * a pad, a puzzle piece) no longer eats the editor's select click. A handler
+ * that is an editor TOOL (a toolbox pick) passes {modes: ['edit']}, or all three.
+ * In Edit an 'edit' handler still runs BEFORE the selection, so it can consume.
+ *
+ * 30b: `fn(object, ctx)` โ `ctx.source` is 'click', 'trigger' (the VR press) or
+ * 'sweep' (VR, Interact/Play: the trigger HELD and the controller tip or laser
+ * passing into this mesh โ each entry clicks once, re-armed when it leaves). Pass
+ * `{sweep: false}` for a control that must not be swept (a knob you drag, a dot you
+ * carry); it still hears the press.
+ * @param {(object: any, ctx?: {source: string, mode: string}) => boolean} fn
+ * @param {{modes?: string[], sweep?: boolean}} [options]
*/
- registerClickHandler(fn) {
+ registerClickHandler(fn, options = {}) {
+ clickHandlerModes.set(fn, normalizeClickModes(options?.modes));
+ if (options?.sweep === false) noSweepHandlers.add(fn);
moduleClickHandlers.push(fn);
onDispose(() => arrayRemove(moduleClickHandlers, fn));
},
@@ -493,20 +658,37 @@ function makeApi(moduleId, moduleName = moduleId) {
registerInteractiveGroup(name) {
moduleInteractiveGroups.push(name);
registerSystemGroup(name); // clickable module content is also listable
+ noteModuleGroup(name, { id: moduleId, name: moduleName }, 'interactive'); // 30 P3
onDispose(() => {
arrayRemove(moduleInteractiveGroups, name);
arrayRemove(systemGroupNames, name);
+ forgetModuleGroup(name, 'interactive');
removeSceneRootGroup(name); // module-owned viewport content goes with the module
});
},
/** List a scene-root group under the object list's System filter @param {string} name */
registerSystemGroup(name) {
registerSystemGroup(name);
+ noteModuleGroup(name, { id: moduleId, name: moduleName }, 'system'); // 30 P3
onDispose(() => {
arrayRemove(systemGroupNames, name);
+ forgetModuleGroup(name, 'system');
removeSceneRootGroup(name);
});
},
+ /**
+ * 30 P3: list a scene-root group in the object list's "Module content" section under
+ * a label a person can read (the group's own name is usually an id). Groups passed to
+ * registerInteractiveGroup / registerSystemGroup are listed anyway; this names them,
+ * or lists one that is neither. Read-only there: a click selects a PROXY and frames
+ * it, and the Inspector points at your module's toolbox and nodes.
+ * @param {string} name the scene-root group's object name
+ * @param {{label?: string, icon?: string}} [options]
+ */
+ registerListedGroup(name, options = {}) {
+ noteModuleGroup(name, { id: moduleId, name: moduleName }, 'listed', options ?? {});
+ onDispose(() => forgetModuleGroup(name, 'listed'));
+ },
/**
* Runs when the scene is cleared (locally or by a peer) โ remove your
* viewport content and reset module state here.
@@ -516,10 +698,55 @@ function makeApi(moduleId, moduleName = moduleId) {
sceneClearHandlers.push(fn);
onDispose(() => arrayRemove(sceneClearHandlers, fn));
},
+ /**
+ * 30 integrate (modules DEVX #35): the editor's click mode on THIS screen โ
+ * 'edit' | 'interact'. LOCAL and read-only; `isPlaying()` says whether Play is on
+ * top of it. A module whose own pointer listeners run outside core's click routing
+ * (untangle's drag) stands down while this reads 'edit' and nothing is playing, so an
+ * Edit click selects its content like any object.
+ * @returns {'edit' | 'interact'}
+ */
+ editorMode() {
+ return get(editorMode) === 'interact' ? 'interact' : 'edit';
+ },
+ /**
+ * 30b P4: where the player STARTS โ entering Interact or Play puts them here (VR: the
+ * rig so the FEET land on it facing `yaw`; desktop Play: the play camera; desktop
+ * Interact: the editor view). `position` is [x, y, z] with y the FEET height; `yaw`
+ * is radians, three's rotation.y (0 faces -Z, forward = (-sin yaw, 0, -cos yaw)).
+ * Overrides the scene's authored `play.spawn`. LOCAL: every peer's module sets its
+ * own from the same replicated state; never saved. `{teleport: true}` also moves the
+ * player there NOW when Interact or Play is on (a new level, a new dungeon floor) โ
+ * without it a changed spawn is a checkpoint, used on the next entry.
+ * `setSpawn(null)` clears it. Cleared when the module is disabled.
+ * @param {number[] | null} position @param {number=} yaw
+ * @param {{teleport?: boolean}=} options @returns {boolean} whether it was accepted
+ */
+ setSpawn(position, yaw = 0, options = {}) {
+ const ok = setRuntimeSpawn(position, yaw, moduleId);
+ if (!spawnDisposeHooked) {
+ spawnDisposeHooked = true;
+ onDispose(() => {
+ if (get(runtimeSpawn)?.owner === moduleId) setRuntimeSpawn(null);
+ });
+ }
+ if (ok && position && options?.teleport) respawnPlayerNow();
+ return ok;
+ },
+ /**
+ * 30b P4: move the player to the spawn in force now (see setSpawn) โ only while
+ * Interact or Play is on; the editor's Edit view is never moved.
+ * @returns {boolean} whether the player was moved
+ */
+ respawnPlayer() {
+ return respawnPlayerNow();
+ },
/**
* Where the user is POINTING, as a THREE.Raycaster in world space โ
* desktop mouse over the viewport, or the VR pointer hand's ray. A fresh
* instance per call (safe to keep). Null before the first pointer event.
+ * 30 P4: in play under a pointer lock it is the CROSSHAIR ray (the view's
+ * centre) โ the mouse ray is frozen there; a free-cursor game keeps the mouse.
* The drag recipe (190/untangle): click to pick, follow pointerRay() in a
* frame task, click to drop. (190)
*/
@@ -766,8 +993,20 @@ function makeApi(moduleId, moduleName = moduleId) {
* @param {'left'|'right'=} hand
*/
haptic(intensity = 0.5, durationMs = 50, hand = undefined) {
+ // 30b: silent in EDIT mode (core's own gate) โ vibration is for playing
vrControlsRef?.hapticPulse?.(intensity, durationMs, hand);
},
+ /**
+ * 30b: a named haptic PATTERN โ 'tap' (hover), 'bump' (a press), 'hit' (a grab,
+ * a contact), 'success', 'fail', 'rumble' (an engine, an explosion), 'heartbeat'.
+ * On one hand ('left'|'right') or both. LOCAL, Interact/Play only (false in Edit,
+ * on desktop nothing buzzes). Core already plays tap / bump / hit / knocks for
+ * you; use this for the game's own moments.
+ * @param {string} name @param {'left'|'right'=} hand @returns {boolean}
+ */
+ hapticPattern(name, hand = undefined) {
+ return vrControlsRef?.hapticPattern?.(String(name), hand) ?? false;
+ },
/**
* 24-A A2: every KNOCK this peer sees โ its own hand's, and every peer's as the
* `hit` message is applied โ as `{uuid, by, at, speed, point, linvel, angvel,
@@ -890,10 +1129,61 @@ function makeApi(moduleId, moduleName = moduleId) {
flyTo(position, lookAt) {
objectActionsRef?.flyTo(position, lookAt ?? position);
},
- /** A spatial UI chime (the ping sounds). LOCAL โ broadcast your own op if
- * peers should hear it too. @param {string=} sound @param {number[]=} position */
+ /**
+ * A sound, LOCAL to this device โ broadcast your own op if peers should hear it.
+ * 30b: the GAME set (procedural, no assets, the "Game sounds" volume): 'click',
+ * 'pop', 'whoosh', 'success', 'fail', 'hit', 'kick', 'shoot', 'laser',
+ * 'explosion', 'coin', 'levelup', 'goal', 'whistle', 'cheer', 'step', 'ring',
+ * 'sparkle', 'hurt', 'portal'; plus the ping chimes 'ding' (the default),
+ * 'chime', 'pluck', 'bell'. An unknown name is a quiet no-op. `position`
+ * spatialises it. Returns whether a sound started.
+ * @param {string=} sound @param {number[]=} position @returns {boolean}
+ */
playSound(sound = 'ding', position = undefined) {
- pingAudioRef?.playPing(sound, position ?? null);
+ const name = String(sound ?? 'ding');
+ if (isGameSound(name)) return playGameSound(name, position ?? null);
+ if (!PING_NAMES.has(name)) return false;
+ pingAudioRef?.playPing(name, position ?? null);
+ return !!pingAudioRef;
+ },
+ /**
+ * 30b: game MUSIC โ procedural loops, LOCAL to this device, tempo-synced to the
+ * session clock (two peers on one preset hear the same bar), under the effects
+ * and on the "Music" volume. Plays only in Interact/Play (a call from Edit returns
+ * false) and stops by itself when the player leaves the game.
+ * Presets: 'arcade', 'ambient', 'dungeon', 'stadium', 'space', 'puzzle', 'studio'.
+ */
+ /**
+ * 30b (C6): a short, pooled particle BURST at a world position โ 'sparkle' (the
+ * default), 'confetti', 'smoke' or 'sparks', an optional CSS colour and a count
+ * (1..96). LOCAL: broadcast your own op if peers should see it too. Returns
+ * whether a burst started.
+ * @param {number[]} position @param {{kind?: string, color?: string, count?: number}=} options
+ * @returns {boolean}
+ */
+ effects: {
+ burst: (/** @type {number[]} */ position, /** @type {{kind?: string, color?: string, count?: number}} */ options = {}) =>
+ !!effectsRef?.burst?.(position, options ?? {}),
+ kinds: () => ['sparkle', 'confetti', 'smoke', 'sparks']
+ },
+ /**
+ * 30b: a BIG centred banner โ "GOAL!", "Level 3", "Ring 2 reached" โ on the desktop
+ * HUD and, in a headset, head-locked in front of the player. `sub` is a second,
+ * smaller line; `ms` how long it stays (300..15000, default 1800); `color` the
+ * title's colour. A new banner replaces the one showing. LOCAL. Returns its id.
+ * @param {string} text @param {{sub?: string, ms?: number, color?: string}=} options
+ * @returns {number}
+ */
+ announce(text, options = {}) {
+ return announceBanner(text, options ?? {});
+ },
+ music: {
+ /** @param {string} preset @param {{volume?: number}=} options 0..1 @returns {boolean} */
+ play: (preset, options = {}) => playGameMusic(preset, options ?? {}),
+ stop: () => stopGameMusic(),
+ /** the preset playing now, or null @returns {string | null} */
+ current: () => get(gameMusicState)?.preset ?? null,
+ presets: () => [...MUSIC_PRESET_IDS]
},
/** Park the editor camera behind an object and follow it (the car's chase
* cam) โ LOCAL, no selection, no undo. @param {string} uuid */
@@ -1014,6 +1304,24 @@ function makeApi(moduleId, moduleName = moduleId) {
* immune to the shared-scope add race. Rows replicate on the presence channel,
* late joiners converge, a row drops with its owner's disconnect.
*/
+ /**
+ * 30 P4 (roadmap 30 fork 7): what this module remembers ON THIS DEVICE โ a best
+ * score, unlocked levels, a settings choice. JSON values under
+ * `tp:mod::` through safeStorage (never throws: a private window or a
+ * full quota falls back to memory for the session), 256 KB per module (a `set` over
+ * it returns false and says so ONCE), LOCAL: never replicated, never in a scene
+ * file, never undone โ and deliberately NOT cleared when the module is disabled or
+ * removed (a reinstall keeps your progress; `clear()` is the module's own reset).
+ * `get(key, fallback)` ยท `set(key, value) -> bool` ยท `remove(key)` ยท `keys()` ยท
+ * `clear()` ยท `bytes()`.
+ */
+ storage: makeModuleStorage(moduleId, {
+ onOverCap: () => {
+ if (storageCapWarned.has(moduleId)) return;
+ storageCapWarned.add(moduleId);
+ showToast(`"${moduleName}" hit its 256 KB storage limit on this device โ that value was not saved.`);
+ }
+ }),
peerVars: {
/** Write MY OWN row. @param {string} name @param {number} value */
setMine(name, value) {
diff --git a/src/lib/moduleWorld.js b/src/lib/moduleWorld.js
new file mode 100644
index 00000000..00025850
--- /dev/null
+++ b/src/lib/moduleWorld.js
@@ -0,0 +1,122 @@
+// 30b P5: MODULE CONTENT FOLLOWS THE WORLD โ a leaf (svelte/store + sceneStore +
+// moduleContent's registry).
+//
+// THE FINDING (the Quest report: "When I spin the world around, the untangled dots do not
+// spin around"). The VR world gestures (71: two-grip scale/rotate/pan) transform the
+// `world-grab-rig` group, and only what lives INSIDE it moves: objectsGroup, the grid, the
+// notes, the particles. A module's viewport content โ the Untangle board, the dungeon, the
+// piano โ lives at the SCENE ROOT (golden rule 5: never in objectsGroup, or it would enter
+// GLTF sync), so it stayed pinned to the room while the rest of the world spun away.
+//
+// THE FIX, for every module at once: a `module-world-root` group INSIDE the rig (a sibling
+// of objectsGroup, so golden rule 5 still holds โ nothing here is serialised or sent), and
+// every REGISTERED module group (registerInteractiveGroup / registerSystemGroup /
+// registerListedGroup โ the names the Module content list already knows) is re-homed
+// under it the moment it reaches the scene root. Its LOCAL transform is untouched, so on
+// the desktop (rig = identity) nothing moves at all, and in VR it rides the rig. A module
+// converting world hits with `group.worldToLocal` (Untangle does) keeps working unchanged,
+// because every matrix it reads is the true one.
+//
+// COMPATIBILITY, the two things a module can still do to "its scene-root group":
+// ยท `api.scene().remove(group)` โ Object3D.remove only removes DIRECT children, so the
+// scene instance gets a remove that also takes a re-homed group back out;
+// ยท `scene.getObjectByName(name)` โ a traversal, so it still finds it.
+// Core's own scene-root scans (playSettings.playPublishers, moduleContent's rows) read
+// `moduleWorldChildren()` as well as `scene.children`.
+// Content a module adds WITHOUT registering a name cannot be told apart from the app's own
+// scene-root helpers, so it stays where it was put.
+import { get } from 'svelte/store';
+import { globalScene } from '../stores/sceneStore';
+import { moduleGroupList, moduleGroupsRevision, moduleContentRows } from './moduleContent';
+
+export const MODULE_WORLD_ROOT = 'module-world-root';
+
+/** @type {any} */ let root = null;
+/** @type {any} */ let patchedScene = null;
+let started = false;
+
+/** the group inside the world rig that module content is re-homed under (null before mount) */
+export function moduleWorldRoot() {
+ return root;
+}
+
+/** the re-homed module groups (for scene-root scans that used to read scene.children) */
+export function moduleWorldChildren() {
+ return root ? root.children : [];
+}
+
+/** is `object` a module group at the top of the module world (or the scene root)? @param {any} object @param {any} scene */
+export function isModuleTopLevel(object, scene) {
+ return !!object && (object.parent === scene || (!!root && object.parent === root));
+}
+
+/** @param {any} object */
+function registeredName(object) {
+ if (!object?.name) return false;
+ return moduleGroupList().some((entry) => entry.name === object.name);
+}
+
+/** move every registered module group sitting at the scene root under the module root */
+export function adoptModuleGroups() {
+ const scene = /** @type {any} */ (get(globalScene));
+ if (!scene || !root) return 0;
+ let moved = 0;
+ for (const child of [...scene.children]) {
+ if (child === root || !registeredName(child)) continue;
+ // .add re-parents (three removes it from the scene first) and KEEPS the local
+ // transform โ which is the point: the group's numbers become rig-relative
+ root.add(child);
+ moved++;
+ }
+ return moved;
+}
+
+/** give the scene a remove() that also finds a re-homed module group @param {any} scene */
+function patchRemove(scene) {
+ if (!scene || patchedScene === scene) return;
+ patchedScene = scene;
+ const nativeRemove = scene.remove;
+ /** @param {...any} objects */
+ scene.remove = function (...objects) {
+ for (const object of objects) {
+ if (root && object?.parent === root) root.remove(object);
+ else nativeRemove.call(this, object);
+ }
+ return this;
+ };
+ // a registered group arriving later is re-homed as it lands (after the add returns โ
+ // never re-parent inside three's own add loop)
+ scene.addEventListener?.('childadded', (/** @type {any} */ event) => {
+ if (!registeredName(event.child)) return;
+ queueMicrotask(adoptModuleGroups);
+ });
+}
+
+/** Scene mounts the root inside the world rig. @param {any} group */
+export function setModuleWorldRoot(group) {
+ root = group;
+ if (root) root.name = MODULE_WORLD_ROOT;
+ start();
+ adoptModuleGroups();
+}
+
+function start() {
+ if (started || typeof window === 'undefined') return;
+ started = true;
+ globalScene.subscribe((scene) => {
+ patchRemove(scene);
+ adoptModuleGroups();
+ });
+ // a name registered AFTER its group was added (register order varies by module)
+ moduleGroupsRevision.subscribe(() => adoptModuleGroups());
+}
+
+/** test/debug view: what is re-homed, and what the object list's Module content lists */
+export function moduleWorldDebug() {
+ const scene = /** @type {any} */ (get(globalScene));
+ return {
+ root: !!root,
+ rehomed: moduleWorldChildren().map((/** @type {any} */ c) => c.name),
+ rows: moduleContentRows(scene).map((/** @type {any} */ row) => row.name)
+ };
+}
diff --git a/src/lib/nodeCatalog.js b/src/lib/nodeCatalog.js
index a06ac847..e03f8b05 100644
--- a/src/lib/nodeCatalog.js
+++ b/src/lib/nodeCatalog.js
@@ -14,6 +14,14 @@ import { GAME_STATES } from './gameState';
// svelte/store). Reaching them through inputRuntime instead would close a cycle:
// inputRuntime imports shortcuts, which reaches history.
import { GAMEPAD_BUTTONS, GAMEPAD_AXES } from './gamepadPrefs';
+// 30b (core-games): the Game Feel nodes' option lists come from the leaves that own them,
+// so a sound, a pattern or a preset added there reaches the palette with no second edit
+import { GAME_SOUNDS as GAME_SOUND_NAMES } from './gameSfx';
+import { HAPTIC_PATTERN_NAMES } from './hapticPatterns';
+import { MUSIC_PRESET_IDS } from './gameMusicPresets';
+/** Announce's value mark, spelled once OUTSIDE the catalog literals: a brace inside a node's
+ * strings breaks the brace-matching scan flow-node-docs reads this file with */
+const V_MARK = '{v}';
/**
* A1: `kind: 'text'` is a free-text param. It writes on COMMIT (change/blur),
@@ -214,6 +222,99 @@ export const nodeCatalog = [
defaults: { name: 'score', fallback: 0 },
params: [{ key: 'name', kind: 'text', placeholder: 'score', maxLength: 40 }]
},
+ // 30 P4 (roadmap 30 fork 7): what a game remembers ON THIS DEVICE โ a best score,
+ // a level reached. Keyed `tp:scene::` so a best belongs to its
+ // game. LOCAL by design: Store Value writes this peer's browser and sends
+ // nothing, so a REPLICATED trigger (an ordinary On Click) makes every peer save
+ // its own copy, while a per-player trigger saves only for the one who earned it.
+ {
+ type: 'storevalue',
+ label: 'Store Value',
+ defaults: { key: 'best', mode: 'max', value: 0 },
+ inputs: ['trigger', 'value'],
+ inputLabels: { value: 'value โ wire a Counter, a variable, a score' },
+ params: [
+ { key: 'key', kind: 'text', placeholder: 'best', maxLength: 60 },
+ { key: 'mode', kind: 'select', options: ['set', 'max', 'min', 'add'] }
+ ],
+ note: 'Saved on this device only โ never sent to other players, never in the scene file.'
+ },
+ {
+ type: 'storedvalue',
+ label: 'Stored Value',
+ defaults: { key: 'best', output: 'number', fallback: 0 },
+ params: [
+ { key: 'key', kind: 'text', placeholder: 'best', maxLength: 60 },
+ { key: 'output', kind: 'select', options: ['number', 'text'] }
+ ],
+ note: 'Reads what THIS device saved โ each player sees their own.'
+ },
+ // 30b (core-games): GAME FEEL โ the flow half of 30b-vr-play's module kit, so a
+ // graph-authored game (Towers, Stars Room, Jam Room) can say "Ring 2 reached",
+ // sparkle, chime, buzz and score itself. All LOCAL on every peer from the
+ // replicated trigger stamp (the storevalue / setcamera rule): no message of their
+ // own. Haptics and music keep the core's Interact/Play gate โ silent in Edit.
+ {
+ type: 'announce',
+ label: 'Announce',
+ defaults: { text: 'Level ' + V_MARK, sub: '', seconds: 1.8, color: '#ffd76a', decimals: 0 },
+ inputs: ['trigger', 'value'],
+ inputLabels: { value: 'value - fills ' + V_MARK + ' in the text' },
+ params: [
+ { key: 'text', kind: 'text', placeholder: 'Ring ' + V_MARK + ' reached', maxLength: 80 },
+ { key: 'sub', kind: 'text', placeholder: 'a smaller second line', maxLength: 120 },
+ { key: 'seconds', kind: 'range', min: 0.3, max: 15, step: 0.1 },
+ { key: 'color', kind: 'text', placeholder: '#ffd76a', maxLength: 20 }
+ ],
+ note: 'A big banner on every screen (and in VR) โ each player sees it from the same pulse.'
+ },
+ {
+ type: 'gamesound',
+ label: 'Game Sound',
+ defaults: { sound: 'coin' },
+ inputs: ['trigger', 'at'],
+ inputLabels: { at: 'at - an object (unwired = not placed)' },
+ params: [{ key: 'sound', kind: 'select', options: [...GAME_SOUND_NAMES] }],
+ note: 'Built-in sounds, no files โ played on each device, at the wired object when there is one.'
+ },
+ {
+ type: 'effectburst',
+ label: 'Effect Burst',
+ defaults: { kind: 'sparkle', color: '', count: 48, lift: 0 },
+ inputs: ['trigger', 'at'],
+ inputLabels: { at: 'at - an object (unwired = in front of the player)' },
+ params: [
+ { key: 'kind', kind: 'select', options: ['sparkle', 'confetti', 'smoke', 'sparks'] },
+ { key: 'count', kind: 'range', min: 4, max: 96, step: 1 },
+ { key: 'lift', kind: 'range', min: -2, max: 4, step: 0.1 },
+ { key: 'color', kind: 'text', placeholder: 'the kind\'s own colours', maxLength: 20 }
+ ],
+ note: 'A short particle burst, pooled โ any number of these costs nothing between pulses.'
+ },
+ {
+ type: 'hapticpulse',
+ label: 'Controller Buzz',
+ defaults: { pattern: 'success', hand: 'both' },
+ inputs: ['trigger'],
+ params: [
+ { key: 'pattern', kind: 'select', options: [...HAPTIC_PATTERN_NAMES] },
+ { key: 'hand', kind: 'select', options: ['both', 'left', 'right'] }
+ ],
+ note: 'VR controllers only, and only in Interact or Play โ the editor stays still.'
+ },
+ {
+ type: 'gamemusic',
+ label: 'Game Music',
+ defaults: { preset: 'arcade', volume: 0.7, while: 'always' },
+ inputs: ['on'],
+ inputLabels: { on: 'on - unwired = always' },
+ params: [
+ { key: 'preset', kind: 'select', options: [...MUSIC_PRESET_IDS] },
+ { key: 'volume', kind: 'range', min: 0, max: 1, step: 0.05 },
+ { key: 'while', kind: 'select', options: ['always', 'round'] }
+ ],
+ note: 'Plays while you are in Interact or Play (never in the editor); each device hears its own.'
+ },
// the round clock, derived from the shared startedAt stamp โ no clock of its own
{
type: 'gametime',
@@ -585,6 +686,9 @@ export const nodeCatalog = [
items: [
// 134: EVENT nodes โ ride small replicated trigger messages, not state
{ type: 'onclick', label: 'On Click', defaults: { pulse: 0.3 } },
+ // 30b (core-games): a player picked the object up โ desktop play/Interact carry or
+ // a VR Interact grip (never an editor move). Towers' crates sound on the lift.
+ { type: 'ongrab', label: 'On Grab', defaults: { pulse: 0.3 } },
// H3: keyboard trigger โ LOCAL key presses replicate as trigger pulses
// (golden rule: never stream local state); held keys re-pulse so the
// output stays high while held
diff --git a/src/lib/nodeDocs.js b/src/lib/nodeDocs.js
index df0afa02..a73658e3 100644
--- a/src/lib/nodeDocs.js
+++ b/src/lib/nodeDocs.js
@@ -35,6 +35,14 @@ export const NODE_DOCS = {
allplayers: "Iterates the connected players so a per-player value can be read, or a HUD row shown, for each of them.",
setvariable: "Stores a named value in the replicated scene variables when a pulse arrives - shared state without an object.",
getvariable: "Reads a named scene variable as a value - the read side of Set Variable, live on every peer.",
+ storevalue: "Saves a value on THIS device when a pulse arrives (set, keep the max or min, or add) - a best score or a level reached that survives a reload; never sent to other players.",
+ storedvalue: "Reads what Store Value saved on this device, as a number or text - the read side of a best score; each player sees their own.",
+ ongrab: "Fires a pulse when a player picks the object up - a carry in Play or Interact, or a VR grip in Interact; editor moves never fire it.",
+ announce: "Shows a big banner - 'Ring 2 reached', 'GOAL!' - on every screen and in VR when a pulse arrives; {v} in the text shows a wired number.",
+ gamesound: "Plays one of the built-in game sounds (coin, ring, levelup, cheer...) when a pulse arrives, at the wired object if one is wired; no sound files needed.",
+ effectburst: "Fires a short sparkle, confetti, smoke or sparks burst at the wired object (or in front of the player) when a pulse arrives; pooled and local to each device.",
+ hapticpulse: "Buzzes the VR controllers with a named pattern (tap, bump, success...) when a pulse arrives - only in Interact or Play, never while editing.",
+ gamemusic: "Plays a looping built-in music preset while you are in Interact or Play - always, or only during a round; wire 'on' to switch it by a condition.",
gametime: "Seconds since the game (or the round) started, the same on every peer - a clock that pauses with the game.",
peervariable: "A value kept PER PLAYER (score, lives, team) - each peer reads its own row, or a named player's.",
charcontroller: "Turns the connected object into a walking, jumping character driven by the player's movement input.",
@@ -70,6 +78,7 @@ export const NODE_DOCS = {
onclick: "Fires a short pulse when its object is clicked - the bridge from user input into the graph.",
keypress: "Fires a pulse while a keyboard key is pressed - the bridge from your keyboard into the graph.",
onimpact: "Fires a pulse when a physics simulation lands the connected object on the ground or another object.",
+ onhit: "Fires a pulse when a hand or a walking player knocks the object - with how hard it was hit (speed) and whether you did it (byMe).",
onenter: "Fires a pulse when something enters a trigger volume - the checkpoint, doorway and pressure-plate node.",
onexit: "Fires a pulse when something leaves a trigger volume - the other half of On Enter.",
onrest: "Fires a pulse when a physics body has finished moving - the counterpart to On Impact, which fires when it starts.",
diff --git a/src/lib/objectActions.js b/src/lib/objectActions.js
index 48c84686..b43c33bf 100644
--- a/src/lib/objectActions.js
+++ b/src/lib/objectActions.js
@@ -16,6 +16,8 @@ import {
orbitControls,
isVRMode,
gizmoSuppressed,
+ editorMode,
+ isLocked,
cameraClaim, pokeScene } from '../stores/sceneStore';
import { attachMultiPivot, releaseMultiPivot, hasCustomOrigin, pivotPose, setPivotOrigin } from './multiTransform';
import { focusTargetFace, faceEditObject, hideElementSelection, restoreElementSelection } from './faceEdit';
@@ -35,6 +37,9 @@ import { canEditObject, warnViewerReadOnly } from './objectPermissions';
import { stripEditOverlays, isEditOverlay } from './editOverlays';
// B7: the transient marker (a LEAF โ two stores only, so no cycle back through history)
import { markTransient } from './transientObjects';
+// 30 P3: a LEAF (THREE + stores), so a static import here closes no cycle
+import { clearModuleSelection } from './moduleContent';
+import { desktopSpawn, spawnEyePose } from './playSpawn'; // 30b P4 (+ vrOnly: core-games)
// D2: a LEAF (svelte stores + THREE), so a static import here closes no cycle
import { shareDuplicatedMaterials, linkMaterials } from './materialSharing';
import {
@@ -164,6 +169,7 @@ export function applySelectionSet(uuids, openProperties = false) {
!locked.find((lockedUuid) => lockedUuid[1] === uuid)
);
applyMemberTints(group, clean);
+ if (clean.length) clearModuleSelection(); // 30 P3: an object selection replaces the proxy
const previous = get(selectedObjects);
selectedObjects.set(clean);
if (clean.length) lastSelection = { uuids: [...clean], origin: null }; // 24-B1
@@ -180,7 +186,13 @@ export function applySelectionSet(uuids, openProperties = false) {
// move gizmo unless every object in the set is editable by the local user
// (their own local-only objects, or anything for editors/admins).
const editable = clean.every((/** @type {any} */ uuid) => canEditObject(group.getObjectByProperty('uuid', uuid)));
- if (get(gizmoSuppressed)) {
+ if (get(editorMode) === 'interact') {
+ // 30 P1: INTERACT hands the scene to the player-style input, so a selection
+ // made from the object list (or kept from Edit) stands with no gizmo โ a
+ // seated gizmo would take the next press on the object and move it instead
+ releaseMultiPivot();
+ controls.detach();
+ } else if (get(gizmoSuppressed)) {
// sculpt mode: selection (and its lock) stand, but no gizmo โ the
// SculptToolbar toggle re-attaches explicitly when the user opts in
releaseMultiPivot();
@@ -211,6 +223,43 @@ export function applySelectionSet(uuids, openProperties = false) {
}
}
+/**
+ * 30 P1: switch the editor between EDIT (a click selects, the gizmo attaches) and
+ * INTERACT (a click plays with the scene: module handlers, On Click nodes, the cursor
+ * grab). LOCAL โ the store is never sent or saved. Entering Interact puts the gizmo away
+ * without dropping the selection, and coming back re-seats it on whatever is still
+ * selected, so a round trip through Interact costs the user nothing.
+ * @param {'edit' | 'interact'} mode @returns {'edit' | 'interact'}
+ */
+export function setEditorMode(mode) {
+ /** @type {'edit' | 'interact'} */
+ const next = mode === 'interact' ? 'interact' : 'edit';
+ if (get(editorMode) === next) return next;
+ editorMode.set(next);
+ /** @type {any} */
+ const controls = get(TControls);
+ if (next === 'interact') {
+ releaseMultiPivot();
+ if (controls && !get(isVRMode)) controls.detach();
+ // 30b P4: a game that names a spawn puts the desktop view there on the way in (VR
+ // does the same for the rig โ vrControls follows this store; Play's camera spawns in
+ // PointerLockControls). No spawn, no move: the view stays where the author left it.
+ const spawn = desktopSpawn();
+ if (spawn && !get(isVRMode) && get(isLocked) !== true) {
+ const { eye, lookAt } = spawnEyePose(spawn);
+ flyTo(eye, lookAt);
+ }
+ } else if (get(selectedObjects).length) {
+ applySelectionSet([...get(selectedObjects)]);
+ }
+ return next;
+}
+
+/** The toolbar cell and the I key. */
+export function toggleEditorMode() {
+ return setEditorMode(get(editorMode) === 'interact' ? 'edit' : 'interact');
+}
+
/** @param {string} uuid @param {boolean} openProperties @param {boolean=} additive - shift-click toggles set membership */
export function selectObject(uuid, openProperties = false, additive = false) {
const group = get(objectsGroup);
@@ -271,6 +320,7 @@ export function deselectObject() {
applyMemberTints(get(objectsGroup), []);
broadcastSelectionRelease(get(selectedObjects));
selectedObjects.set([]);
+ clearModuleSelection(); // 30 P3: the Module content proxy goes with any deselect
if (controls && !get(isVRMode)) controls.detach();
// selectedObject keeps the last object on purpose โ the open inspector binds
// to $selectedObject.position/material and would crash on an empty value
@@ -298,6 +348,14 @@ export function setTransformMode(mode) {
const controls = get(TControls);
const object = controls?.object;
const vr = get(isVRMode);
+ // 30 P1: a transform tool IS editing โ picking one in Interact goes back to Edit (which
+ // re-seats the gizmo on the selection) in that tool, rather than doing nothing visible
+ if (get(editorMode) === 'interact' && !vr) {
+ setEditorMode('edit');
+ get(TControls)?.setMode(mode);
+ transformMode.set(mode);
+ return;
+ }
const same = get(transformMode) === mode;
const isEditProxy = !!(object?.userData?.isFaceProxy || object?.userData?.isVertexProxy);
if (same && object && !vr) {
@@ -683,6 +741,12 @@ registerHistoryKind('props', (entry, state) => {
if (peer)
peer.send({ type: 'objectParameters', parameter: 'device', uuid: entry.uuid, device: state.device });
}
+ if ('pick' in state) {
+ // 30 P2: click-through in the viewport ("the next opaque thing behind me")
+ if (state.pick) object.userData.pick = state.pick;
+ else delete object.userData.pick;
+ if (peer) peer.send({ type: 'objectParameters', parameter: 'pick', uuid: entry.uuid, pick: state.pick ?? null });
+ }
if ('origin' in state) {
// 17-D: the per-object transform origin (pivot offset) is scene data, so
// moving it is undoable and replicated like any other userData write
@@ -706,6 +770,30 @@ registerHistoryKind('group', (entry, state) => {
return true;
});
+/**
+ * 30 P2: mark an object CLICK-THROUGH in the viewport โ an editor click passes it to the
+ * next opaque thing behind (a game's walls, a glass case, a ceiling). Scene data like
+ * `userData.physics`: one `props` undo entry, the existing `objectParameters` message,
+ * and it rides toJSON / GLTF extras into every save. Absent is the default, so an object
+ * never flagged serialises byte-identically.
+ * @param {string} uuid @param {boolean} on @returns {boolean} whether anything changed
+ */
+export function setPickThrough(uuid, on) {
+ const object = get(objectsGroup)?.getObjectByProperty('uuid', uuid);
+ if (!object) return false;
+ const before = object.userData.pick ?? null;
+ const next = on ? 'through' : null;
+ if (before === next) return false;
+ if (next) object.userData.pick = next;
+ else delete object.userData.pick;
+ recordEntry({ kind: 'props', uuid, before: { pick: before }, after: { pick: next } });
+ /** @type {any} */
+ const peer = get(peers);
+ peer?.send({ type: 'objectParameters', parameter: 'pick', uuid, pick: next });
+ pokeScene();
+ return true;
+}
+
/** Toggle visibility and replicate (same message Properties uses) @param {string} uuid */
export function toggleObjectVisibility(uuid) {
const group = get(objectsGroup);
@@ -1137,7 +1225,10 @@ export function flyTo(position, target, duration = 400) {
/** @param {number} now */
function step(now) {
if (token !== focusAnimation) return;
- const t = Math.min((now - started) / duration, 1);
+ // a rAF timestamp is the FRAME's start, which can precede `started`: a 0 ms fly then
+ // divided a negative (or zero) elapsed by 0 and parked the camera at ยฑInfinity/NaN for a
+ // frame, which threw from the audio listener (30 mod-audit finding). Clamp both ends.
+ const t = duration > 0 ? Math.min(Math.max((now - started) / duration, 0), 1) : 1;
const ease = 1 - Math.pow(1 - t, 3);
camera.position.lerpVectors(startPosition, endPosition, ease);
controls.target.lerpVectors(startTarget, endTarget, ease);
diff --git a/src/lib/packRefs.js b/src/lib/packRefs.js
new file mode 100644
index 00000000..a9a8f7d7
--- /dev/null
+++ b/src/lib/packRefs.js
@@ -0,0 +1,551 @@
+// @ts-ignore - no bundled three type declarations (project-wide)
+import * as THREE from 'three';
+// @ts-ignore - three addons ship no declarations here (project-wide)
+import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
+// @ts-ignore
+import { DRACOLoader } from 'three/addons/loaders/DRACOLoader.js';
+// @ts-ignore
+import { MeshoptDecoder } from 'three/addons/libs/meshopt_decoder.module.js';
+import { writable, get } from 'svelte/store';
+import { objectsGroup, pokeScene } from '../stores/sceneStore';
+import { showToast } from '../stores/appStore';
+import { PACKS_BASE } from './packs';
+import { hashBytes } from './explorer';
+
+// 30c โ KIT REFERENCES: a pack piece in a scene is a REFERENCE, not a copy.
+//
+// THE MEASUREMENT this module exists for: one placed `WallStone` (a 0.84 MB GLB from the
+// architecture kit) serialized through `buildSessionPayload` as a 7.9 MB toJSON element โ
+// its three 1024ยฒ JPEGs come back as PNG data URLs (3.3 + 2.3 + 1.9 MB) โ and 5.9 MB
+// zipped. `objects` is one toJSON() PER top-level child, so two copies of one wall share
+// nothing, and a castle of ~100 kit pieces would be a ~600 MB file. The wire has the same
+// shape (a session load sends each element; a placement sends each mesh as a GLTF).
+//
+// So a piece placed from a pack carries WHERE IT CAME FROM:
+// userData.packRef = {pack, item, path, hash, kids}
+// `path` is relative to PACKS_BASE (an absolute URL passes through โ the Khronos rows),
+// `hash` is the content hash of the bytes it was built from, `kids` the uuids of its
+// descendants in traverse order (so every peer rebuilds the same child uuids). A save, a
+// session-load broadcast and a late joiner's sync write a PRISTINE piece as a STUB: an
+// empty Group with the root's uuid, transform and userData plus `packStub: true`. Every
+// peer (and the loader of a file) REFILLS a stub from the pack URL โ fetched and parsed
+// once per piece, and every copy shares its textures, so twelve walls upload three
+// textures instead of thirty-six.
+//
+// PRISTINE is measured, never assumed: a piece whose descendants differ from the file it
+// came from (a mesh edit, a new colour, a dropped texture, a moved child) is written in
+// full exactly as before โ `fingerprintOf` over the descendants' structure, geometry
+// checksums, material parameters and an 8ร8 sample of every texture, compared against the
+// same fingerprint of the parsed file. Only the ROOT's own transform, name and userData
+// are free, and those ride the stub.
+//
+// AN OLDER BUILD reading a stub gets an empty group where the piece was (nothing throws);
+// a peer that cannot reach the pack keeps the stub, says so once, and SAVES it back as a
+// stub โ a reference is never silently turned into nothing.
+//
+// A LEAF: three + its loaders, svelte/store, two stores, packs (for PACKS_BASE) and
+// explorer (for the one content hash) โ both leaves themselves. sessions and
+// commandsHandler import this; nothing here reaches back.
+
+/**
+ * @typedef {{pack: string, item: string, path: string, hash?: string, kids?: string[]}} PackRef
+ */
+
+/** How many refills are in flight โ the author script and the suite wait on it.
+ * @type {import('svelte/store').Writable} */
+export const packRefsPending = writable(0);
+
+/** url -> the parsed file ({hash, scene}); a failure is forgotten so a retry can win
+ * @type {Map>} */
+const templates = new Map();
+/** content hash -> the fingerprint of a pristine copy
+ * @type {Map} */
+const fingerprints = new Map();
+/** image-bytes key -> the ONE texture every piece painted with those bytes uses
+ * @type {Map} */
+const sharedTextures = new Map();
+/** root uuid -> its refill, so two scans never fill one root twice
+ * @type {Map>} */
+const filling = new Map();
+/** urls already reported as unreachable (one toast per piece, not per copy) */
+const reported = new Set();
+
+/** @param {any} object @returns {PackRef | null} */
+export function packRefOf(object) {
+ const ref = object?.userData?.packRef;
+ return ref && typeof ref.path === 'string' && ref.path ? ref : null;
+}
+
+/** Where a reference's bytes live. @param {PackRef} ref */
+export function packRefUrl(ref) {
+ if (/^https?:\/\//.test(ref.path)) return ref.path;
+ return String(PACKS_BASE).replace(/\/+$/, '') + '/' + ref.path.replace(/^\/+/, '');
+}
+
+/**
+ * The reference for a pack item fetched from `url`: relative to PACKS_BASE when it lives
+ * there (so a scene follows the pack to whatever ref the build serves), absolute otherwise.
+ * @param {string} url @param {{pack?: string, item?: string}} [names]
+ * @returns {PackRef | null}
+ */
+export function packRefFromUrl(url, names = {}) {
+ if (!url || typeof url !== 'string') return null;
+ const base = String(PACKS_BASE).replace(/\/+$/, '') + '/';
+ let path = url;
+ if (url.startsWith(base)) path = url.slice(base.length);
+ else if (!/^https?:\/\//.test(url)) return null;
+ const pack = names.pack || (path === url ? '' : path.split('/')[0]) || 'pack';
+ const item = names.item || (path.split('/').pop() ?? '').replace(/\.\w+$/, '') || 'item';
+ return { pack, item, path };
+}
+
+function createLoader() {
+ const loader = new GLTFLoader();
+ const draco = new DRACOLoader();
+ draco.setDecoderPath('/draco/');
+ loader.setDRACOLoader(draco);
+ loader.setMeshoptDecoder(MeshoptDecoder);
+ return loader;
+}
+
+/**
+ * The bytes of every IMAGE in a GLB, by glTF image index โ read from the file's own JSON
+ * chunk, because the parsed texture no longer knows them. Empty for a .gltf or anything
+ * unexpected (sharing is an optimisation; nothing depends on it).
+ * @param {ArrayBuffer} buffer @returns {Uint8Array[]}
+ */
+function glbImages(buffer) {
+ try {
+ const view = new DataView(buffer);
+ if (view.getUint32(0, true) !== 0x46546c67) return [];
+ const jsonLength = view.getUint32(12, true);
+ const json = JSON.parse(new TextDecoder().decode(new Uint8Array(buffer, 20, jsonLength)));
+ const binStart = 20 + jsonLength + 8;
+ return (json.images ?? []).map((/** @type {any} */ image) => {
+ const bv = json.bufferViews?.[image.bufferView];
+ return bv ? new Uint8Array(buffer, binStart + (bv.byteOffset ?? 0), bv.byteLength) : new Uint8Array();
+ });
+ } catch {
+ return [];
+ }
+}
+
+const MAP_SLOTS = ['map', 'normalMap', 'roughnessMap', 'metalnessMap', 'emissiveMap', 'aoMap', 'alphaMap', 'bumpMap', 'displacementMap', 'lightMap'];
+
+/**
+ * One texture per distinct IMAGE across every kit piece: the architecture kit's six
+ * sandstone walls carry byte-identical JPEGs, so without this each wall variant uploaded
+ * its own three. Keyed by the image bytes' hash plus everything that makes two textures
+ * of one image differ (colour space, sampler, channel, flip).
+ * @param {any} gltf @param {ArrayBuffer} buffer
+ */
+async function shareTextures(gltf, buffer) {
+ const images = glbImages(buffer);
+ if (!images.length) return;
+ const json = gltf.parser?.json;
+ /** @type {string[]} */
+ const imageKeys = await Promise.all(
+ images.map(async (bytes) => (bytes.byteLength ? hashBytes(bytes.slice().buffer) : ''))
+ );
+ gltf.scene.traverse((/** @type {any} */ node) => {
+ if (!node.isMesh) return;
+ for (const material of Array.isArray(node.material) ? node.material : [node.material]) {
+ for (const slot of MAP_SLOTS) {
+ const texture = material?.[slot];
+ if (!texture) continue;
+ const index = gltf.parser?.associations?.get(texture)?.textures;
+ const source = index == null ? undefined : json?.textures?.[index]?.source;
+ const imageKey = source == null ? '' : imageKeys[source];
+ if (!imageKey) continue;
+ const key = [imageKey, texture.colorSpace, texture.wrapS, texture.wrapT, texture.magFilter, texture.minFilter, texture.channel, texture.flipY].join('|');
+ const shared = sharedTextures.get(key);
+ if (shared && shared !== texture) {
+ material[slot] = shared;
+ texture.dispose();
+ } else if (!shared) sharedTextures.set(key, texture);
+ }
+ }
+ });
+}
+
+/**
+ * Fetch + parse a pack file ONCE per url, and register the fingerprint of a pristine copy
+ * under the hash of the bytes actually fetched.
+ * @param {string} url @returns {Promise<{hash: string, scene: any}>}
+ */
+export function loadPackTemplate(url) {
+ let job = templates.get(url);
+ if (!job) {
+ job = (async () => {
+ const res = await fetch(url);
+ if (!res.ok) throw new Error('HTTP ' + res.status);
+ const buffer = await res.arrayBuffer();
+ const hash = await hashBytes(buffer);
+ /** @type {any} */
+ const gltf = await new Promise((resolve, reject) => createLoader().parse(buffer, '', resolve, reject));
+ // an animated rig replicates as its BYTES (animatedImports) โ never a reference
+ if (gltf.animations?.length) throw new Error('animated models are not referenced');
+ await shareTextures(gltf, buffer);
+ const scene = gltf.scene;
+ scene.updateMatrixWorld(true);
+ fingerprints.set(hash, fingerprintOf(scene));
+ return { hash, scene };
+ })();
+ templates.set(url, job);
+ job.catch(() => templates.delete(url));
+ }
+ return job;
+}
+
+// ---- the fingerprint ---------------------------------------------------------
+
+/** Seven significant digits, and anything under 1e-6 is zero โ a GLTF round trip turns a
+ * rotation matrix's -1.14e-9 into -1.42e-9, which is the same matrix.
+ * @param {number} v */
+const num = (v) =>
+ typeof v === 'number' && Number.isFinite(v) ? (Math.abs(v) < 1e-6 ? 0 : Number(v.toPrecision(7))) : String(v);
+
+/** Two weighted sums over a typed array โ equal arrays give equal sums, and a moved
+ * vertex moves the second one even when it keeps the first.
+ * @param {ArrayLike} array */
+function checksum(array) {
+ let a = 0;
+ let b = 0;
+ for (let i = 0; i < array.length; i++) {
+ a += array[i];
+ b += array[i] * ((i % 251) + 1);
+ }
+ return num(a) + '/' + num(b);
+}
+
+/** @param {any} geometry */
+function geometryKey(geometry) {
+ if (!geometry?.attributes) return '-';
+ const names = Object.keys(geometry.attributes).sort();
+ const parts = names.map((name) => {
+ const attr = geometry.attributes[name];
+ return name + ':' + attr.itemSize + ':' + attr.count + ':' + (attr.normalized ? 1 : 0) + ':' + checksum(attr.array);
+ });
+ const index = geometry.index ? 'i' + geometry.index.count + ':' + checksum(geometry.index.array) : 'noindex';
+ const groups = (geometry.groups ?? []).map((/** @type {any} */ g) => g.start + '+' + g.count + '@' + (g.materialIndex ?? 0)).join(';');
+ return parts.join(',') + '|' + index + '|' + groups;
+}
+
+/** 8ร8 RGBA of a texture's image, cached per image. Null when it cannot be read (no
+ * canvas, a tainted or odd image) โ then the texture is compared by its shape alone.
+ * @type {WeakMap} */
+const samples = new WeakMap();
+/** @param {any} texture @returns {number[] | null} */
+function textureSample(texture) {
+ const image = texture?.image;
+ if (!image || typeof image !== 'object') return null;
+ if (samples.has(image)) return samples.get(image) ?? null;
+ /** @type {number[] | null} */
+ let out = null;
+ try {
+ if (typeof document !== 'undefined' && (image.width ?? 0) > 0) {
+ const canvas = document.createElement('canvas');
+ canvas.width = 8;
+ canvas.height = 8;
+ const ctx = canvas.getContext('2d', { willReadFrequently: true });
+ if (ctx) {
+ ctx.drawImage(image, 0, 0, 8, 8);
+ out = Array.from(ctx.getImageData(0, 0, 8, 8).data);
+ }
+ }
+ } catch {
+ out = null;
+ }
+ samples.set(image, out);
+ return out;
+}
+
+/** @param {any} texture */
+function textureKey(texture) {
+ if (!texture) return '-';
+ const image = texture.image ?? {};
+ return [
+ (image.width ?? '?') + 'x' + (image.height ?? '?'),
+ texture.flipY ? 1 : 0,
+ texture.wrapS,
+ texture.wrapT,
+ num(texture.repeat?.x) + ',' + num(texture.repeat?.y),
+ num(texture.offset?.x) + ',' + num(texture.offset?.y),
+ num(texture.rotation),
+ texture.channel ?? 0
+ ].join(':');
+}
+
+/** @param {any} color */
+const colorKey = (color) => (color?.getHexString ? color.getHexString() : '-');
+
+/** @param {any} material @param {(number[] | null)[]} sink */
+function materialKey(material, sink) {
+ if (!material) return '-';
+ const head = [
+ material.type,
+ colorKey(material.color),
+ num(material.roughness),
+ num(material.metalness),
+ colorKey(material.emissive),
+ num(material.emissiveIntensity),
+ num(material.opacity),
+ material.transparent ? 1 : 0,
+ material.side,
+ num(material.alphaTest),
+ material.vertexColors ? 1 : 0,
+ material.flatShading ? 1 : 0,
+ material.wireframe ? 1 : 0,
+ num(material.normalScale?.x) + ',' + num(material.normalScale?.y),
+ num(material.envMapIntensity)
+ ].join(',');
+ const maps = MAP_SLOTS.map((slot) => {
+ const texture = material[slot];
+ if (texture) sink.push(textureSample(texture));
+ return slot + '=' + textureKey(texture);
+ }).join(',');
+ return head + '|' + maps;
+}
+
+/**
+ * What must match for a copy to be the file it came from: every DESCENDANT's type, name,
+ * visibility and local matrix, each mesh's geometry and material(s), plus texture samples
+ * (compared with a tolerance, because an autosave round trip re-encodes the JPEGs). The
+ * root itself is excluded โ its transform, name and userData are the placement and ride
+ * the stub.
+ * @param {any} root @returns {{key: string, samples: (number[] | null)[]}}
+ */
+export function fingerprintOf(root) {
+ /** @type {(number[] | null)[]} */
+ const sink = [];
+ /** @type {string[]} */
+ const lines = [];
+ root.traverse((/** @type {any} */ node) => {
+ if (node === root) return;
+ let line = node.type + ':' + node.name + ':' + (node.visible ? 1 : 0) + ':' + node.matrix.elements.map(num).join(',');
+ if (node.isMesh || node.isLine || node.isPoints) {
+ line += '|' + geometryKey(node.geometry);
+ const materials = Array.isArray(node.material) ? node.material : [node.material];
+ line += '|' + materials.map((/** @type {any} */ m) => materialKey(m, sink)).join('||');
+ }
+ lines.push(line);
+ });
+ return { key: lines.join('\n'), samples: sink };
+}
+
+/** Mean absolute difference per channel under which two samples are one image. */
+const SAMPLE_TOLERANCE = 10;
+
+/** @param {{key: string, samples: (number[] | null)[]}} a @param {{key: string, samples: (number[] | null)[]}} b */
+export function sameFingerprint(a, b) {
+ if (!a || !b || a.key !== b.key || a.samples.length !== b.samples.length) return false;
+ for (let i = 0; i < a.samples.length; i++) {
+ const x = a.samples[i];
+ const y = b.samples[i];
+ if (!x || !y) continue; // unreadable on one side: the shape key already matched
+ if (x.length !== y.length) return false;
+ let diff = 0;
+ for (let k = 0; k < x.length; k++) diff += Math.abs(x[k] - y[k]);
+ if (diff / x.length > SAMPLE_TOLERANCE) return false;
+ }
+ return true;
+}
+
+/** The uuids of a root's descendants in traverse order. @param {any} root */
+function descendantUuids(root) {
+ /** @type {string[]} */
+ const out = [];
+ root.traverse((/** @type {any} */ node) => {
+ if (node !== root) out.push(node.uuid);
+ });
+ return out;
+}
+
+/**
+ * Stamp a freshly imported pack piece BEFORE it replicates: its reference, its children's
+ * uuids, and โ since it IS the file right now โ the pristine fingerprint for its hash.
+ * @param {any} root @param {PackRef} ref @param {string} hash the bytes it was parsed from
+ */
+export function stampPackRef(root, ref, hash) {
+ if (!root || !ref) return;
+ root.updateMatrixWorld(true);
+ root.userData = { ...(root.userData ?? {}), packRef: { pack: ref.pack, item: ref.item, path: ref.path, hash, kids: descendantUuids(root) } };
+ if (hash && !fingerprints.has(hash)) fingerprints.set(hash, fingerprintOf(root));
+}
+
+/**
+ * Is this object a reference that can travel as a stub? A hollow stub (not refilled yet,
+ * or its pack was unreachable) always is; a filled one only when it still IS the file.
+ * @param {any} object @returns {boolean}
+ */
+export function isPristinePackRef(object) {
+ const ref = packRefOf(object);
+ if (!ref) return false;
+ if (object.userData.packStub) return object.children.length === 0;
+ const fingerprint = ref.hash ? fingerprints.get(ref.hash) : null;
+ if (!fingerprint) return false;
+ object.updateMatrixWorld(true);
+ return sameFingerprint(fingerprint, fingerprintOf(object));
+}
+
+/**
+ * The stub: an empty Group carrying the root's identity, transform and userData, as a
+ * toJSON element โ the shape `objects` and the `object` message already carry, so an
+ * older reader parses it into an empty group rather than failing.
+ * @param {any} root @returns {any}
+ */
+export function stubElementOf(root) {
+ const ref = /** @type {PackRef} */ (packRefOf(root));
+ const hollow = new THREE.Group();
+ hollow.uuid = root.uuid;
+ hollow.name = root.name;
+ hollow.position.copy(root.position);
+ hollow.quaternion.copy(root.quaternion);
+ hollow.scale.copy(root.scale);
+ hollow.visible = root.visible;
+ hollow.renderOrder = root.renderOrder;
+ hollow.layers.mask = root.layers.mask;
+ const kids = root.userData.packStub ? ref.kids ?? [] : descendantUuids(root);
+ hollow.userData = JSON.parse(JSON.stringify({ ...root.userData, packRef: { ...ref, kids }, packStub: true }));
+ hollow.updateMatrix();
+ return hollow.toJSON();
+}
+
+/** How many nodes a stub stands for (the root + what it refills to), for object budgets.
+ * @param {any} node a serialized node (element.object) */
+export function stubNodeCount(node) {
+ const kids = node?.userData?.packStub ? node?.userData?.packRef?.kids : null;
+ return Array.isArray(kids) ? kids.length : 0;
+}
+
+/** A child uuid every peer computes alike: the root's uuid with its last 12 hex digits
+ * replaced by the child's index. @param {string} rootUuid @param {number} index */
+function derivedUuid(rootUuid, index) {
+ return String(rootUuid).slice(0, 24) + (index + 1).toString(16).padStart(12, '0');
+}
+
+/** A private copy of a parsed piece: geometry and material cloned (the edit tools mutate
+ * both IN PLACE, and a copy must never move its siblings), textures shared.
+ * @param {any} scene */
+function instanceOf(scene) {
+ const copy = scene.clone(true);
+ copy.traverse((/** @type {any} */ node) => {
+ if (!node.isMesh) return;
+ node.geometry = node.geometry.clone();
+ node.material = Array.isArray(node.material) ? node.material.map((/** @type {any} */ m) => m.clone()) : node.material.clone();
+ });
+ return copy;
+}
+
+/**
+ * Refill a stub from its pack. Resolves true when it filled, false when it could not (or
+ * is no longer in the scene, or was filled meanwhile). The children take the recorded
+ * uuids, so every peer converges on the same ones.
+ * @param {any} root @returns {Promise}
+ */
+export function fillPackRef(root) {
+ if (!root?.userData?.packStub) return Promise.resolve(false);
+ const ref = packRefOf(root);
+ if (!ref) return Promise.resolve(false);
+ const inflight = filling.get(root.uuid);
+ if (inflight) return inflight;
+ const url = packRefUrl(ref);
+ packRefsPending.update((n) => n + 1);
+ const job = (async () => {
+ try {
+ const { hash, scene } = await loadPackTemplate(url);
+ // replaced (a clear, a reload of the same file) or filled while we fetched
+ if (!root.userData.packStub || root.children.length) return false;
+ if (get(objectsGroup)?.getObjectByProperty('uuid', root.uuid) !== root) return false;
+ const copy = instanceOf(scene);
+ /** @type {any[]} */
+ const nodes = [];
+ copy.traverse((/** @type {any} */ node) => {
+ if (node !== copy) nodes.push(node);
+ });
+ const kids = Array.isArray(ref.kids) ? ref.kids : [];
+ // a stub that lost its kids (a merge import re-uuids the tree) still has to give
+ // every peer the SAME child uuids: derive them from the root's
+ nodes.forEach((node, i) => {
+ node.uuid = typeof kids[i] === 'string' && kids[i] ? kids[i] : derivedUuid(root.uuid, i);
+ });
+ const hideShadow = root.userData.shadow === false;
+ for (const child of [...copy.children]) root.add(child);
+ if (hideShadow)
+ root.traverse((/** @type {any} */ node) => {
+ if (!node.isMesh) return;
+ node.castShadow = false;
+ node.receiveShadow = false;
+ node.userData.shadow = false;
+ });
+ delete root.userData.packStub;
+ root.userData.packRef = { ...ref, hash, kids: nodes.map((node) => node.uuid) };
+ pokeScene();
+ return true;
+ } catch (error) {
+ if (!reported.has(url)) {
+ reported.add(url);
+ console.log('kit piece could not be loaded: ' + url, error);
+ showToast('Could not load the kit piece "' + (ref.item || ref.path) + '" โ its pack is unreachable. It stays in the scene as a placeholder.');
+ }
+ return false;
+ } finally {
+ filling.delete(root.uuid);
+ packRefsPending.update((n) => n - 1);
+ }
+ })();
+ filling.set(root.uuid, job);
+ return job;
+}
+
+/** Resolves once no refill is in flight (the author script and the suites wait on it). */
+export function packRefsSettled() {
+ return new Promise((resolve) => {
+ /** @type {any} */
+ let off = null;
+ off = packRefsPending.subscribe((n) => {
+ if (n > 0) return;
+ queueMicrotask(() => off?.());
+ resolve(true);
+ });
+ });
+}
+
+/** Refill every stub in the scene, and learn the pristine fingerprint of every piece whose
+ * file we have not parsed yet (a scene restored from the autosave), so a later save can
+ * write it as a stub again. */
+function scan() {
+ const group = get(objectsGroup);
+ if (!group) return;
+ group.traverse((/** @type {any} */ node) => {
+ const ref = packRefOf(node);
+ if (!ref) return;
+ if (node.userData.packStub) {
+ if (!node.children.length) fillPackRef(node);
+ } else if (ref.hash && !fingerprints.has(ref.hash)) {
+ loadPackTemplate(packRefUrl(ref)).catch(() => {});
+ }
+ });
+}
+
+let started = false;
+/** @type {any} */
+let scanTimer = null;
+
+/** Watch the scene for stubs (a loaded file, a peer's broadcast, a late joiner's sync). */
+export function startPackRefs() {
+ if (started || typeof window === 'undefined') return;
+ started = true;
+ // 30b integrate: undo keeps a pristine piece as its stub (history.registerReferenceSnapshot).
+ // Dynamic: history's import subtree must not gain an edge to the Explorer through here.
+ import('./history').then((history) =>
+ history.registerReferenceSnapshot((/** @type {any} */ object) => (isPristinePackRef(object) ? stubElementOf(object) : null))
+ );
+ objectsGroup.subscribe(() => {
+ if (scanTimer) return;
+ scanTimer = setTimeout(() => {
+ scanTimer = null;
+ scan();
+ }, 30);
+ });
+}
diff --git a/src/lib/physics.js b/src/lib/physics.js
index 1ed12567..14f0268b 100644
--- a/src/lib/physics.js
+++ b/src/lib/physics.js
@@ -148,10 +148,34 @@ const IMPACT_MIN_DOWN_VY = 1.2; // m/s downward (pre-step) for a contact to coun
export async function warmup() {
if (RAPIER) return;
const module = await import('@dimforge/rapier3d-compat');
- await module.init();
+ await initQuietly(module);
RAPIER = module;
}
+// 30 P0: rapier-compat's own `init()` hands wasm-bindgen's init a bare byte array โ the
+// pre-0.2.93 calling convention โ and wasm-bindgen answers with "using deprecated
+// parameters for the initialization function; pass a single object instead". The call
+// is INSIDE rapier's bundle (`init` takes no arguments and the inner init is not
+// exported), so there is no single-object form for us to pass, and rapier is a FROZEN
+// dependency (solver behaviour). So the one warning is filtered for the length of that
+// call and nothing else: an exact-prefix match, restored in `finally`, every other
+// console.warn passes straight through.
+const RAPIER_INIT_WARNING = 'using deprecated parameters for the initialization function';
+
+/** @param {any} module */
+async function initQuietly(module) {
+ const warn = console.warn;
+ console.warn = (/** @type {any[]} */ ...args) => {
+ if (typeof args[0] === 'string' && args[0].startsWith(RAPIER_INIT_WARNING)) return;
+ warn.apply(console, args);
+ };
+ try {
+ await module.init();
+ } finally {
+ console.warn = warn;
+ }
+}
+
/** @param {any} object */
function transformOf(object) {
return {
diff --git a/src/lib/playCursor.js b/src/lib/playCursor.js
new file mode 100644
index 00000000..533c69f7
--- /dev/null
+++ b/src/lib/playCursor.js
@@ -0,0 +1,77 @@
+// 30 P3 โ FREE-CURSOR GAMES (roadmap 30 fork 6): WHERE PLAY MODE AIMS.
+//
+// Play mode had exactly one aim: NDC (0, 0), the crosshair, because under pointer lock the
+// cursor is pinned to the centre of the canvas and does not exist for the player. A board
+// game, a puzzle or an instrument wants the opposite โ the real cursor, visible, aiming
+// wherever it is โ and `scenePhysics.play.cursor: 'free'` asks for that (absent = 'locked',
+// today, byte for byte). A module may publish it too, field by field, through the
+// `userData.play` contract (`resolvePlaySettings`).
+//
+// THIS LEAF IS THE ONE ANSWER to "where is the player aiming right now", so the pieces that
+// ask cannot disagree:
+// ยท playInteract's tap and grab (the crosshair ray before this),
+// ยท PointerLockControls, which must not ask for a lock in free mode,
+// ยท PlayReticle, which has no crosshair to draw in free mode,
+// ยท the module SDK's `pointerRay()` (lane 30-core-modes reads `playAimNdc()` in play:
+// the cursor when free, the crosshair under a lock โ which is also the fix for the
+// stale-mouse ray it returned under a lock).
+//
+// The cursor is recorded in CLIENT pixels and converted to NDC against the canvas at read
+// time (the W9 rule `pointerRay` already keeps: the viewport can change with the pointer
+// perfectly still). LOCAL and unreplicated โ an aim is a fact about this screen.
+//
+// Imports stores and leaves only (sceneStore, scenePhysics via playSettings, canvasRect),
+// so playInteract, PointerLockControls and moduleSDK can all reach it without a cycle.
+
+import { get } from 'svelte/store';
+import { isLocked, isVRMode, globalScene } from '../stores/sceneStore';
+import { resolvePlaySettings } from './playSettings';
+import { ndcFromClient } from './canvasRect';
+
+/** the last pointer position over the page, CLIENT pixels */
+const cursor = { x: 0, y: 0, seen: false };
+
+if (typeof window !== 'undefined') {
+ // capture phase + passive: we only read coordinates, and a panel that stops the event
+ // on its way up (the documented delegated-handler trap) must not hide the cursor from us
+ const note = (/** @type {PointerEvent} */ event) => {
+ cursor.x = event.clientX;
+ cursor.y = event.clientY;
+ cursor.seen = true;
+ };
+ window.addEventListener('pointermove', note, { capture: true, passive: true });
+ window.addEventListener('pointerdown', note, { capture: true, passive: true });
+}
+
+/** 'free' | 'locked' โ the scene's play cursor, publishers included. Readable in the
+ * editor too (it is authored data), so the Inspector can show it. @returns {'free'|'locked'} */
+export function playCursorSetting() {
+ return resolvePlaySettings(get(globalScene)).cursor === 'free' ? 'free' : 'locked';
+}
+
+/** Is play running with a FREE cursor right now? Desktop play only: a headset has no
+ * cursor to free, and outside play there is no aim at all. */
+export function playCursorFree() {
+ if (get(isLocked) !== true || get(isVRMode)) return false;
+ return playCursorSetting() === 'free';
+}
+
+/** The last cursor position in client pixels, or null before the first pointer event. */
+export function cursorClient() {
+ return cursor.seen ? { x: cursor.x, y: cursor.y } : null;
+}
+
+/**
+ * WHERE PLAY MODE AIMS, in NDC: the cursor in free-cursor play (the centre until the first
+ * pointer event), the crosshair (0, 0) otherwise.
+ * @returns {{x: number, y: number}}
+ */
+export function playAimNdc() {
+ if (!playCursorFree() || !cursor.seen) return { x: 0, y: 0 };
+ return ndcFromClient(cursor.x, cursor.y);
+}
+
+/** test/debug view */
+export function playCursorDebug() {
+ return { setting: playCursorSetting(), free: playCursorFree(), cursor: cursorClient(), ndc: playAimNdc() };
+}
diff --git a/src/lib/playInteract.js b/src/lib/playInteract.js
index 88102534..baf66577 100644
--- a/src/lib/playInteract.js
+++ b/src/lib/playInteract.js
@@ -1,6 +1,6 @@
import * as THREE from 'three';
import { writable, get } from 'svelte/store';
-import { isLocked, isVRMode, playPointerFree, objectsGroup, globalScene, lockedObjects, pokeScene } from '../stores/sceneStore';
+import { isLocked, isVRMode, playPointerFree, objectsGroup, globalScene, lockedObjects, pokeScene, editorMode } from '../stores/sceneStore';
import { peers } from '../stores/appStore';
import { sceneHits } from './scenePick';
import { topLevelObjectOf } from './objectActions';
@@ -13,11 +13,14 @@ import {
remoteSimulating,
isInitiator
} from './physics';
-import { suspendAnimation, resumeAnimation, fireObjectClick } from './flowRuntime';
+import { suspendAnimation, resumeAnimation, fireObjectClick, fireObjectGrab } from './flowRuntime';
import { velocityFromSamples } from './throwVelocity';
import { resolvePlaySettings } from './playSettings';
+import { pickStack, primaryIndex } from './selectThrough';
import { nameOf } from './lockControl';
-import { moduleClickHandlers, moduleInteractiveGroups, fireClickMiss } from './moduleSDK';
+import { moduleInteractiveGroups, fireClickMiss, runClickHandlers } from './moduleSDK';
+// 30 P3: where play mode aims โ the crosshair under a lock, the cursor in a free-cursor game
+import { playAimNdc, playCursorFree } from './playCursor';
// 21-B B3: play mode becomes INTERACT mode โ a crosshair grab at distance,
// scroll to push and pull, and a release that throws with the velocity you
@@ -76,9 +79,14 @@ const yawQuat = new THREE.Quaternion();
const desiredQuat = new THREE.Quaternion();
const euler = new THREE.Euler(0, 0, 0, 'YXZ');
-/** @type {{object: any, relQuat: THREE.Quaternion, mass: number, held: boolean,
- * samples: {t: number, pos: THREE.Vector3, quat: THREE.Quaternion}[], lastSent: number}|null} */
+/** `cursor`: 30 P1 โ the carry follows the editor's CURSOR ray (Interact), not the
+ * crosshair. Everything else about the hold is the play-mode hold, unforked.
+ * @type {{object: any, relQuat: THREE.Quaternion, mass: number, held: boolean,
+ * samples: {t: number, pos: THREE.Vector3, quat: THREE.Quaternion}[], lastSent: number,
+ * cursor?: boolean}|null} */
let grab = null;
+/** 30 P1: the cursor's NDC while an Interact carry runs (fed by Scene's pointermove) */
+const cursorNdc = new THREE.Vector2();
let carryDistance = CARRY_DEFAULT;
/** @type {{t: number, uuid: string|null, hit: any}|null} */ let press = null;
let started = false;
@@ -113,8 +121,13 @@ function dynamicUuids() {
/** @param {any} camera */
function aimFrom(camera) {
camera.getWorldPosition(camPos);
- camera.getWorldDirection(camDir);
+ // 30 P3: the aim is the crosshair (NDC 0,0) under a lock and the CURSOR in a
+ // free-cursor game; `camDir` then follows the ray rather than the view axis, which is
+ // what makes a carried object follow the cursor
+ const aim = playAimNdc();
+ centre.set(aim.x, aim.y);
raycaster.setFromCamera(centre, camera);
+ camDir.copy(raycaster.ray.direction);
return sceneHits(raycaster, {}); // no tinyProxies: a proxy carries no `face`
// and is a SELECTION affordance โ grabbing an invisible speck is not a feature
}
@@ -134,6 +147,89 @@ function moduleTap() {
return false;
}
+// --- 30 P1: INTERACT, the editor's play-style mode ----------------------------------
+// The editor's second click mode borrows play's hands without play's lock: the cursor
+// aims (not the crosshair), a press on a dynamic body while a sim runs CARRIES it with the
+// exact hold above, and a short click is play's TAP โ module groups, module handlers
+// registered for 'interact', On Click nodes, the miss. Scene owns the canvas events and
+// calls in here, because the editor's own gestures (sessions, pings, pins) sit in front.
+
+/** Is the editor in INTERACT right now (outside play and VR)? */
+export function editorInteractActive() {
+ return get(editorMode) === 'interact' && get(isLocked) !== true && !get(isVRMode);
+}
+
+/**
+ * A press in Interact. Starts a cursor carry when the ray's first hit is a dynamic body
+ * of a running sim that nobody else holds; returns whether it did (Scene then stands the
+ * camera controls down for the gesture, or lets the press orbit as usual).
+ * @param {any} ray a THREE.Raycaster aimed through the cursor
+ * @param {{x: number, y: number}} ndc the cursor in NDC
+ * @param {any} camera
+ */
+export function cursorGrabStart(ray, ndc, camera) {
+ if (!editorInteractActive() || grab || !camera || !simRunning()) return false;
+ const hit = sceneHits(ray, {})[0];
+ const target = hit ? topLevelObjectOf(hit.object) : null;
+ if (!target || !dynamicUuids().has(target.uuid)) return false;
+ if (get(lockedObjects).some((/** @type {any} */ entry) => entry[1] === target.uuid)) return false;
+ if (!canEditObject(target)) {
+ warnViewerReadOnly();
+ return false;
+ }
+ activeCamera = camera;
+ cursorNdc.set(ndc.x, ndc.y);
+ camera.getWorldPosition(camPos);
+ beginGrab(target, camera);
+ // (TS narrowed `grab` to null at the guard above; beginGrab assigned it since)
+ const started = /** @type {any} */ (grab);
+ if (started) started.cursor = true;
+ return !!started;
+}
+
+/** The cursor moved while carrying. @param {{x: number, y: number}} ndc */
+export function cursorGrabMove(ndc) {
+ if (grab?.cursor) cursorNdc.set(ndc.x, ndc.y);
+}
+
+/** Release: a throw, like play's. @returns {boolean} whether a cursor carry ended */
+export function cursorGrabEnd() {
+ if (!grab?.cursor) return false;
+ endGrab(true);
+ return true;
+}
+
+/**
+ * A short click in Interact: play's tap, aimed by the cursor. Module scene-root groups
+ * first (as the editor pick always did), then the first scene hit offered to the module
+ * handlers that run in 'interact', then its On Click nodes; a click on nothing is the miss.
+ * Selects nothing, ever.
+ * @param {any} ray a THREE.Raycaster aimed through the cursor
+ * @returns {'module-group' | 'module-handler' | 'click' | 'miss'}
+ */
+export function interactClick(ray) {
+ const scene = /** @type {any} */ (get(globalScene));
+ for (const name of moduleInteractiveGroups) {
+ const root = scene?.getObjectByName(name);
+ if (!root) continue;
+ const hits = ray.intersectObject(root, true);
+ if (hits.length > 0 && runClickHandlers(hits[0].object, 'interact')) return 'module-group';
+ }
+ // 30 P2: the same see-through rule as the editor's pick โ a 0.12-opacity wall in
+ // front of a star must not take the star's click here either
+ const stack = pickStack(sceneHits(ray, { tinyProxies: true }), topLevelObjectOf);
+ const entry = stack.length ? stack[primaryIndex(stack)] : null;
+ const hit = entry?.hit ?? null;
+ if (hit && runClickHandlers(hit.object, 'interact')) return 'module-handler';
+ const target = entry?.target ?? null;
+ if (target) {
+ fireObjectClick(target.uuid);
+ return 'click';
+ }
+ fireClickMiss();
+ return 'miss';
+}
+
/** @param {any} object */
function massOf(object) {
const mass = object?.userData?.physics?.mass;
@@ -160,6 +256,8 @@ function beginGrab(object, camera) {
);
suspendAnimation(object.uuid);
playInteractState.set({ mode: 'carrying', distance: carryDistance, uuid: object.uuid, blocked: null });
+ // 30b (core-games): an On Grab node hears it (a crate's lift sound in Towers)
+ fireObjectGrab(object.uuid);
}
/**
@@ -218,6 +316,11 @@ function sendThrow(object, velocity, throwIt) {
/** @param {PointerEvent} event */
function onPointerDown(event) {
if (event.button !== 0) return;
+ // 30 P3: with a FREE cursor a press anywhere on the page reaches this window listener โ
+ // a HUD button, a toast, the โ โ so only a press on the VIEWPORT is a world gesture.
+ // Under a lock the target is the locked canvas anyway, which is why this is scoped to
+ // free mode (a synthesized window-level press keeps working there, as it always has).
+ if (playCursorFree() && !isViewportTarget(event)) return;
const mode = interactionMode();
if (mode === 'off' || !activeCamera) return;
const hits = aimFrom(activeCamera);
@@ -245,6 +348,13 @@ function onPointerDown(event) {
beginGrab(target, activeCamera);
}
+/** Is this event aimed at the 3D viewport (the renderer's canvas)? @param {Event} event */
+function isViewportTarget(event) {
+ /** @type {any} */
+ const target = event.target;
+ return !!target && target.tagName === 'CANVAS' && !!target.closest?.('.viewport');
+}
+
/** @param {PointerEvent} event */
function onPointerUp(event) {
if (event.button !== 0) return;
@@ -321,7 +431,15 @@ function onWheel(event) {
*/
export function tickPlayInteract(delta, camera) {
activeCamera = camera ?? activeCamera;
- const mode = interactionMode();
+ // 30 P1: an Interact carry lives OUTSIDE play, so play's 'off' must not cancel it โ
+ // leaving Interact (or entering play/VR) is what ends it, through the same
+ // zero-velocity cancel every other path uses
+ const cursorCarry = !!grab?.cursor;
+ if (cursorCarry && (!editorInteractActive() || !camera)) {
+ endGrab(false);
+ return;
+ }
+ const mode = cursorCarry ? 'grab' : interactionMode();
if (mode === 'off' || !camera) {
if (grab) endGrab(false);
if (get(playInteractState).mode !== 'off')
@@ -337,8 +455,19 @@ export function tickPlayInteract(delta, camera) {
endGrab(false);
return;
}
- camera.getWorldPosition(camPos);
- camera.getWorldDirection(camDir);
+ if (grab.cursor) {
+ // the same carry point, along the CURSOR's ray instead of the view axis
+ raycaster.setFromCamera(cursorNdc, camera);
+ camPos.copy(raycaster.ray.origin);
+ camDir.copy(raycaster.ray.direction);
+ } else {
+ camera.getWorldPosition(camPos);
+ // 30 P3: along the AIM ray, so a free-cursor carry follows the cursor
+ const aim = playAimNdc();
+ centre.set(aim.x, aim.y);
+ raycaster.setFromCamera(centre, camera);
+ camDir.copy(raycaster.ray.direction);
+ }
targetPos.copy(camPos).addScaledVector(camDir, carryDistance);
// dt-based, so a throttled tab does not change the feel
const k = Math.min(SPRING_K_MAX, Math.max(SPRING_K_MIN, SPRING_K / Math.sqrt(Math.max(grab.mass, 1))));
@@ -438,6 +567,7 @@ export function playInteractDebug() {
started,
mode: interactionMode(),
carrying: grab?.object?.uuid ?? null,
+ cursor: !!grab?.cursor,
held: !!grab?.held,
distance: carryDistance,
lastUp,
diff --git a/src/lib/playMode.js b/src/lib/playMode.js
index 6f573a36..a19f886b 100644
--- a/src/lib/playMode.js
+++ b/src/lib/playMode.js
@@ -3,6 +3,12 @@ import { isLocked, isVRMode, vrOverride, vrPassthrough } from '../stores/sceneSt
// appStore is a LEAF (svelte/store and nothing else โ sceneStore already pulls in more
// than it does), so this does not widen the cycle surface the note below is about.
import { showToast } from '../stores/appStore';
+// 30 P0: the back-button marker goes through SvelteKit's shallow-routing entry, not a raw
+// `history.pushState` โ the router patches the raw call in dev with a "will conflict with
+// SvelteKit's router" warning, and it is right: its own popstate handler reads the index
+// keys only ITS pushState writes. `$app/navigation` is SSR-safe to IMPORT (only a CALL on
+// the server throws, and the marker is only ever pushed from a browser play press).
+import { pushState } from '$app/navigation';
// THE PLAY STATE MACHINE, lifted out of Controls.svelte so the play FAB, the FAB's
// right-click mode menu and (next) a keyboard shortcut all press the same button.
@@ -154,20 +160,38 @@ export function exitPlay() {
// no history to go back to, pushing and popping our own entry is invisible.
//
// It is safe to pop because the marker is ALWAYS the top entry when we hold one: this
-// app keeps no state in the URL and nothing else in it calls pushState (grep), so
-// there is no router to fight and nothing of the user's can be underneath ours. The
-// flag is the whole guard โ see `consumePlayMarker` for the double-pop it prevents.
+// app keeps no state in the URL and nothing else in it pushes history entries (grep), so
+// nothing of the user's can be underneath ours. The flag is the whole guard โ see
+// `consumePlayMarker` for the double-pop it prevents.
+//
+// 30 P0: the entry is a SvelteKit SHALLOW entry (`pushState` from $app/navigation) โ
+// same url, same navigation index, so the router's popstate handler treats the Back
+// that spends it as a state change and never navigates; our listener below still sees
+// the popstate. The state object lands under the router's `sveltekit:states` key, which
+// is where `playMarkerState()` reads it.
let backMarker = false;
function pushPlayMarker() {
if (backMarker || typeof history === 'undefined') return;
try {
- // the SAME url with a state object: no navigation, nothing for a router to
- // resolve, and `history.state.tpPlay` is a readable answer to "is the marker up?"
- history.pushState({ tpPlay: true }, '');
+ // the SAME url ('' resolves to the current one) with a state object: no
+ // navigation, nothing to load, and `playMarkerState()` is a readable answer to
+ // "is the marker up?"
+ pushState('', { tpPlay: true });
backMarker = true;
} catch {
- /* a sandboxed frame can refuse pushState; play simply keeps its normal exits */
+ /* a sandboxed frame can refuse pushState (and the router refuses before it has
+ started); play simply keeps its normal exits */
+ }
+}
+
+/** the marker's state object as the router stored it on the CURRENT entry (test/debug view) */
+export function playMarkerState() {
+ try {
+ const st = /** @type {any} */ (typeof history === 'undefined' ? null : history.state);
+ return !!st?.['sveltekit:states']?.tpPlay;
+ } catch {
+ return false;
}
}
diff --git a/src/lib/playSettings.js b/src/lib/playSettings.js
index 57784b0d..973aed71 100644
--- a/src/lib/playSettings.js
+++ b/src/lib/playSettings.js
@@ -1,6 +1,29 @@
-import { get } from 'svelte/store';
+import { get, writable } from 'svelte/store';
import { scenePlay } from './scenePhysics';
import { showToast } from '../stores/appStore';
+import { normalizeLocomotion, normalizeSpawn } from './locomotionPolicy';
+import { moduleWorldChildren } from './moduleWorld';
+
+/**
+ * 30b P4: a spawn point set at RUNTIME by a module (`api.setSpawn(position, yaw)`) โ a
+ * dungeon's floor, a level's start. LOCAL (every peer's module runs the same code off the
+ * same replicated state, so each sets its own), never saved, and it wins over the scene's
+ * authored `play.spawn` (contract C1: a module overrides the scene's).
+ * @type {import('svelte/store').Writable<{position: [number, number, number], yaw: number, owner?: string} | null>}
+ */
+export const runtimeSpawn = writable(null);
+
+/** @param {any} position @param {any} [yaw] @param {string} [owner] @returns {boolean} */
+export function setRuntimeSpawn(position, yaw, owner) {
+ if (position == null) {
+ runtimeSpawn.set(null);
+ return true;
+ }
+ const spawn = normalizeSpawn(position, yaw);
+ if (!spawn) return false;
+ runtimeSpawn.set(owner ? { ...spawn, owner } : spawn);
+ return true;
+}
// 21-B B3: what play mode BEHAVES like in this scene.
//
@@ -28,7 +51,10 @@ let warnedMultiple = false;
*/
export function playPublishers(scene) {
if (!scene?.children) return [];
- const found = scene.children.filter((/** @type {any} */ child) => child?.userData?.play);
+ // 30b P5: registered module groups live under the world rig's module root now
+ const found = [...scene.children, ...moduleWorldChildren()].filter(
+ (/** @type {any} */ child) => child?.userData?.play
+ );
found.sort((/** @type {any} */ a, /** @type {any} */ b) => {
if (a.name === 'dungeon-module') return -1;
if (b.name === 'dungeon-module') return 1;
@@ -41,7 +67,15 @@ export function playPublishers(scene) {
* The effective play settings: the scene's shared `play` block, overridden
* FIELD BY FIELD by each publisher (a module only overrides what it declares).
* @param {any} scene
- * @returns {{interaction: 'grab'|'click'|'off', grounded: boolean, eyeHeight: number}}
+ * 30 P3: `cursor` too โ 'free' (the real cursor aims, no pointer lock) or 'locked' (the
+ * crosshair, today). A module publishing `userData.play.cursor` overrides the scene's, the
+ * way `grounded` does, which is how a board-game module asks for it without an authored
+ * scene field.
+ * 30b P3/P4: `locomotion` ({teleport, fly}, both false unless the scene or a publisher
+ * 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}}
*/
export function resolvePlaySettings(scene) {
const base = get(scenePlay);
@@ -49,8 +83,13 @@ export function resolvePlaySettings(scene) {
const out = {
interaction: base.interaction,
grounded: base.grounded,
- eyeHeight: DEFAULT_EYE_HEIGHT
+ eyeHeight: DEFAULT_EYE_HEIGHT,
+ cursor: base.cursor === 'free' ? 'free' : 'locked',
+ locomotion: { teleport: false, fly: false },
+ spawn: normalizeSpawn(base.spawn)
};
+ const baseLoco = normalizeLocomotion(base.locomotion);
+ if (baseLoco) Object.assign(out.locomotion, baseLoco);
const publishers = playPublishers(scene);
if (publishers.length > 1 && !warnedMultiple) {
warnedMultiple = true;
@@ -67,7 +106,14 @@ export function resolvePlaySettings(scene) {
out.interaction = play.interaction;
if (typeof play.grounded === 'boolean') out.grounded = play.grounded;
if (typeof play.eyeHeight === 'number') out.eyeHeight = play.eyeHeight;
+ if (play.cursor === 'free' || play.cursor === 'locked') out.cursor = play.cursor;
+ const loco = normalizeLocomotion(play.locomotion);
+ if (loco) Object.assign(out.locomotion, loco);
+ const spawn = normalizeSpawn(play.spawn);
+ if (spawn) out.spawn = spawn;
}
+ const runtime = get(runtimeSpawn);
+ if (runtime) out.spawn = { position: runtime.position, yaw: runtime.yaw };
return out;
}
diff --git a/src/lib/playSpawn.js b/src/lib/playSpawn.js
new file mode 100644
index 00000000..f6263635
--- /dev/null
+++ b/src/lib/playSpawn.js
@@ -0,0 +1,62 @@
+// 30b P4: THE DESKTOP HALF OF A SPAWN โ a LEAF (THREE + svelte/store + sceneStore +
+// playSettings), so PointerLockControls, objectActions and moduleSDK can all reach it
+// without closing a cycle. The VR half lives in vrControls.spawnPlayer (it moves the XR
+// reference space, which only vrControls holds).
+//
+// A spawn is `{position: [x, y, z], yaw}` โ y is the FEET, yaw is three's rotation.y (0 faces
+// -Z) โ resolved by playSettings.resolvePlaySettings: a module's runtime api.setSpawn, else
+// a publisher's userData.play.spawn, else the scene's play.spawn.
+import * as THREE from 'three';
+import { get } from 'svelte/store';
+import { playerCam } from '../stores/sceneStore';
+import { resolvePlaySettings } from './playSettings';
+import { globalScene } from '../stores/sceneStore';
+
+/** the play camera's eye above the feet: the walker's 1.7 m person */
+export const SPAWN_EYE = 1.7;
+
+/** the spawn in force right now, or null @returns {{position: [number, number, number], yaw: number} | null} */
+export function currentSpawn() {
+ return resolvePlaySettings(get(globalScene)).spawn;
+}
+
+/** the spawn a DESKTOP view honours โ the one in force unless it is VR-only (30b core-games)
+ * @returns {{position: [number, number, number], yaw: number} | null} */
+export function desktopSpawn() {
+ const spawn = currentSpawn();
+ return spawn && !(/** @type {any} */ (spawn).vrOnly) ? spawn : null;
+}
+
+/**
+ * Where an EYE stands on a spawn, and a point straight ahead of it at eye height (what
+ * flyTo and lookAt want). Pure. @param {{position: number[], yaw: number}} spawn
+ */
+export function spawnEyePose(spawn) {
+ const [x, y, z] = spawn.position;
+ const eye = [x, y + SPAWN_EYE, z];
+ const lookAt = [x - Math.sin(spawn.yaw) * 2, y + SPAWN_EYE, z - Math.cos(spawn.yaw) * 2];
+ return { eye, lookAt };
+}
+
+/**
+ * Put desktop Play's camera on the spawn: eye at feet + SPAWN_EYE, facing its yaw, level.
+ * PointerLockControls derives yaw/pitch from the camera's quaternion every look, so writing
+ * the quaternion is the whole of "face this way".
+ * @param {{position: number[], yaw: number} | null} [spawn] defaults to the one in force
+ * @returns {boolean} whether the camera moved
+ */
+export function spawnDesktopPlayer(spawn = desktopSpawn()) {
+ /** @type {any} */
+ const cam = get(playerCam);
+ if (!spawn || !cam?.isObject3D) return false;
+ const { eye } = spawnEyePose(spawn);
+ const target = new THREE.Vector3(eye[0], eye[1], eye[2]);
+ if (cam.parent) {
+ cam.parent.updateWorldMatrix(true, false);
+ cam.parent.worldToLocal(target);
+ }
+ cam.position.copy(target);
+ cam.quaternion.setFromEuler(new THREE.Euler(0, spawn.yaw, 0, 'YXZ'));
+ cam.updateMatrixWorld(true);
+ return true;
+}
diff --git a/src/lib/scenePhysics.js b/src/lib/scenePhysics.js
index 8992b6c7..43491845 100644
--- a/src/lib/scenePhysics.js
+++ b/src/lib/scenePhysics.js
@@ -1,6 +1,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
// 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
@@ -43,6 +44,10 @@ export const DEFAULT_SCENE_PHYSICS = Object.freeze({
});
const BOUNDS_ACTIONS = ['freeze', 'respawn', 'delete'];
+// 30 P3: the play cursor. 'locked' (the crosshair under pointer lock) is the default and
+// is NEVER written โ a normalized play block carries `cursor` only when it is 'free' โ so
+// every scene saved before this, and every scene that does not use it, stays byte-identical.
+const PLAY_CURSORS = ['free', 'locked'];
const INTERACTIONS = ['grab', 'click', 'off'];
/** @param {any} v @param {number} lo @param {number} hi @param {number} fallback */
@@ -61,6 +66,12 @@ function nullableNum(v, lo, hi) {
return Math.max(lo, Math.min(hi, n));
}
+/** `{[key]: value}` when there is a value, `{}` when not โ for fields that are ABSENT at
+ * their default. @param {string} key @param {any} value */
+function optional(key, value) {
+ return value == null ? {} : { [key]: value };
+}
+
/** @param {any} v @param {boolean} fallback */
function bool(v, fallback) {
return typeof v === 'boolean' ? v : fallback;
@@ -142,9 +153,15 @@ export function normalizeScenePhysics(raw) {
{
interaction: pick(playRaw.interaction, INTERACTIONS, d.play.interaction),
grounded: bool(playRaw.grounded, d.play.grounded),
- simOnPlay: bool(playRaw.simOnPlay, d.play.simOnPlay)
+ simOnPlay: bool(playRaw.simOnPlay, d.play.simOnPlay),
+ // 30 P3: present only when free (see PLAY_CURSORS)
+ ...(pick(playRaw.cursor, PLAY_CURSORS, 'locked') === 'free' ? { cursor: 'free' } : {}),
+ // 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))
},
- ['interaction', 'grounded', 'simOnPlay']
+ ['interaction', 'grounded', 'simOnPlay', 'cursor', 'locomotion', 'spawn']
),
// 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
@@ -189,7 +206,7 @@ scenePhysicsState_.subscribe((s) => sceneGravity.set(s.gravity));
export const scenePhysicsGround = derived(scenePhysicsState_, (s) => s.ground);
/** out-of-bounds config. NOT named `sceneBounds` โ that is sceneBounds.js */
export const scenePhysicsBounds = derived(scenePhysicsState_, (s) => s.bounds);
-/** play-mode block ({interaction, grounded, simOnPlay}) */
+/** play-mode block ({interaction, grounded, simOnPlay, cursor?: 'free', spawn?: {position, yaw}}) */
export const scenePlay = derived(scenePhysicsState_, (s) => s.play);
/** A1: the knock block ({enabled, gain, maxSpeed, minSpeed, radius, spin, predict}) */
export const sceneKnock = derived(scenePhysicsState_, (s) => s.knock);
diff --git a/src/lib/selectThrough.js b/src/lib/selectThrough.js
new file mode 100644
index 00000000..6989413d
--- /dev/null
+++ b/src/lib/selectThrough.js
@@ -0,0 +1,135 @@
+// 30 P2: SELECT-THROUGH and CLICK-CYCLE โ the pure part. Imports NOTHING (the
+// objectListNav / inputDevice shape), so the editor pick, the suite and vitest share it.
+//
+// Two measured reasons it exists (roadmap 30, "Selection and the object list"):
+// - a near-invisible SHELL wins the nearest-hit pick. Stars Room's 0.12-opacity walls
+// took every click on a star, a pad or a planet (16 of 16 selected "Wall south"),
+// and Football's 0.06 ceiling ate the lamps and the bars.
+// - with nothing but the nearest hit there was no way at all to reach what sits
+// BEHIND an opaque object, short of the object list.
+//
+// So a plain click prefers the first OPAQUE target down the ray, and a REPEATED click on
+// the same spot walks down the stack (the DCC convention: Blender and Maya both cycle).
+//
+// THE TIMING IS A DECISION, and it deviates from the plan's "< 600 ms": the editor's
+// double-click (15-O / 85's configurable action) is a second click on the SAME object
+// inside 400 ms, so a cycle inside that window would hand the second click to the object
+// behind and double-click-to-open-properties would die wherever anything sits behind the
+// cursor, i.e. almost everywhere. A repeat therefore cycles from the END of the
+// double-click window, and 600 ms would leave a 200 ms window nobody can hit on purpose.
+
+/** below this effective opacity a surface is glass, not a thing to click */
+export const SEE_THROUGH_OPACITY = 0.25;
+/** how far (css px) a second click may land from the first and still be "the same spot" */
+export const CYCLE_SLOP_PX = 4;
+/** the editor's double-click window (raycastSelect's lastPick) โ a repeat inside it is a double-click */
+export const DOUBLE_CLICK_MS = 400;
+/** after this, a click on the same spot is a fresh pick again, not "the next one down" */
+export const CYCLE_WINDOW_MS = 1500;
+
+/**
+ * The effective opacity of the surface a raycast hit landed on: the material of the
+ * hit's own FACE on a multi-material mesh, 0 for a material switched off.
+ * @param {any} hit
+ * @returns {number}
+ */
+export function hitOpacity(hit) {
+ const object = hit?.object;
+ if (!object) return 1;
+ let material = object.material;
+ if (Array.isArray(material)) {
+ const index = hit.face?.materialIndex ?? 0;
+ material = material[index] ?? material[0];
+ }
+ if (!material) return 1;
+ if (material.visible === false) return 0;
+ if (material.transparent && typeof material.opacity === 'number') return material.opacity;
+ return 1;
+}
+
+/**
+ * Is this hit something a click should pass through to whatever is behind it?
+ * The flag on the TOP-LEVEL object wins (a template marks its walls and ceilings
+ * `userData.pick = 'through'` whatever their opacity), then the surface's own opacity,
+ * then a hidden node on the way up (three's raycaster does not test `visible`).
+ * @param {any} hit @param {any} target the hit's top-level object
+ */
+export function isSeeThrough(hit, target) {
+ if (target?.userData?.pick === 'through') return true;
+ if (hitOpacity(hit) < SEE_THROUGH_OPACITY) return true;
+ for (let node = hit?.object; node; node = node.parent) {
+ if (node.visible === false) return true;
+ if (node === target) break;
+ }
+ return false;
+}
+
+/**
+ * @typedef {{ uuid: string, target: any, hit: any, through: boolean }} StackEntry
+ */
+
+/**
+ * Collapse raw hits (nearest first) into the STACK of distinct top-level targets.
+ * A target counts as opaque if ANY of its hits is โ a glass case around a solid core
+ * is a thing you can click, the core is just behind its own glass.
+ * @param {any[]} hits
+ * @param {(object: any) => any} topOf resolves a hit mesh to its top-level object (null = skip)
+ * @returns {StackEntry[]}
+ */
+export function pickStack(hits, topOf) {
+ /** @type {StackEntry[]} */
+ const stack = [];
+ /** @type {Map} */
+ const at = new Map();
+ for (const hit of hits ?? []) {
+ const target = topOf(hit.object);
+ if (!target) continue;
+ const through = isSeeThrough(hit, target);
+ const index = at.get(target.uuid);
+ if (index === undefined) {
+ at.set(target.uuid, stack.length);
+ stack.push({ uuid: target.uuid, target, hit, through });
+ } else if (stack[index].through && !through) {
+ stack[index].through = false;
+ }
+ }
+ return stack;
+}
+
+/** The entry a plain click selects: the first opaque target, else the nearest.
+ * @param {StackEntry[]} stack */
+export function primaryIndex(stack) {
+ const index = stack.findIndex((entry) => !entry.through);
+ return index < 0 ? 0 : index;
+}
+
+/**
+ * Is `click` a deliberate repeat of `last` โ the same spot, after the double-click window
+ * and before the cycle window closes?
+ * @param {{x: number, y: number, t: number}} click
+ * @param {{x: number, y: number, t: number} | null | undefined} last
+ */
+export function isRepeatClick(click, last) {
+ if (!last) return false;
+ const dt = click.t - last.t;
+ if (!(dt >= DOUBLE_CLICK_MS && dt <= CYCLE_WINDOW_MS)) return false;
+ return Math.hypot(click.x - last.x, click.y - last.y) <= CYCLE_SLOP_PX;
+}
+
+/**
+ * Which stack entry this click selects. A repeat of the last click whose pick is still in
+ * the stack takes the NEXT entry down (wrapping, so the see-through shell in front is
+ * reachable too); anything else takes the primary.
+ * @param {StackEntry[]} stack
+ * @param {{x: number, y: number, t: number}} click
+ * @param {{x: number, y: number, t: number, uuid: string | null} | null | undefined} last
+ * @returns {{index: number, cycled: boolean}} index -1 = nothing to pick
+ */
+export function chooseInStack(stack, click, last) {
+ if (!stack.length) return { index: -1, cycled: false };
+ const primary = primaryIndex(stack);
+ if (!last?.uuid || !isRepeatClick(click, last)) return { index: primary, cycled: false };
+ const current = stack.findIndex((entry) => entry.uuid === last.uuid);
+ if (current < 0) return { index: primary, cycled: false };
+ return { index: (current + 1) % stack.length, cycled: stack.length > 1 };
+}
diff --git a/src/lib/sessions.js b/src/lib/sessions.js
index 4dfc1f4c..10fa8db0 100644
--- a/src/lib/sessions.js
+++ b/src/lib/sessions.js
@@ -6,6 +6,7 @@ import { serializeGraphs, copyGraphFrom } from './flowGraphs';
import { serializeNode, serializeEdge, sendNodes } from './nodesHandler';
import { parkAnimatedAtBase } from './flowRuntime';
import { stripEditOverlays } from './editOverlays';
+import { isPristinePackRef, stubElementOf, stubNodeCount } from './packRefs';
// B7: a spawner's copies exist only while the world runs โ never in a scene file
import { isTransient } from './transientObjects';
import {
@@ -263,7 +264,9 @@ export function buildSessionPayload(name) {
// scene file as permanent content
objects: (group?.children ?? [])
.filter((/** @type {any} */ child) => !animatedUuids.includes(child.uuid) && !isTransient(child))
- .map((/** @type {any} */ child) => child.toJSON()),
+ // 30c: a pristine KIT PIECE is written as a stub its pack refills (packRefs.js
+ // has the measurement: one wall is 7.9 MB as toJSON)
+ .map((/** @type {any} */ child) => (isPristinePackRef(child) ? stubElementOf(child) : child.toJSON())),
animated: animatedImportsSnapshot(group),
// authored movement tracks (the Animation window) were never saved
animations: animationsSnapshot(),
@@ -356,7 +359,7 @@ export function buildSelectionPayload(name, uuids) {
const objects = roots
.map((uuid) => group?.getObjectByProperty('uuid', uuid))
.filter((/** @type {any} */ object) => object && !animatedUuids.includes(object.uuid) && !isTransient(object))
- .map((/** @type {any} */ object) => object.toJSON());
+ .map((/** @type {any} */ object) => (isPristinePackRef(object) ? stubElementOf(object) : object.toJSON()));
const clips = animationsSnapshot();
/** @type {any} */
const animations = {};
@@ -1201,6 +1204,9 @@ export function importObjects(payload, indices) {
uuidMap.set(node.uuid, fresh);
node.uuid = fresh;
});
+ // 30c: a kit-piece stub refills its children under the RECORDED uuids โ which are
+ // the file's, and this import is a merge; let the refill derive fresh ones
+ if (object.userData?.packStub && object.userData.packRef) object.userData.packRef.kids = [];
group.add(object);
recordObjectPresence('create', object);
if (peer) peer.send({ type: 'object', element: object.toJSON() });
@@ -1301,6 +1307,18 @@ export async function applySession(payload, opts = {}) {
const restored = await restoreSessionLibrary(payload);
if (restored) showToast('Restored ' + restored + ' library file' + (restored === 1 ? '' : 's'));
}
+ // 30c: a scene REPLACE ends the run. The physics world holds bodies for the objects
+ // about to be wiped and none for the ones arriving, and sim-on-play skips a start while
+ // `simulating` is still true โ so after playing one level, the next one loaded was
+ // walked THROUGH (measured: the walker crossed the tavern's walls and stairs as if they
+ // were not there). Stopped the quiet way a yielded run stops (no settling moves, no
+ // undo entry for a layout that is being thrown away); the next Play builds a new world.
+ try {
+ const physics = await import('./physics');
+ if (get(physics.simulating)) physics.stopSimulation({ yielded: true });
+ } catch {
+ /* physics failing to load must never block a scene load */
+ }
if (replicate) sceneCommand('/clear all'); // replicated clear (objects + module content)
else clearSceneLocal();
/** @type {any} */
@@ -1436,6 +1454,8 @@ export function countPayloadObjects(payload) {
const walk = (node) => {
if (!node) return;
n++;
+ // 30c: a kit-piece stub stands for the nodes it refills to
+ n += stubNodeCount(node);
for (const kid of node.children ?? []) walk(kid);
};
for (const element of payload?.objects ?? []) {
diff --git a/src/lib/shortcuts.js b/src/lib/shortcuts.js
index d871b61b..8a770537 100644
--- a/src/lib/shortcuts.js
+++ b/src/lib/shortcuts.js
@@ -23,7 +23,8 @@ import {
setTransformMode,
selectAllObjects,
clearIsolation,
- isIsolated
+ isIsolated,
+ toggleEditorMode
} from './objectActions';
import { undo, redo } from './history';
import { editingObject, enterEditMode, exitEditMode } from './meshEdit';
@@ -269,6 +270,18 @@ export const shortcuts = [
else if (get(selectedObject)?.uuid) enterEditMode(get(selectedObject).uuid);
}
},
+ {
+ // 30 P1: Edit / Interact. Free outside a mesh session (I is MESH_EDIT_KEYS' inset
+ // there, which is why the registry already stands down for it); `when` also stands
+ // it down while sculpt, spline or draw own the letter keys (their probes), so a
+ // stray I never flips the mode under a session.
+ id: 'editor.interact-mode',
+ keys: 'I',
+ group: 'Objects',
+ label: 'Edit / Interact mode (Interact: clicks play with the scene instead of selecting)',
+ when: () => !keySessionOpen(),
+ action: () => toggleEditorMode()
+ },
{
id: 'panels.object-list',
keys: 'O',
@@ -633,6 +646,35 @@ function slug(text) {
.replace(/^-+|-+$/g, '');
}
+/**
+ * 30 P1: editor SESSIONS that own the plain letter keys register a probe here โ sculpt,
+ * spline edit and draw (Scene, which already imports all three) โ so a mode key can stand
+ * down while one is open without this module importing them: shortcuts sits inside
+ * history's import family, and splineEdit reaches vrControls, which would close a cycle.
+ * @type {(() => boolean)[]} */
+const keySessionProbes = [];
+
+/** @param {() => boolean} probe @returns {() => void} unregister */
+export function registerKeySessionProbe(probe) {
+ keySessionProbes.push(probe);
+ return () => {
+ const index = keySessionProbes.indexOf(probe);
+ if (index >= 0) keySessionProbes.splice(index, 1);
+ };
+}
+
+/** Is any session holding the letter keys right now (mesh edit included)? */
+export function keySessionOpen() {
+ if (get(editingObject) || get(faceEditObject)) return true;
+ return keySessionProbes.some((probe) => {
+ try {
+ return !!probe();
+ } catch {
+ return false;
+ }
+ });
+}
+
/**
* @param {ShortcutInput} shortcut
*/
diff --git a/src/lib/tinyMarkers.js b/src/lib/tinyMarkers.js
index fae5b1ea..8eff110f 100644
--- a/src/lib/tinyMarkers.js
+++ b/src/lib/tinyMarkers.js
@@ -2,6 +2,7 @@
import * as THREE from 'three';
import { get } from 'svelte/store';
import { objectsGroup, globalScene, globalCamera, globalRenderer, selectedObjects } from '../stores/sceneStore';
+import { helpersHidden } from './helperLayer';
// A dot for an object you can no longer see.
//
@@ -74,6 +75,11 @@ export function updateTinyMarkers() {
const renderer = get(globalRenderer);
const height = renderer?.domElement?.clientHeight ?? 0;
if (!scene || !group || !camera?.isPerspectiveCamera || !height) return;
+ // 30b P1: an aiming aid for the EDITOR โ Interact and Play draw no scaffolding
+ if (helpersHidden()) {
+ if (points) points.visible = false;
+ return;
+ }
/** @type {number[]} */
const spots = [];
diff --git a/src/lib/vrControls.js b/src/lib/vrControls.js
index eef68025..0c469a3d 100644
--- a/src/lib/vrControls.js
+++ b/src/lib/vrControls.js
@@ -39,9 +39,18 @@ import {
vrToolMode,
vrTargetHz,
vrSleeveEnabled,
+ editorMode,
peerHandStyle, pokeScene } from '../stores/sceneStore';
+import { isScenery, pickGripTarget, gripMovesWorld } from './vrGrip';
+import { resolvePlaySettings, playPublishers } from './playSettings';
+import { hudDocs, isGameHud } from './hudDocs';
+import { locomotionPolicy, vrSpawnOffsets, yawForward } from './locomotionPolicy';
+import { resolveWalk } from './charController';
import { activeRing, findMenuEntry, ringEntries, sectorFromStick, pushRing, popRing, resetRings, hubEntry } from './vrRadialMenu';
import { paletteColorAt, barValueAt } from './vrPalette';
+// 30b (vr-play) C4: the ONE game-feel predicate (a leaf) + the pattern shapes (pure)
+import { gameFeelActive } from './gameFeel';
+import { hapticSchedule, knockHapticScale } from './hapticPatterns';
import { recordMaterialChange, setMaterialParam } from './materialsHandler';
import { prefabs, instantiatePrefab } from './prefabs';
import {
@@ -117,17 +126,20 @@ import {
focusObject,
ungroupObject,
applySelectionSet,
- selectionUuids
+ selectionUuids,
+ setEditorMode,
+ toggleEditorMode
} from './objectActions';
import { vrKeyboardTarget, openVRKeyboard, pressVRKey, closeVRKeyboard } from './vrKeyboard';
import { sceneCommand } from './commandsHandler.svelte';
import { sendPing, pingColor } from './ping';
import { peerColor } from './lockControl';
import { setVRAxes, setVRButtons } from './inputRuntime';
-import { suspendAnimation, resumeAnimation } from './flowRuntime';
+import { suspendAnimation, resumeAnimation, fireObjectGrab } from './flowRuntime';
import { drawMode, toggleDrawMode, addStrokePoint, endStroke } from './drawMode';
import { setPttHeld, cycleMicMode, vrMicMode, micActive, pttActive } from './voiceChat';
import { safeStorage } from './safeStorage';
+import { helpersHidden } from './helperLayer';
import {
HOLD_MS,
vrWindowAdjust,
@@ -233,7 +245,7 @@ vrChatPanelOpen.subscribe((open) => {
});
/** @type {any} */ let renderer = null;
-/** @type {{menu?: boolean, squeeze?: boolean, stick?: boolean, trigger?: boolean, a?: boolean}[]} */
+/** @type {{menu?: boolean, squeeze?: boolean, stick?: boolean, trigger?: boolean, a?: boolean, mode?: boolean}[]} */
const previousButtons = [{}, {}];
const raycaster = new THREE.Raycaster();
const tempMatrix = new THREE.Matrix4();
@@ -310,6 +322,8 @@ function ensureRayLines() {
export function updateHoverBox(object) {
const scene = get(globalScene);
if (!scene) return;
+ // 30b P1: the hover shell is editor scaffolding โ none in Interact/Play
+ if (helpersHidden()) object = null;
if (!hoverBox) {
hoverBox = new THREE.Box3Helper(new THREE.Box3(), new THREE.Color(RAY_HOVER));
hoverBox.name = 'vr-hover-box';
@@ -329,6 +343,8 @@ function setHovered(object) {
// the shell is the primary, emissive-independent cue; the emissive tint is a
// secondary touch for materials that support it
updateHoverBox(object);
+ // 30b P1: ...and neither is the emissive hover tint (it paints a replicated material)
+ if (helpersHidden()) object = null;
if (hoveredObject === object) return;
if (hoveredObject?.material?.emissive) hoveredObject.material.emissive.setHex(hoveredEmissive);
hoveredObject = null;
@@ -381,9 +397,17 @@ export function registerVRTriggerHooks(hooks) {
if (i >= 0) triggerHooks.splice(i, 1);
};
}
+/** 30b (C3): did SOME hook take the last trigger press on each slot? The sweep starts
+ * from a non-consuming hook, so it reads this on its next frame to stand down under a
+ * gesture another feature claimed (a knob drag, a cable, the sleeve). */
+const triggerClaims = [false, false];
+/** @param {number} index */
+export function triggerClaimed(index) {
+ return !!triggerClaims[index];
+}
/** @param {number} index @returns {boolean} */
export function vrModuleTriggerStart(index) {
- return triggerHooks.some((h) => {
+ const claimed = triggerHooks.some((h) => {
try {
return !!h.start?.(index);
} catch (error) {
@@ -391,6 +415,8 @@ export function vrModuleTriggerStart(index) {
return false;
}
});
+ triggerClaims[index] = claimed;
+ return claimed;
}
/** @param {number} index @returns {boolean} */
export function vrModuleTriggerEnd(index) {
@@ -800,7 +826,8 @@ export function updateTeleport(session) {
const y = source?.gamepad?.axes?.[3] ?? 0;
// 157: teleport can be disabled โ reset any arm + hide the arc
- if (!get(vrTeleportEnabled)) {
+ // 30b P3: ...and Interact allows it only when the scene's play block says so
+ if (!get(vrTeleportEnabled) || !vrLocomotionNow().teleport) {
teleportEngaged = false;
hideArc();
return;
@@ -926,24 +953,228 @@ export function initVRControls(r) {
renderer = r;
}
+/** 30b: what the actuators were asked for (a ring, newest last) and how many pulses the
+ * Edit-mode gate swallowed โ the suites' view, since no headset is attached headless */
+const hapticRing = /** @type {{intensity: number, ms: number, hand: string | null, at: number}[]} */ ([]);
+let hapticSuppressed = 0;
+
+// ---- 30b P3: WALK LIKE A GAME (Interact) ------------------------------------------------
+// Edit keeps the editor's stick (VRControls.svelte: fly/strafe, left-grip pan/elevate,
+// teleport, the world gestures). INTERACT walks: the left stick moves along the head's
+// yaw at a walking pace, the step resolves through charController.resolveWalk โ the SAME
+// three tiers desktop's walker uses (the rapier capsule when a sim runs, a dungeon raster,
+// the ground plane) โ gravity pulls the feet down, a ~0.3 m step is climbed (the
+// capsule's autostep), and nothing flies or teleports unless the play block allows it.
+// The rig moves the WebXR way: by offsetting the reference space (offset = -(the viewer's
+// displacement), the convention across this file).
+//
+// FEET. A headset reports the HEAD. The physical head height comes from the viewer pose in
+// the BASE reference space captured at session start (local-floor: y 0 is the real floor),
+// and the feet are the head's current world y minus it โ robust to every offset any gesture
+// applied since. Without a base space (a fake session in a suite), a standing 1.6 m head.
+
+/** metres per second on a full stick */
+export const VR_WALK_SPEED = 2.2;
+/** a standing head when no base space can say better */
+const STANDING_HEAD = 1.6;
+/** @type {any} */ let xrBaseSpace = null;
+
+/** Scene's onsessionstart: remember the untouched reference space. */
+export function noteXRBaseSpace() {
+ xrBaseSpace = renderer?.xr?.getReferenceSpace?.() ?? null;
+}
+
+/** 30b P3: the locomotion rules in force right now (mode + the resolved play block). */
+export function vrLocomotionNow() {
+ const mode = get(editorMode) === 'interact' ? 'interact' : 'edit';
+ return locomotionPolicy(mode, resolvePlaySettings(get(globalScene)).locomotion);
+}
+
+/** the viewer pose in the current space, and the head's physical height @returns {any} */
+function viewerNow() {
+ const frame = renderer?.xr?.getFrame?.();
+ const space = renderer?.xr?.getReferenceSpace?.();
+ const pose = frame && space ? frame.getViewerPose?.(space) : null;
+ if (!pose) return null;
+ const base = xrBaseSpace ? frame.getViewerPose?.(xrBaseSpace) : null;
+ const p = pose.transform.position;
+ const o = pose.transform.orientation;
+ const q = new THREE.Quaternion(o.x, o.y, o.z, o.w);
+ const fwd = new THREE.Vector3(0, 0, -1).applyQuaternion(q);
+ return {
+ head: { x: p.x, y: p.y, z: p.z },
+ yaw: Math.atan2(-fwd.x, -fwd.z),
+ headHeight: base ? base.transform.position.y : STANDING_HEAD
+ };
+}
+
+/** @param {{x: number, y: number, z: number}} offset reference-space offset (-(displacement))
+ * @param {{x: number, y: number, z: number, w: number}} [orientation] */
+function offsetSpace(offset, orientation) {
+ const space = renderer?.xr?.getReferenceSpace?.();
+ if (!space) return false;
+ renderer.xr.setReferenceSpace(
+ space.getOffsetReferenceSpace(
+ orientation ? new XRRigidTransform(offset, orientation) : new XRRigidTransform(offset)
+ )
+ );
+ return true;
+}
+
+/**
+ * 30b P3: ONE walker step as data โ the wanted displacement from the stick, resolved against
+ * the world. Exported so a suite drives it with a real simulation and no headset.
+ * @param {{head: {x: number, y: number, z: number}, headHeight: number, yaw: number,
+ * stick: {x: number, y: number}, dt: number, fly?: boolean, aim?: {x: number, y: number, z: number}}} input
+ * @returns {{dx: number, dy: number, dz: number, feet: number, grounded: boolean, source: string}}
+ */
+export function vrWalkStep(input) {
+ const dead = (/** @type {number} */ v) => (Math.abs(v) > 0.15 ? v : 0);
+ const sx = dead(input.stick.x);
+ const sy = dead(input.stick.y);
+ const dt = Math.max(0, Math.min(input.dt, 0.1));
+ const speed = VR_WALK_SPEED * dt;
+ const fwd = input.fly && input.aim ? input.aim : yawForward(input.yaw);
+ const flat = yawForward(input.yaw);
+ // stick UP is negative y in xr-standard; strafe is always horizontal
+ const right = { x: -flat.z, z: flat.x };
+ const desired = {
+ dx: speed * (-sy * fwd.x + sx * right.x),
+ dz: speed * (-sy * fwd.z + sx * right.z),
+ ...(input.fly ? { dy: speed * -sy * (fwd.y ?? 0) } : {})
+ };
+ // the capsule is quantised to 10 cm so a nodding head does not rebuild it every frame
+ const height = Math.min(2.1, Math.max(1, Math.round(input.headHeight * 10) / 10));
+ const feet = input.head.y - input.headHeight;
+ const r = resolveWalk({ x: input.head.x, y: feet, z: input.head.z }, height, dt, desired, {
+ gravity: !input.fly
+ });
+ return { dx: r.dx, dy: r.feet - feet, dz: r.dz, feet: r.feet, grounded: r.grounded, source: r.source };
+}
+
+/**
+ * 30b P3: the Interact half of VRControls.svelte's stick task. Returns false in Edit (the
+ * caller then runs the editor's own stick code unchanged).
+ * @param {number} dt seconds @param {any} session
+ */
+export function tickVRInteractLocomotion(dt, session) {
+ const policy = vrLocomotionNow();
+ if (!policy.walk) return false;
+ const viewer = viewerNow();
+ if (!viewer) return true;
+ const left = [...(session?.inputSources ?? [])].find((s) => s.handedness === 'left');
+ const axes = left?.gamepad?.axes ?? [];
+ /** @type {any} */
+ let aim = null;
+ if (policy.fly) {
+ const index = controllerIndexFor('left');
+ if (index >= 0) {
+ const v = new THREE.Vector3(0, 0, -1).applyQuaternion(
+ renderer.xr.getController(index).getWorldQuaternion(new THREE.Quaternion())
+ );
+ aim = { x: v.x, y: v.y, z: v.z };
+ }
+ }
+ const step = vrWalkStep({
+ head: viewer.head,
+ headHeight: viewer.headHeight,
+ yaw: viewer.yaw,
+ stick: { x: axes[2] ?? 0, y: axes[3] ?? 0 },
+ dt,
+ fly: policy.fly,
+ aim
+ });
+ // fell out of the world: back to the spawn (or the origin)
+ if (step.feet < -50) {
+ // no spawn: stand back up on the origin, feet at 0
+ if (!spawnPlayer())
+ offsetSpace({ x: viewer.head.x, y: viewer.head.y - viewer.headHeight, z: viewer.head.z });
+ return true;
+ }
+ if (step.dx || step.dy || step.dz) offsetSpace({ x: -step.dx, y: -step.dy, z: -step.dz });
+ return true;
+}
+
+/**
+ * 30b P4: put the player on the game's spawn โ the runtime api.setSpawn, else the scene's
+ * `play.spawn` (resolvePlaySettings). In VR the FEET land on it facing its yaw; on the
+ * desktop the play camera does (PointerLockControls reads the same resolution). No spawn,
+ * no move. @returns {boolean} whether the player was moved
+ */
+export function spawnPlayer() {
+ const spawn = resolvePlaySettings(get(globalScene)).spawn;
+ if (!spawn || !renderer?.xr?.getSession?.()) return false;
+ const viewer = viewerNow();
+ if (!viewer) return false;
+ const { turn, move } = vrSpawnOffsets(viewer.head, viewer.yaw, viewer.headHeight, spawn);
+ offsetSpace(turn.position, turn.orientation);
+ offsetSpace(move);
+ return true;
+}
+
/**
* Buzz the VR controllers if the session's gamepads support it (no-op on
* desktop). Used by modules for press feedback. Optional `hand` targets one
* controller โ matched by each inputSource's OWN handedness (never a raw slot
* index, which diverges from the controller order after a hands<->controllers
* swap โ 194/210; axesForSlot resolves the same way).
+ *
+ * 30b (C4): a NO-OP IN EDIT MODE โ "The vibration should be only interactive mode, not
+ * in edit mode" (the user, from a Quest). Every core pulse and a module's api.haptic
+ * funnel through here, so this one gate covers all of them.
* @param {number} intensity 0..1 @param {number} durationMs
* @param {'left'|'right'=} hand omit to pulse both
+ * @param {boolean=} force pulse even in Edit (the mode-switch tick only)
*/
-export function hapticPulse(intensity = 0.5, durationMs = 50, hand = undefined) {
+export function hapticPulse(intensity = 0.5, durationMs = 50, hand = undefined, force = false) {
+ // `force`: the one pulse that must be felt IN Edit โ 30b-vr-modes' Edit/Interact switch
+ // tick, which confirms the switch INTO Edit
+ if (!force && !gameFeelActive()) {
+ hapticSuppressed++;
+ return;
+ }
const session = renderer?.xr?.getSession?.();
- session?.inputSources?.forEach((source) => {
+ if (!session) return;
+ hapticRing.push({ intensity, ms: durationMs, hand: hand ?? null, at: performance.now() });
+ if (hapticRing.length > 64) hapticRing.shift();
+ session.inputSources?.forEach((/** @type {any} */ source) => {
if (hand && source.handedness !== hand) return;
const actuator = source.gamepad?.hapticActuators?.[0];
actuator?.pulse?.(intensity, durationMs);
});
}
+/**
+ * 30b (C4): play a named PATTERN ('tap' 'bump' 'hit' 'success' 'fail' 'rumble'
+ * 'heartbeat' โ hapticPatterns.js) as timed pulses. `scale` sizes every pulse (a knock's
+ * impulse). Returns false for an unknown name or outside Interact/Play; each pulse is
+ * gated again as it fires, so leaving the game mid-pattern stops it.
+ * @param {string} name @param {'left'|'right'=} hand @param {number=} scale
+ * @returns {boolean}
+ */
+export function hapticPattern(name, hand = undefined, scale = 1) {
+ const pulses = hapticSchedule(name, scale);
+ if (!pulses.length || !gameFeelActive()) return false;
+ for (const pulse of pulses) {
+ if (pulse.at <= 0) hapticPulse(pulse.intensity, pulse.ms, hand);
+ else setTimeout(() => hapticPulse(pulse.intensity, pulse.ms, hand), pulse.at);
+ }
+ return true;
+}
+
+/** 30b: the knock's haptic seam (Scene hands this to knock.js) โ a `hit` sized by the
+ * knock's own strength, on the hand that hit. `intensity` arrives as knock.js computes
+ * it (0.2 + speed/10); read back as the speed it encodes.
+ * @param {number} intensity @param {number} _ms @param {'left'|'right'} hand */
+export function hapticKnock(intensity, _ms, hand) {
+ hapticPattern('hit', hand, knockHapticScale(Math.max(0, (Number(intensity) - 0.2) * 10)));
+}
+
+/** the suites' view of the actuators @returns {{pulses: any[], suppressed: number}} */
+export function hapticDebug() {
+ return { pulses: hapticRing.map((p) => ({ ...p })), suppressed: hapticSuppressed };
+}
+
/** 194: resolve a controller slot by HANDEDNESS. three's getController(i) is a
* persistent object; Scene stamps controller.userData.handedness from each
* 'connected' event, so this survives a hands<->controllers reorder (the raw
@@ -1572,11 +1803,12 @@ function broadcastMove(object, force = false) {
});
}
-/** @param {any} object @param {any} before */
+/** @param {any} object @param {any} before null = a PLAYER's grab (Interact): moved and
+ * thrown like any other, but not an edit, so no undo entry */
function endGrab(object, before) {
broadcastMove(object, true);
const after = transformStateOf(object);
- if (JSON.stringify(before) !== JSON.stringify(after))
+ if (before && JSON.stringify(before) !== JSON.stringify(after))
recordTransform({ uuid: object.uuid, before: before, after: after });
// PFX-C: mid-sim release = throw (velocity estimate from the hold samples)
import('./physics').then((m) => m.releaseBody(object.uuid));
@@ -1586,6 +1818,17 @@ function endGrab(object, before) {
/** @type {{index: number, prev: any} | null} right-grip drag-the-world pan */
let worldPan = null;
+/** 30b P2: test/debug view of what the grips are doing right now */
+export function vrGripDebug() {
+ return {
+ grab: grab?.object?.uuid ?? null,
+ grabInteract: !!grab?.interact,
+ worldGrab: !!worldGrab,
+ worldPan: !!worldPan,
+ emptyAir: [...emptyAirSqueeze]
+ };
+}
+
// ---- 214: Box Select โ a 3D drag-box marquee. Trigger-press anchors a corner,
// the controller drags the opposite corner, release selects every top-level
// object whose world origin falls inside. The visual is a scene-root mesh
@@ -2224,13 +2467,13 @@ function onSqueezeStart(index) {
}
if (!get(objectsGroup)) return;
const controller = renderer.xr.getController(index);
- const hits = controllerRay(index).intersectObjects(get(objectsGroup).children, true);
- let object = hits.length ? topLevelObjectOf(hits[0].object) : null;
- if (!object) {
- // hand inside an object grabs it without a pointer (100.3)
- object = containedTopLevel(controller.getWorldPosition(new THREE.Vector3()), get(objectsGroup));
- }
+ // 30b P2: the grip takes what vrGrip.pickGripTarget says โ scenery (floors, walls, the
+ // room you stand in) passes through, and in INTERACT only a player-holdable body counts
+ 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;
emptyAirSqueeze[index] = true;
// 186: in stretch mode both grips drive the stretch, not a world grab
if (get(vrStretchObject)) return;
@@ -2250,6 +2493,8 @@ function onSqueezeStart(index) {
if (get(lockedObjects).find((lock) => lock[1] === object.uuid)) return;
if (grab && grab.object === object && grab.index !== index) {
+ // 30b P2: a player's second hand does not resize the thing it is holding
+ if (mode === 'interact') return;
// second hand on the same object -> two-hand scale
const distance = controllerDistance();
scaleGrab = {
@@ -2263,6 +2508,7 @@ function onSqueezeStart(index) {
return;
}
+ const interact = mode === 'interact';
suspendAnimation(object.uuid); // animated objects park at their base while held
// PFX-C: mid-sim, a VR-grabbed dynamic body follows the hand kinematically
// and RELEASE throws it with the estimated hand velocity โ the exact desktop
@@ -2281,7 +2527,10 @@ function onSqueezeStart(index) {
grab = {
object,
index,
- style: get(vrGrabStyle),
+ // 30b P2: a player's hand is RIGID (no gizmo-style move/rotate), and `interact`
+ // switches off the editor's extras in updateGrab/endGrab (snap, stick scale, undo)
+ interact,
+ style: interact ? 'rigid' : get(vrGrabStyle),
relPos: object.position.clone().sub(pPos).applyQuaternion(pQuat.clone().invert()),
relQuat: pQuat.clone().invert().multiply(object.quaternion),
startScale: object.scale.clone(),
@@ -2291,8 +2540,53 @@ function onSqueezeStart(index) {
before: transformStateOf(object)
};
vrGrabbedHand.set(renderer.xr.getController(index)?.userData?.handedness ?? null);
- hapticPulse(0.25, 30);
- selectObject(object.uuid); // locks it for peers, updates selection state
+ // 30b (C4): a grab lands with a `hit` (a gated no-op in Edit, like every pulse)
+ hapticPattern('hit', renderer.xr.getController(index)?.userData?.handedness ?? undefined);
+ // 30b P2: a player picking something up is not SELECTING it โ no lock broadcast, no
+ // selection shell, no inspector (Edit keeps all three)
+ if (!interact) selectObject(object.uuid); // locks it for peers, updates selection state
+ // 30b (core-games): a PLAYER's grab reaches On Grab nodes (Edit moves things, it does not play)
+ else fireObjectGrab(object.uuid);
+}
+
+/**
+ * 30b P2: the top-level object a grip closes on, or null for empty air. Ray hits first
+ * (nearest first, each top-level object once), then the hand-inside test (100.3); both go
+ * through the vrGrip rule, so a floor, a wall or the room you stand in is never held.
+ * Exported for the headless suite. @param {any} ray a THREE.Raycaster
+ * @param {any} handPos the controller's world position @param {'edit'|'interact'} mode
+ */
+export function gripTargetOf(ray, handPos, mode) {
+ const group = get(objectsGroup);
+ if (!group) return null;
+ /** @type {any} */
+ const camera = get(globalCamera);
+ const head = camera ? camera.getWorldPosition(new THREE.Vector3()) : null;
+ const locked = get(lockedObjects);
+ const interaction = mode === 'interact' ? resolvePlaySettings(get(globalScene)).interaction : 'grab';
+ /** @param {any} object */
+ const describe = (object) => {
+ const box = new THREE.Box3().setFromObject(object);
+ return {
+ scenery: isScenery(box.isEmpty() ? null : box, head),
+ grabbable:
+ interaction === 'grab' &&
+ object.userData?.physics?.mode === 'dynamic' &&
+ !locked.find((/** @type {any} */ lock) => lock[1] === object.uuid)
+ };
+ };
+ /** @type {any[]} */
+ const order = [];
+ for (const hit of ray.intersectObjects(group.children, true)) {
+ const top = topLevelObjectOf(hit.object);
+ if (top && !order.includes(top)) order.push(top);
+ }
+ const picked = pickGripTarget(order.map(describe), mode);
+ if (picked >= 0) return order[picked];
+ // a hand INSIDE an object needs no pointer (100.3) โ the same rule decides
+ const inside = containedTopLevel(handPos, group);
+ if (inside && pickGripTarget([describe(inside)], mode) === 0) return inside;
+ return null;
}
/** @param {number} index */
@@ -2359,7 +2653,7 @@ function onSqueezeEnd(index) {
hapticPulse(0.4, 60);
return;
}
- endGrab(object, grab.before);
+ endGrab(object, grab.interact ? null : grab.before);
grab = null;
vrGrabbedHand.set(null);
hapticPulse(0.18, 24);
@@ -2419,7 +2713,7 @@ function updateGrab() {
const pPos = position.clone().applyMatrix4(parentInv);
const pQuat = parentQuat.clone().invert().multiply(quaternion);
- const axes = axesForSlot(grab.index);
+ const axes = grab.interact ? [] : axesForSlot(grab.index); // 30b P2: no reel/scale in Interact
const adjusted = grabStickAdjust({
length: Math.max(grab.relPos.length(), 0.05),
scale: grab.scaleFactor,
@@ -2435,7 +2729,9 @@ function updateGrab() {
const pose = rigidGrabPose(pPos, pQuat, grab.relPos, grab.relQuat);
object.position.copy(pose.position);
object.quaternion.copy(pose.quaternion);
- if (get(vrSnapMode) === 'surface') {
+ if (grab.interact) {
+ // 30b P2: a player's hand does not snap
+ } else if (get(vrSnapMode) === 'surface') {
dropToSurface(object, get(objectsGroup)); // 156: rest on the nearest surface under it
} else if (get(snapEnabled)) {
const step = get(snapSettings).translate;
@@ -3118,11 +3414,13 @@ export function updateVRControls() {
console.log('VR frame hook failed', error);
}
}
+ updateModeLabel(); // 30b P4: the wrist label follows the mode (hidden outside a session)
if (!session) {
hideArc();
teleportEngaged = false;
return;
}
+ payPendingSpawn(); // 30b P4
// open menu/panel are modal for the sticks: sector nav / scrolling own them;
// a RIGHT-hand grab owns the right stick too (reel/scale beats teleport, 100);
// D9: manipulation gestures + ANY held grip stand navigation down entirely
@@ -3185,6 +3483,13 @@ export function updateVRControls() {
}
prev.menu = menuPressed;
+ // 30b P4: the MODE button โ Y on the LEFT hand (B on the right when the radial menu
+ // lives on the left, so the two never share a button): Edit <-> Interact, in VR
+ if (source.handedness === modeHand()) {
+ if (menuPressed && !prev.mode) toggleVRMode();
+ prev.mode = menuPressed;
+ }
+
// right A held = push-to-talk
const aPressed = !!buttons[4]?.pressed;
@@ -3533,3 +3838,149 @@ export function updateVRControls() {
vrHovered.set(null);
}
}
+
+// ---- 30b P4: ENTER INTERACT, SWITCH IN VR, SPAWN ------------------------------------------
+// Contract C1: pressing Play in VR on a GAME enters Interact; the left Y button toggles
+// Edit <-> Interact in the headset (a haptic tick + a label on that wrist says which); and
+// entering Interact puts the world back to 1:1 and the player on the game's spawn.
+
+/** Is this scene a game? A HUD screen bound to a game state (the GameChip rule), a spawn,
+ * or a module publishing the play contract (a dungeon). */
+export function sceneIsGame() {
+ const scene = get(globalScene);
+ if (isGameHud(get(hudDocs))) return true;
+ if (resolvePlaySettings(scene).spawn) return true;
+ return playPublishers(scene).length > 0;
+}
+
+/** which hand owns the mode button: the LEFT (Y), unless the radial menu lives there */
+export function modeHand() {
+ return get(vrMenuHand) === 'left' ? 'right' : 'left';
+}
+
+/** Scene's onsessionstart (after the base space is live). */
+export function onVRSessionStart() {
+ noteXRBaseSpace();
+ // a game is played, not edited: Play in VR lands in Interact (the stores follow below)
+ if (sceneIsGame() && get(editorMode) !== 'interact') setEditorMode('interact');
+ else if (get(editorMode) === 'interact') enterInteractVR();
+ updateModeLabel();
+}
+
+/** Interact starts at 1:1, on the spawn, with no editor gesture half-done. */
+function enterInteractVR() {
+ worldGrab = null;
+ worldPan = null;
+ emptyAirSqueeze[0] = emptyAirSqueeze[1] = false;
+ if (grab && !grab.interact) {
+ endGrab(grab.object, grab.before);
+ grab = null;
+ vrGrabbedHand.set(null);
+ }
+ resetWorldRig();
+ // at sessionstart no XR frame exists yet (so no viewer pose to move FROM): the spawn
+ // waits for the first frame that has one (updateVRControls)
+ spawnPending = true;
+ if (viewerNow()) {
+ spawnPending = false;
+ spawnPlayer();
+ }
+}
+
+/** The button: flip the mode, tick the hand, flash the label. @returns {'edit'|'interact'} */
+export function toggleVRMode() {
+ const next = toggleEditorMode();
+ hapticPulse(0.35, 40, /** @type {any} */ (modeHand()), true);
+ modeLabelFlashUntil = Date.now() + 500;
+ updateModeLabel();
+ return next;
+}
+
+let spawnPending = false;
+/** a spawn owed since enterInteractVR, paid on the first frame with a viewer pose */
+function payPendingSpawn() {
+ if (!spawnPending || !viewerNow()) return;
+ spawnPending = false;
+ spawnPlayer();
+}
+
+/** @type {any} */ let modeLabel = null;
+/** @type {any} */ let modeLabelCanvas = null;
+let modeLabelText = '';
+let modeLabelFlashUntil = 0;
+
+function ensureModeLabel() {
+ if (modeLabel || typeof document === 'undefined') return modeLabel;
+ modeLabelCanvas = document.createElement('canvas');
+ modeLabelCanvas.width = 256;
+ modeLabelCanvas.height = 80;
+ const texture = new THREE.CanvasTexture(modeLabelCanvas);
+ texture.colorSpace = THREE.SRGBColorSpace;
+ modeLabel = new THREE.Mesh(
+ new THREE.PlaneGeometry(0.075, 0.0234),
+ new THREE.MeshBasicMaterial({ map: texture, transparent: true, depthWrite: true })
+ );
+ modeLabel.name = 'vr-mode-label';
+ // on the back of the controller, tilted up toward the eyes (the wrist you glance at)
+ modeLabel.position.set(0, 0.03, 0.07);
+ modeLabel.rotation.set(-1.0, 0, 0);
+ modeLabel.renderOrder = 996;
+ return modeLabel;
+}
+
+/** redraw the label when the mode changes; re-parent it onto the mode hand's controller */
+function updateModeLabel() {
+ const label = ensureModeLabel();
+ if (!label || !renderer) return;
+ const presenting = !!renderer.xr.getSession?.();
+ const index = presenting ? controllerIndexFor(modeHand()) : -1;
+ label.visible = index >= 0;
+ if (index < 0) return;
+ const controller = renderer.xr.getController(index);
+ if (label.parent !== controller) controller.add(label);
+ const interact = get(editorMode) === 'interact';
+ const text = interact ? 'INTERACT' : 'EDIT';
+ label.scale.setScalar(Date.now() < modeLabelFlashUntil ? 1.35 : 1);
+ if (text === modeLabelText) return;
+ modeLabelText = text;
+ const ctx = modeLabelCanvas.getContext('2d');
+ if (!ctx) return;
+ ctx.clearRect(0, 0, 256, 80);
+ ctx.fillStyle = interact ? 'rgba(20, 110, 90, 0.9)' : 'rgba(150, 95, 10, 0.9)';
+ ctx.beginPath();
+ ctx.roundRect?.(4, 4, 248, 72, 18);
+ if (!ctx.roundRect) ctx.rect(4, 4, 248, 72);
+ ctx.fill();
+ ctx.fillStyle = '#ffffff';
+ ctx.font = 'bold 34px sans-serif';
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+ ctx.fillText(text, 112, 42);
+ ctx.font = 'bold 22px sans-serif';
+ ctx.fillStyle = 'rgba(255,255,255,0.75)';
+ ctx.fillText(modeHand() === 'left' ? 'Y' : 'B', 228, 42);
+ label.material.map.needsUpdate = true;
+}
+
+/** test/debug view of the label */
+export function vrModeLabelDebug() {
+ return {
+ text: modeLabelText,
+ visible: !!modeLabel?.visible,
+ hand: modeLabel?.parent?.userData?.handedness ?? null,
+ flashing: Date.now() < modeLabelFlashUntil
+ };
+}
+
+// THE LAST STATEMENT IN THE FILE on purpose: a module-level subscribe runs its callback
+// synchronously at evaluation, so every `let` it reaches must already be declared (the
+// TDZ rule that took the whole app down twice). Entering Interact in a live session resets
+// the world and spawns; either way the label follows.
+let lastEditorMode = get(editorMode);
+editorMode.subscribe((mode) => {
+ if (mode === lastEditorMode) return;
+ lastEditorMode = mode;
+ if (!renderer?.xr?.getSession?.()) return;
+ if (mode === 'interact') enterInteractVR();
+ updateModeLabel();
+});
diff --git a/src/lib/vrGameInput.js b/src/lib/vrGameInput.js
new file mode 100644
index 00000000..ec0ff9c8
--- /dev/null
+++ b/src/lib/vrGameInput.js
@@ -0,0 +1,432 @@
+// 30b (vr-play) โ THE GAME IN YOUR HANDS, the VR input half: what a controller is pointing
+// at or touching in a GAME, and what that feels like. P1 (C4) is the haptic defaults โ
+// a `tap` when the laser enters something clickable, a `bump` when the trigger lands on
+// it; P4 (C3) grows the hold-and-sweep on the same resolver.
+//
+// "Clickable" is decided HERE, once, for the tap, the bump and the sweep alike:
+// ยท anything under a module's scene-root INTERACTIVE group (a board, a dungeon door);
+// ยท a part of a module DEVICE (`userData.device` on an ancestor โ the music lab's piano
+// keys, drum pads, transport buttons: every one is its own control);
+// ยท an object an On Click node targets (flowRuntime's own rule), or one a module marked
+// `userData.clickable = true`.
+// The ENTRY KEY is what "once per entry" counts: the exact mesh for module content and
+// device parts (a key is not its piano), the top-level object otherwise (an object with an
+// On Click is one thing to press however many meshes it is built from).
+//
+// Everything is LOCAL and gated on gameFeelActive(): the editor never buzzes (the user's
+// rule), and nothing here sends anything โ a click that lands replicates through the paths
+// it always did (module handlers, the On Click trigger).
+//
+// Plugs into vrControls through its generic registries (registerVRFrameHook,
+// registerVRTriggerHooks) โ the vrSleeve precedent โ so vrControls never imports it.
+// Started from Scene.svelte (`startVrGameInput`), which already imports everything here.
+import * as THREE from 'three';
+import { get } from 'svelte/store';
+import { objectsGroup, globalScene, globalRenderer } from '../stores/sceneStore';
+import { gameFeelActive, gameClickMode } from './gameFeel';
+import { sceneHits } from './scenePick';
+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';
+// 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';
+
+const _mat = new THREE.Matrix4();
+
+/**
+ * @typedef {{kind: 'object', key: string, mesh: any, top: any | null, point: any, distance: number, group: string | null}} ClickTarget
+ */
+
+/** the scene-root interactive group a mesh sits under, or null @param {any} mesh */
+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;
+ }
+ return null;
+}
+
+/** is there a module device on the path from the mesh up to `top`? @param {any} mesh @param {any} top */
+function deviceBetween(mesh, top) {
+ for (let node = mesh; node; node = node.parent) {
+ if (node.userData?.device) return true;
+ if (node === top) break;
+ }
+ return false;
+}
+
+/**
+ * Resolve a raycast/overlap HIT to the thing a press would reach โ or null when nothing
+ * clickable is there. Pure over the scene; exported for the suites.
+ * @param {any} hit a THREE intersection ({object, point, distance})
+ * @returns {ClickTarget | null}
+ */
+export function clickTargetOf(hit) {
+ const mesh = hit?.object;
+ if (!mesh) return null;
+ const group = interactiveGroupOf(mesh);
+ if (group) return { kind: 'object', key: mesh.uuid, mesh, top: null, point: hit.point, distance: hit.distance ?? 0, group };
+ const top = topLevelObjectOf(mesh);
+ if (!top) return null;
+ if (deviceBetween(mesh, top)) return { kind: 'object', key: mesh.uuid, mesh, top, point: hit.point, distance: hit.distance ?? 0, group: null };
+ if (top.userData?.clickable === true || objectHasOnClick(top.uuid))
+ return { kind: 'object', key: top.uuid, mesh, top, point: hit.point, distance: hit.distance ?? 0, group: null };
+ return null;
+}
+
+/**
+ * The nearest clickable along a ray: module interactive groups first (the viewport click's
+ * order), then the scene with the see-through rule (a faint wall does not take the press).
+ * @param {any} ray a THREE.Raycaster @returns {ClickTarget | null}
+ */
+export function clickTargetAlong(ray) {
+ /** @type {ClickTarget | null} */
+ let best = null;
+ const scene = get(globalScene);
+ for (const name of moduleInteractiveGroups) {
+ const root = scene?.getObjectByName(name);
+ if (!root) continue;
+ const hits = ray.intersectObject(root, true).filter((/** @type {any} */ h) => h.object.visible !== false);
+ const target = hits.length ? clickTargetOf(hits[0]) : null;
+ if (target && (!best || target.distance < best.distance)) best = target;
+ }
+ if (get(objectsGroup)) {
+ const stack = pickStack(sceneHits(ray), topLevelObjectOf);
+ const entry = stack.length ? stack[primaryIndex(stack)] : null;
+ const target = entry?.hit ? clickTargetOf(entry.hit) : null;
+ if (target && (!best || target.distance < best.distance)) best = target;
+ }
+ return best;
+}
+
+/* -------------------------------------------------------------- controller rays ---- */
+
+/** the world ray of three.js controller SLOT `index` @param {number} index */
+export function controllerRayOf(index) {
+ const renderer = /** @type {any} */ (get(globalRenderer));
+ const controller = renderer?.xr?.getController?.(index);
+ if (!controller) return null;
+ const ray = new THREE.Raycaster();
+ _mat.identity().extractRotation(controller.matrixWorld);
+ ray.ray.origin.setFromMatrixPosition(controller.matrixWorld);
+ ray.ray.direction.set(0, 0, -1).applyMatrix4(_mat);
+ return ray;
+}
+
+/** 'left' | 'right' | undefined for a controller slot @param {number} index */
+export function handOf(index) {
+ const renderer = /** @type {any} */ (get(globalRenderer));
+ const h = renderer?.xr?.getController?.(index)?.userData?.handedness;
+ return h === 'left' || h === 'right' ? h : undefined;
+}
+
+/* ----------------------------------------------------------------- the defaults ---- */
+
+/** what each slot's laser pointed at last frame (entry key) โ the hover-enter edge */
+const hoverKey = /** @type {(string | null)[]} */ ([null, null]);
+const stats = { taps: 0, bumps: 0, sweepClicks: 0 };
+
+/**
+ * One frame of the hover edge for slot `index`: a NEW clickable under the laser taps that
+ * hand. Exported so the suites drive it with a posed controller (no XR session headless).
+ * @param {number} index @param {any} [ray] defaults to the controller's own ray
+ * @returns {ClickTarget | null}
+ */
+export function hoverFrame(index, ray = controllerRayOf(index)) {
+ if (!ray || !gameFeelActive()) {
+ hoverKey[index] = null;
+ return null;
+ }
+ const target = clickTargetAlong(ray);
+ const key = target?.key ?? null;
+ if (key && key !== hoverKey[index]) {
+ hapticPattern('tap', handOf(index));
+ stats.taps++;
+ }
+ hoverKey[index] = key;
+ return target;
+}
+
+/**
+ * A trigger PRESS on slot `index`: a bump when it lands on something clickable. Never
+ * consumes the press โ the click itself still travels the normal select path.
+ * @param {number} index @param {any} [ray] @returns {boolean} whether it bumped
+ */
+export function pressFeedback(index, ray = controllerRayOf(index)) {
+ if (!ray || !gameFeelActive()) return false;
+ if (!clickTargetAlong(ray)) return false;
+ hapticPattern('bump', handOf(index));
+ stats.bumps++;
+ return true;
+}
+
+/** @returns {{taps: number, bumps: number, sweepClicks: number, hover: (string | null)[], sweeps: boolean[]}} */
+export function vrGameInputDebug() {
+ return { ...stats, hover: [...hoverKey], sweeps: sweeps.map(Boolean) };
+}
+
+/* --------------------------------------------------------------- P3 the panel ---- */
+
+const _pos = new THREE.Vector3();
+const _quat = new THREE.Quaternion();
+
+/** one hand's controller world pose, or null @param {'left'|'right'} hand */
+function handPose(hand) {
+ const renderer = /** @type {any} */ (get(globalRenderer));
+ const index = controllerIndexFor(hand);
+ const controller = index >= 0 ? renderer?.xr?.getController?.(index) : null;
+ if (!controller) return null;
+ controller.getWorldPosition(_pos);
+ controller.getWorldQuaternion(_quat);
+ return { position: _pos.clone(), quaternion: _quat.clone() };
+}
+
+/** the controller's TIP for a poke (the target-ray origin is its front end) @param {number} index */
+function tipOf(index) {
+ const renderer = /** @type {any} */ (get(globalRenderer));
+ const controller = renderer?.xr?.getController?.(index);
+ return controller ? controller.getWorldPosition(new THREE.Vector3()) : null;
+}
+
+/** true once a press of OURS (a panel button) took this trigger: its trailing 'select'
+ * must not also reach Scene's pick */
+let swallowNext = false;
+/** was a headset presenting on the last frame (the session-end edge) */
+let presenting = false;
+
+/* ---------------------------------------------------------------- P4 the sweep ---- */
+
+// C3 โ HOLD THE TRIGGER AND SWEEP. The user: "I want to be able to hold trigger and
+// automatically press the buttons where I move the controller around. So, for example, I
+// can hit different keys on piano holding trigger and on the mixer enable/disable".
+//
+// While the trigger is held in Interact/Play, every clickable the controller TIP (a
+// ~6 cm sphere just ahead of the controller) ENTERS is clicked once, and re-armed when
+// the tip leaves it; with the tip touching nothing, the LASER does the same for things
+// out of reach (and for the game board's buttons). The first entry is the press itself
+// (`source: 'trigger'`), fired on the PRESS rather than the release, so an instrument
+// sounds when you hit it; every later one is `source: 'sweep'`, which a handler can opt
+// out of (`registerClickHandler(fn, {sweep: false})`). A press another feature CLAIMED
+// (a knob drag, a cable, the sleeve) is left alone โ read one frame later, because this
+// hook does not consume and cannot see the hooks after it. When the sweep clicked
+// anything, the press's trailing 'select' is swallowed: that click already happened.
+
+/** the tip sphere's radius (~6 cm across) and how far ahead of the controller it sits */
+export const TIP_RADIUS = 0.03;
+const TIP_AHEAD = 0.03;
+/** the laser sweeps only what the tip cannot reach */
+const LASER_MIN = 0.25;
+
+/** @typedef {{first: boolean, inside: Set, laserKey: string | null, panelKey: string | null, fired: number}} Sweep */
+/** @type {(Sweep | null)[]} */
+const sweeps = [null, null];
+
+const _box = new THREE.Box3();
+const _sphere = new THREE.Sphere();
+
+/**
+ * Every clickable a TIP sphere touches (bounding sphere, then the world box โ a key, a
+ * pad, a step cell is a box). Keyed by entry, so a device part is its own control.
+ * @param {THREE.Vector3} tip @param {number} [radius] @returns {ClickTarget[]}
+ */
+export function clickTargetsTouching(tip, radius = TIP_RADIUS) {
+ /** @type {any[]} */
+ const roots = [];
+ const group = get(objectsGroup);
+ if (group) roots.push(group);
+ const scene = /** @type {any} */ (get(globalScene));
+ for (const name of moduleInteractiveGroups) {
+ const root = scene?.getObjectByName(name);
+ if (root) roots.push(root);
+ }
+ /** @type {Map} */
+ const found = new Map();
+ for (const root of roots)
+ root.traverseVisible((/** @type {any} */ node) => {
+ if (!node.isMesh || !node.geometry) return;
+ const geo = node.geometry;
+ if (!geo.boundingSphere) geo.computeBoundingSphere();
+ _sphere.copy(geo.boundingSphere).applyMatrix4(node.matrixWorld);
+ if (_sphere.distanceToPoint(tip) > radius) return;
+ if (!geo.boundingBox) geo.computeBoundingBox();
+ _box.copy(geo.boundingBox).applyMatrix4(node.matrixWorld);
+ if (_box.distanceToPoint(tip) > radius) return;
+ const distance = _box.distanceToPoint(tip);
+ const target = clickTargetOf({ object: node, point: tip.clone(), distance });
+ const known = target ? found.get(target.key) : null;
+ if (target && (!known || distance < known.distance)) found.set(target.key, target);
+ });
+ return [...found.values()].sort((a, b) => a.distance - b.distance);
+}
+
+/**
+ * CLICK a target the way Interact/Play's tap does โ the module handlers for this mode
+ * first, then the object's On Click โ and bump the hand when something acted.
+ * @param {ClickTarget} target @param {string} source @param {number} index @returns {boolean}
+ */
+export function fireClickTarget(target, source, index) {
+ let acted = runClickHandlers(target.mesh, gameClickMode(), { source });
+ if (!acted && target.top) acted = fireObjectClick(target.top.uuid) > 0;
+ if (acted) {
+ hapticPattern('bump', handOf(index));
+ stats.sweepClicks++;
+ }
+ return acted;
+}
+
+/** the tip sphere's centre for slot `index` @param {number} index */
+function tipAhead(index) {
+ const ray = controllerRayOf(index);
+ return ray ? ray.ray.origin.clone().addScaledVector(ray.ray.direction, TIP_AHEAD) : null;
+}
+
+/**
+ * One frame of a held trigger on slot `index`. `opts.tip`/`opts.ray` inject the pose (the
+ * suites; no headset headless). Returns the entry keys this frame clicked, or null when
+ * no sweep is live.
+ * @param {number} index @param {{tip?: THREE.Vector3 | null, ray?: THREE.Raycaster | null}} [opts]
+ * @returns {string[] | null}
+ */
+export function sweepFrame(index, opts = {}) {
+ const sweep = sweeps[index];
+ if (!sweep) return null;
+ if (!gameFeelActive() || (sweep.first && triggerClaimed(index))) {
+ sweeps[index] = null;
+ return null;
+ }
+ const source = sweep.first ? 'trigger' : 'sweep';
+ sweep.first = false;
+ const tip = opts.tip === undefined ? tipAhead(index) : opts.tip;
+ const ray = opts.ray === undefined ? controllerRayOf(index) : opts.ray;
+ /** @type {string[]} */
+ const fired = [];
+ // the tip plays what it is NEAREST to: a 6 cm sphere is wider than a piano key, and
+ // "every key it touches" would sound clusters. A target fires when it becomes the
+ // nearest touching one; it stays spent while the tip still touches it, and LEAVING
+ // re-arms it.
+ const touching = tip ? clickTargetsTouching(tip) : [];
+ const nearest = touching[0] ?? null;
+ const still = new Set([...sweep.inside].filter((key) => touching.some((t) => t.key === key)));
+ if (nearest && !still.has(nearest.key)) {
+ if (fireClickTarget(nearest, source, index)) fired.push(nearest.key);
+ still.add(nearest.key);
+ }
+ sweep.inside = still;
+ if (!touching.length && ray) {
+ const board = panelTargetAlong(ray);
+ const boardKey = board?.hit.id ?? null;
+ if (board && boardKey !== sweep.panelKey && pressPanelTarget(board, { source, u: uAcross(board) })) {
+ hapticPattern('bump', handOf(index));
+ fired.push(board.hit.id);
+ }
+ sweep.panelKey = boardKey;
+ const far = board ? null : clickTargetAlong(ray);
+ const farKey = far && far.distance > LASER_MIN ? far.key : null;
+ if (far && farKey && farKey !== sweep.laserKey && fireClickTarget(far, source, index)) fired.push(farKey);
+ sweep.laserKey = farKey;
+ } else {
+ sweep.laserKey = null;
+ sweep.panelKey = null;
+ }
+ if (fired.length) {
+ sweep.fired += fired.length;
+ swallowNext = true;
+ }
+ return fired;
+}
+
+/**
+ * A trigger PRESS on slot `index`. In Interact/Play: a game-board button under the laser
+ * is pressed at once and the press consumed (the sweep carries on across the board); any
+ * other press starts a SWEEP, which fires on the next frame once it knows no other
+ * gesture claimed this press. Returns whether the press was consumed. Exported for the
+ * suites (the live hook calls it).
+ * @param {number} index @returns {boolean}
+ */
+export function triggerStart(index) {
+ swallowNext = false;
+ sweeps[index] = null;
+ if (!gameFeelActive()) return false;
+ const ray = controllerRayOf(index);
+ const target = ray ? panelTargetAlong(ray) : null;
+ if (target) {
+ pressPanelTarget(target, { source: 'trigger', u: uAcross(target) });
+ hapticPattern('bump', handOf(index));
+ swallowNext = true;
+ sweeps[index] = { first: false, inside: new Set(), laserKey: null, panelKey: target.hit.id, fired: 1 };
+ return true;
+ }
+ sweeps[index] = { first: true, inside: new Set(), laserKey: null, panelKey: null, fired: 0 };
+ return false;
+}
+
+/** The trigger RELEASED on slot `index`: the sweep ends (the swallow stays armed for the
+ * trailing 'select', which WebXR fires before 'selectend'). @param {number} index */
+export function triggerEnd(index) {
+ sweeps[index] = null;
+ return false;
+}
+
+/** is a sweep live on slot `index`? (suites) @param {number} index */
+export function sweepLive(index) {
+ return !!sweeps[index];
+}
+
+/** @type {(() => void)[]} */
+let offs = [];
+
+/** Wire the defaults into the VR loop. Returns the stop. Idempotent. */
+export function startVrGameInput() {
+ stopVrGameInput();
+ offs = [
+ registerVRFrameHook(() => {
+ const renderer = /** @type {any} */ (get(globalRenderer));
+ if (!renderer?.xr?.isPresenting) {
+ // the session ENDED: take the surfaces down once (this hook runs every desktop
+ // frame too, where there is nothing to do)
+ if (presenting) hideVrGamePanel();
+ presenting = false;
+ return;
+ }
+ presenting = true;
+ vrGamePanelFrame({ hands: [handPose('left'), null] });
+ for (const index of [0, 1]) {
+ const ray = controllerRayOf(index);
+ // the laser on the board wins over the world behind it
+ if (panelHover(index, gameFeelActive() ? ray : null)) hoverKey[index] = null;
+ else hoverFrame(index, ray);
+ pokeFrame(index, gameFeelActive() ? tipOf(index) : null);
+ sweepFrame(index);
+ }
+ }),
+ registerVRTriggerHooks({
+ start: (/** @type {number} */ index) => triggerStart(index),
+ end: (/** @type {number} */ index) => triggerEnd(index),
+ swallow: () => {
+ if (!swallowNext) return false;
+ swallowNext = false;
+ return true;
+ }
+ }),
+ // the beam ends ON the board and the wrist card (the reticle sits on the button)
+ registerPanelGroupProvider(() => {
+ const s = vrGameSurface('vr-game-panel');
+ return s?.mesh.visible ? s.mesh : null;
+ }),
+ registerPanelGroupProvider(() => {
+ const s = vrGameSurface('vr-game-wrist');
+ return s?.mesh.visible ? s.mesh : null;
+ })
+ ];
+ return stopVrGameInput;
+}
+
+export function stopVrGameInput() {
+ for (const off of offs) off();
+ offs = [];
+ hoverKey[0] = hoverKey[1] = null;
+ sweeps[0] = sweeps[1] = null;
+ hideVrGamePanel();
+}
diff --git a/src/lib/vrGamePanel.js b/src/lib/vrGamePanel.js
new file mode 100644
index 00000000..33e856cc
--- /dev/null
+++ b/src/lib/vrGamePanel.js
@@ -0,0 +1,898 @@
+// 30b (vr-play) C2 โ THE GAME UI IN VR. The user, on a Quest: "in VR I should be able to
+// see the game menu and interact with it", "I also should see the score in the VR".
+//
+// DOM is invisible in a headset, so the HUD layer (HudLayer.svelte) draws nothing there.
+// This module draws the SAME documents into CANVAS TEXTURES on four scene-root meshes:
+//
+// ยท the GAME PANEL โ a world-space board ~1.2 m in front at chest height that LAZILY
+// follows your head yaw (it re-centres only once you have turned past ~35 degrees, so
+// it holds still while you read it). It shows a game's MENU-like screens (menu, pause,
+// results: a screen with input 'menu', a control on it, or bound to menu/paused/over)
+// laid out exactly as the desktop HUD lays them out (the 9-grid over the 1280x720
+// reference stage), plus a footer with two buttons of ours: "Edit mode" (the Edit โ
+// Interact switch 30b-vr-modes adds; a plain `editorMode` write is enough to leave) and
+// "Top strip" on/off. Its buttons answer the controller LASER + trigger and a
+// fingertip/controller-tip POKE.
+// ยท the WRIST card โ the game's small overlays (score, timer, level) as short lines on
+// the LEFT wrist, shown when you turn the wrist toward your face, with the same two
+// buttons, so there is always a way back to editing even mid-round.
+// ยท the TOP STRIP โ the same lines in one head-locked row across the top of the view,
+// LOCAL on/off (persisted), default on.
+// ยท the ANNOUNCE banner โ api.announce(), head-locked, big and centred.
+//
+// A button press is the desktop press verbatim: `fireHudButton(id)` pulses the element's
+// Button nodes through the replicated trigger log, a toggle writes its value first โ so a
+// press in the headset and a click on the desktop cannot disagree about what happened.
+//
+// LOCAL and non-replicated throughout; everything sits at the SCENE ROOT under fixed names
+// (golden rule 5) on the default layer (both eyes, never the helper layer), and only while
+// a headset session is presenting AND the player is in Interact/Play. The frame work is
+// driven by vrGameInput's frame hook; the suites drive `vrGamePanelFrame` with a synthetic
+// head pose, since no XR session runs headless.
+import * as THREE from 'three';
+import { get } from 'svelte/store';
+import { globalScene, globalRenderer } from '../stores/sceneStore';
+import { setEditorMode } from './objectActions';
+import { hudDocs, hudRuntime, hudValueOf, setHudValue, visibleScreen, activeHudKeys, rectInFrame, hudScreenOverride } from './hudDocs';
+import { isInteractiveKind, isRenderableKind } from './hudKinds';
+import { fireHudButton, hudOptionsOf } from './flowRuntime';
+import { cameraPreview } from './cameraPreview';
+import { gameState } from './gameState';
+import { gameFeelActive } from './gameFeel';
+import { gameAnnouncement } from './gameAnnounce';
+import { playGameSound } from './gameSfx';
+import { hudImageFor, resolveHudImage } from './hudImages';
+import { safeStorage } from './safeStorage';
+
+/** the HUD's authoring reference โ the editor artboard's stage */
+export const STAGE_W = 1280;
+export const STAGE_H = 720;
+/** the panel's footer (our own buttons), in stage pixels */
+export const FOOTER_H = 96;
+const PANEL_DIST = 1.2;
+const PANEL_DROP = 0.22; // below the eyes: chest height
+const FOLLOW_DEG = 35;
+const SETTLE_DEG = 2;
+const WRIST_W = 0.15;
+const STRIP_KEY = 'vr:gameStrip';
+
+/** the board canvas's pixels per stage pixel: ~1000 px across a metre-wide board, what a
+ * headset's ~20 px per degree needs at 1.2 m */
+const SCALE = 1.5;
+
+/* ------------------------------------------------------------ the classification --- */
+
+/** a screen the PANEL shows (a menu, a pause, a results card) rather than the wrist
+ * @param {any} screen */
+export function isPanelScreen(screen) {
+ if (!screen) return false;
+ if (screen.input === 'menu') return true;
+ if (['menu', 'paused', 'over'].includes(String(screen.showWhile ?? ''))) return true;
+ return (screen.elements ?? []).some((/** @type {any} */ el) => isInteractiveKind(el.kind) && el.enabled !== false);
+}
+
+/** kinds with no VR form on the board (a crosshair, a plot, the debug pill, module DOM) */
+const NO_VR_FORM = new Set(['crosshair', 'minimap', 'debug', 'custom', 'damageflash']);
+/** the board never gets narrower than its footer needs, in stage pixels */
+const MIN_CROP_W = 720;
+const CROP_PAD = 36;
+
+/**
+ * The part of the 1280x720 stage a screen actually USES โ the union of its elements'
+ * rects, padded, widened to fit the footer. A desktop menu is usually a card in the middle
+ * of a transparent screen; drawn whole, the headset saw a wall of empty backdrop with the
+ * menu small in its middle (measured on the Towers template). Pure; exported.
+ * @param {any} screen @returns {{x: number, y: number, w: number, h: number}}
+ */
+export function panelCrop(screen) {
+ let x0 = Infinity;
+ let y0 = Infinity;
+ let x1 = -Infinity;
+ let y1 = -Infinity;
+ for (const el of screen?.elements ?? []) {
+ if (!isRenderableKind(el.kind) || NO_VR_FORM.has(el.kind)) continue;
+ const r = rectInFrame(el, STAGE_W, STAGE_H);
+ x0 = Math.min(x0, r.left);
+ y0 = Math.min(y0, r.top);
+ x1 = Math.max(x1, r.left + r.w);
+ y1 = Math.max(y1, r.top + r.h);
+ }
+ if (!Number.isFinite(x0)) return { x: 0, y: 0, w: STAGE_W, h: STAGE_H };
+ x0 = Math.max(0, x0 - CROP_PAD);
+ y0 = Math.max(0, y0 - CROP_PAD);
+ x1 = Math.min(STAGE_W, x1 + CROP_PAD);
+ y1 = Math.min(STAGE_H, y1 + CROP_PAD);
+ let w = x1 - x0;
+ if (w < MIN_CROP_W) {
+ const cx = (x0 + x1) / 2;
+ w = MIN_CROP_W;
+ x0 = Math.min(Math.max(0, cx - w / 2), STAGE_W - w);
+ }
+ return { x: x0, y: y0, w, h: Math.max(120, y1 - y0) };
+}
+
+/** metres per stage pixel on the board: a 14 px label reads ~1 degree tall at 1.2 m */
+const METRES_PER_PX = 0.0016;
+
+/**
+ * The screens on show right now, the HudLayer rule (scene doc + the one keyed by the camera
+ * looked through), split into what the panel shows and what the wrist/strip show.
+ * @returns {{panel: {key: string, screen: any}[], overlay: {key: string, screen: any}[]}}
+ */
+export function vrScreens() {
+ const through = get(cameraPreview)?.uuid ?? null;
+ /** @type {{key: string, screen: any}[]} */
+ const panel = [];
+ /** @type {{key: string, screen: any}[]} */
+ const overlay = [];
+ for (const key of activeHudKeys(through)) {
+ const screen = visibleScreen(key);
+ if (!screen) continue;
+ (isPanelScreen(screen) ? panel : overlay).push({ key, screen });
+ }
+ return { panel, overlay };
+}
+
+/**
+ * The overlay as SHORT LINES โ what a score/timer/level card SAYS, readable on a wrist.
+ * Pure over (elements, runtime, values); exported for the suites.
+ * @param {any[]} elements @param {Record} runtime @returns {string[]}
+ */
+export function overlayLines(elements, runtime) {
+ /** @type {string[]} */
+ const out = [];
+ const sorted = [...elements].sort((a, b) => (a.y ?? 0) - (b.y ?? 0) || (a.x ?? 0) - (b.x ?? 0));
+ for (const el of sorted) {
+ if (!isRenderableKind(el.kind)) continue;
+ const rt = runtime?.[el.id] ?? null;
+ const text = rt?.text !== undefined && rt?.text !== null ? String(rt.text) : String(el.label ?? '');
+ if (el.kind === 'text' || el.kind === 'timer' || el.kind === 'richtext' || el.kind === 'panel') {
+ const clean = text.replace(/\[[^\]]*\]/g, '').replace(/\s+/g, ' ').trim();
+ if (clean) out.push(clean);
+ } else if (el.kind === 'bar' || el.kind === 'progressradial') {
+ const value = Number(rt?.value ?? el.value ?? 0);
+ const min = Number(rt?.min ?? el.min ?? 0);
+ const max = Number(rt?.max ?? el.max ?? 1);
+ const pct = max - min > 1e-9 ? Math.round(Math.min(1, Math.max(0, (value - min) / (max - min))) * 100) : 0;
+ out.push((text ? text + ' ' : '') + pct + '%');
+ } else if (el.kind === 'list') {
+ const rows = Array.isArray(rt?.rows) && rt.rows.length ? rt.rows : String(el.rowsText ?? '').split('\n').filter(Boolean);
+ if (text) out.push(text);
+ for (const row of rows.slice(0, 3)) out.push(String(row));
+ }
+ if (out.length >= 6) break;
+ }
+ return out.slice(0, 6);
+}
+
+/* --------------------------------------------------------------------- drawing ----- */
+
+/** a style value, token names resolved to literals (a canvas cannot take var()) */
+function paint(/** @type {any} */ value, /** @type {string} */ fallback) {
+ if (value === undefined || value === null || value === '') return fallback;
+ const text = String(value);
+ if (/^[a-z][a-z0-9-]*$/i.test(text) && !/^(transparent|white|black|red|green|blue|gray|grey|yellow|orange|none)$/i.test(text)) {
+ try {
+ const v = getComputedStyle(document.documentElement).getPropertyValue('--' + text).trim();
+ return v || fallback;
+ } catch {
+ return fallback;
+ }
+ }
+ return text;
+}
+
+/** @param {CanvasRenderingContext2D} g @param {number} x @param {number} y @param {number} w @param {number} h @param {number} r */
+function roundRect(g, x, y, w, h, r) {
+ const rr = Math.max(0, Math.min(r, w / 2, h / 2));
+ g.beginPath();
+ g.moveTo(x + rr, y);
+ g.arcTo(x + w, y, x + w, y + h, rr);
+ g.arcTo(x + w, y + h, x, y + h, rr);
+ g.arcTo(x, y + h, x, y, rr);
+ g.arcTo(x, y, x + w, y, rr);
+ g.closePath();
+}
+
+/** @type {Map} */
+const images = new Map();
+let imageTick = 0;
+
+/** @typedef {{id: string, key: string, kind: string, x: number, y: number, w: number, h: number, footer?: string}} HitRect */
+
+/**
+ * Draw one menu screen + our footer into a 2D context sized (STAGE_W x (STAGE_H+FOOTER_H))
+ * * SCALE, and return the pressable rects in CANVAS pixels. `hover` rings the element the
+ * laser is on. Pure over its arguments (the DOM is only touched for token colours and
+ * image decode); exported for the suites.
+ * @param {CanvasRenderingContext2D} g @param {{key: string, screen: any} | null} entry
+ * @param {Record} runtime @param {{hover?: string | null, strip?: boolean, mode?: string, crop?: {x: number, y: number, w: number, h: number}}} [opts]
+ * @returns {HitRect[]}
+ */
+export function drawPanel(g, entry, runtime, opts = {}) {
+ const crop = opts.crop ?? { x: 0, y: 0, w: STAGE_W, h: STAGE_H };
+ const W = g.canvas.width;
+ const H = g.canvas.height;
+ const k = W / crop.w;
+ g.clearRect(0, 0, W, H);
+ // the board: a dark rounded plate, so a screen authored over the game view still reads
+ roundRect(g, 0, 0, W, H, 28 * k);
+ g.fillStyle = 'rgba(12, 16, 26, 0.9)';
+ g.fill();
+ g.lineWidth = 3 * k;
+ g.strokeStyle = 'rgba(148, 163, 184, 0.55)';
+ g.stroke();
+ /** @type {HitRect[]} */
+ const hits = [];
+ const elements = (entry?.screen?.elements ?? []).filter((/** @type {any} */ el) => isRenderableKind(el.kind));
+ const sorted = [...elements].sort((a, b) => (a.z ?? 0) - (b.z ?? 0));
+ g.save();
+ g.beginPath();
+ g.rect(0, 0, W, crop.h * k);
+ g.clip();
+ for (const el of sorted) {
+ const r = rectInFrame(el, STAGE_W, STAGE_H);
+ const x = (r.left - crop.x) * k;
+ const y = (r.top - crop.y) * k;
+ const w = r.w * k;
+ const h = r.h * k;
+ const style = el.style ?? {};
+ const rt = runtime?.[el.id] ?? null;
+ const text = rt?.text !== undefined && rt?.text !== null ? String(rt.text) : String(el.label ?? '');
+ const size = Math.max(10, Number(style.size ?? 14)) * k;
+ const weight = String(style.weight ?? (el.kind === 'button' ? 600 : 400));
+ const colour = paint(style.color, '#f3f4f6');
+ const disabled = el.enabled === false;
+ g.globalAlpha = Number(style.opacity ?? 1) * (disabled ? 0.45 : 1);
+ const bg = paint(style.bg, el.kind === 'button' || el.kind === 'toggle' || el.kind === 'dropdown' || el.kind === 'tabs' ? '#374151' : 'transparent');
+ const radius = Number(style.radius ?? (el.kind === 'button' ? 8 : 0)) * k;
+ if (bg !== 'transparent') {
+ roundRect(g, x, y, w, h, radius);
+ g.fillStyle = bg;
+ g.fill();
+ }
+ if (style.border) {
+ roundRect(g, x, y, w, h, radius);
+ g.lineWidth = 2 * k;
+ g.strokeStyle = paint(style.border, 'rgba(75,85,99,0.7)');
+ g.stroke();
+ }
+ g.fillStyle = colour;
+ g.font = `${weight} ${size}px system-ui, sans-serif`;
+ g.textBaseline = 'middle';
+ const align = el.kind === 'button' ? 'center' : String(style.align ?? 'left');
+ g.textAlign = align === 'center' ? 'center' : align === 'right' ? 'right' : 'left';
+ const pad = Number(style.pad ?? 0) * k;
+ const tx = align === 'center' ? x + w / 2 : align === 'right' ? x + w - pad - 4 * k : x + pad + 4 * k;
+ if (el.kind === 'bar' || el.kind === 'progressradial' || el.kind === 'slider') {
+ const value = Number(el.kind === 'slider' ? hudValueOf(el.id, el.value ?? el.min ?? 0) : rt?.value ?? el.value ?? 0);
+ const min = Number(rt?.min ?? el.min ?? 0);
+ const max = Number(rt?.max ?? el.max ?? (el.kind === 'slider' ? 100 : 1));
+ const pct = max - min > 1e-9 ? Math.min(1, Math.max(0, (value - min) / (max - min))) : 0;
+ roundRect(g, x, y + h * 0.3, w, h * 0.4, h * 0.2);
+ g.fillStyle = 'rgba(255,255,255,0.15)';
+ g.fill();
+ roundRect(g, x, y + h * 0.3, w * pct, h * 0.4, h * 0.2);
+ g.fillStyle = paint(style.color, '#ef562f');
+ g.fill();
+ if (text && el.kind !== 'slider') {
+ g.fillStyle = '#f3f4f6';
+ g.textAlign = 'center';
+ g.fillText(text, x + w / 2, y + h / 2);
+ }
+ } else if (el.kind === 'toggle') {
+ const on = !!hudValueOf(el.id, el.value);
+ roundRect(g, x + 6 * k, y + h * 0.2, h * 1.1, h * 0.6, h * 0.3);
+ g.fillStyle = on ? '#22c55e' : 'rgba(255,255,255,0.2)';
+ g.fill();
+ g.beginPath();
+ g.arc(x + 6 * k + (on ? h * 0.8 : h * 0.3), y + h / 2, h * 0.24, 0, Math.PI * 2);
+ g.fillStyle = '#fff';
+ g.fill();
+ g.fillStyle = colour;
+ g.textAlign = 'left';
+ g.fillText(text, x + h * 1.3 + 10 * k, y + h / 2);
+ } else if (el.kind === 'dropdown' || el.kind === 'tabs') {
+ const options = hudOptionsOf(el.id, el);
+ const held = hudValueOf(el.id, el.value ?? (el.kind === 'tabs' ? 0 : options[0]));
+ const shown = el.kind === 'tabs' ? options[Math.round(Number(held)) || 0] ?? '' : String(held ?? '');
+ g.textAlign = 'center';
+ g.fillText((text ? text + ': ' : '') + 'โน ' + shown + ' โบ', x + w / 2, y + h / 2);
+ } else if (el.kind === 'list') {
+ const rows = Array.isArray(rt?.rows) && rt.rows.length ? rt.rows : String(el.rowsText ?? '').split('\n').filter(Boolean);
+ const rowH = Number(el.rowHeight ?? 18) * k;
+ let yy = y + rowH / 2;
+ if (text) {
+ g.fillText(text, tx, yy);
+ yy += rowH;
+ }
+ for (const row of rows) {
+ if (yy > y + h) break;
+ g.fillText(String(row), tx, yy);
+ yy += rowH;
+ }
+ } else if (el.kind === 'image') {
+ const url = hudImageFor(String(el.src ?? ''));
+ if (!url && el.src) void resolveHudImage(String(el.src));
+ if (url) {
+ let img = images.get(url);
+ if (!img) {
+ img = new Image();
+ img.onload = () => (imageTick += 1);
+ img.src = url;
+ images.set(url, img);
+ }
+ if (img.complete && img.naturalWidth) g.drawImage(img, x, y, w, h);
+ }
+ } else if (el.kind === 'crosshair' || el.kind === 'minimap' || el.kind === 'debug' || el.kind === 'custom' || el.kind === 'damageflash') {
+ // a crosshair, a plot, a debug pill and a module's own DOM have no VR form here
+ } else if (text) {
+ // text, timer, button, panel, richtext and anything else that says something
+ const lines = el.wrap || el.kind === 'richtext' ? wrapText(g, text.replace(/\[[^\]]*\]/g, ''), w - 8 * k) : [text];
+ const lh = size * 1.25;
+ let yy = y + h / 2 - ((lines.length - 1) * lh) / 2;
+ for (const line of lines) {
+ g.fillText(line, tx, yy);
+ yy += lh;
+ }
+ }
+ g.globalAlpha = 1;
+ const pressable = isInteractiveKind(el.kind) && !disabled;
+ if (pressable) {
+ hits.push({ id: el.id, key: entry?.key ?? 'scene', kind: el.kind, x, y, w, h });
+ if (opts.hover === el.id) {
+ roundRect(g, x - 4 * k, y - 4 * k, w + 8 * k, h + 8 * k, radius + 4 * k);
+ g.lineWidth = 4 * k;
+ g.strokeStyle = '#5fd0ff';
+ g.stroke();
+ }
+ }
+ }
+ g.restore();
+ // the footer: OUR two buttons, always there
+ const fy = crop.h * k;
+ g.fillStyle = 'rgba(255,255,255,0.06)';
+ g.fillRect(0, fy, W, 2 * k);
+ const buttons = [
+ { id: 'edit', label: 'Edit mode' },
+ { id: 'strip', label: opts.strip === false ? 'Top strip: off' : 'Top strip: on' }
+ ];
+ const bw = 300 * k;
+ const bh = 60 * k;
+ buttons.forEach((b, i) => {
+ 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.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)';
+ g.stroke();
+ g.fillStyle = '#f3f4f6';
+ g.font = `600 ${26 * k}px system-ui, sans-serif`;
+ g.textAlign = 'center';
+ g.textBaseline = 'middle';
+ g.fillText(b.label, bx + bw / 2, by + bh / 2);
+ hits.push({ id: 'footer:' + b.id, key: '', kind: 'footer', footer: b.id, x: bx, y: by, w: bw, h: bh });
+ });
+ return hits;
+}
+
+/** a line cut to fit `max` pixels with an ellipsis @param {CanvasRenderingContext2D} g
+ * @param {string} text @param {number} max */
+function fitText(g, text, max) {
+ if (g.measureText(text).width <= max) return text;
+ let lo = 0;
+ let hi = text.length;
+ while (lo < hi) {
+ const mid = (lo + hi + 1) >> 1;
+ if (g.measureText(text.slice(0, mid) + 'โฆ').width <= max) lo = mid;
+ else hi = mid - 1;
+ }
+ return text.slice(0, lo).trimEnd() + 'โฆ';
+}
+
+/** the strip is one glanceable row: a sentence (a hint, an instruction) belongs on the
+ * wrist, not across the top of the view @param {string} line */
+export function stripWorthy(line) {
+ return line.length <= 28;
+}
+
+/** @param {CanvasRenderingContext2D} g @param {string} text @param {number} max */
+function wrapText(g, text, max) {
+ /** @type {string[]} */
+ const out = [];
+ for (const para of text.split('\n')) {
+ let line = '';
+ for (const word of para.split(/\s+/)) {
+ const next = line ? line + ' ' + word : word;
+ if (line && g.measureText(next).width > max) {
+ out.push(line);
+ line = word;
+ } else line = next;
+ }
+ if (line) out.push(line);
+ }
+ return out.slice(0, 12);
+}
+
+/**
+ * A compact card of lines (+ optional footer buttons) โ the wrist, the top strip and the
+ * banner are all this. Returns the pressable rects.
+ * @param {CanvasRenderingContext2D} g @param {string[]} lines
+ * @param {{title?: string, row?: boolean, buttons?: boolean, strip?: boolean, hover?: string | null, big?: boolean, color?: string}} [opts]
+ * @returns {HitRect[]}
+ */
+export function drawCard(g, lines, opts = {}) {
+ const W = g.canvas.width;
+ const H = g.canvas.height;
+ g.clearRect(0, 0, W, H);
+ roundRect(g, 0, 0, W, H, Math.min(24, H / 4));
+ g.fillStyle = 'rgba(12, 16, 26, 0.86)';
+ g.fill();
+ g.lineWidth = 2;
+ g.strokeStyle = 'rgba(148, 163, 184, 0.5)';
+ g.stroke();
+ g.fillStyle = opts.color ?? '#f3f4f6';
+ g.textBaseline = 'middle';
+ /** @type {HitRect[]} */
+ const hits = [];
+ if (opts.big) {
+ g.textAlign = 'center';
+ g.font = `800 ${Math.round(H * 0.42)}px system-ui, sans-serif`;
+ g.fillText(fitText(g, lines[0] ?? '', W - 40), W / 2, lines[1] ? H * 0.4 : H / 2);
+ if (lines[1]) {
+ g.font = `500 ${Math.round(H * 0.16)}px system-ui, sans-serif`;
+ g.fillStyle = '#e5e7eb';
+ g.fillText(lines[1], W / 2, H * 0.78);
+ }
+ return hits;
+ }
+ if (opts.row) {
+ g.textAlign = 'center';
+ g.font = `600 ${Math.round(H * 0.46)}px system-ui, sans-serif`;
+ g.fillText(fitText(g, lines.join(' ยท '), W - 24), W / 2, H / 2);
+ return hits;
+ }
+ const pad = 18;
+ const footer = opts.buttons ? 64 : 0;
+ const rows = Math.max(1, lines.length + (opts.title ? 1 : 0));
+ const rowH = Math.min(52, (H - footer - pad * 2) / rows);
+ let y = pad + rowH / 2;
+ g.textAlign = 'left';
+ if (opts.title) {
+ g.font = `700 ${Math.round(rowH * 0.62)}px system-ui, sans-serif`;
+ g.fillStyle = '#93c5fd';
+ g.fillText(opts.title, pad, y);
+ y += rowH;
+ }
+ g.fillStyle = '#f3f4f6';
+ g.font = `600 ${Math.round(rowH * 0.62)}px system-ui, sans-serif`;
+ for (const line of lines) {
+ g.fillText(fitText(g, line, W - pad * 2), pad, y);
+ y += rowH;
+ }
+ if (opts.buttons) {
+ const labels = [
+ { id: 'edit', label: 'Edit' },
+ { id: 'strip', label: opts.strip === false ? 'Strip off' : 'Strip on' }
+ ];
+ const bw = (W - pad * 3) / 2;
+ labels.forEach((b, i) => {
+ const bx = pad + i * (bw + pad);
+ const by = H - footer + 8;
+ roundRect(g, bx, by, bw, footer - 22, 10);
+ g.fillStyle = '#1f2937';
+ g.fill();
+ g.lineWidth = opts.hover === 'footer:' + b.id ? 4 : 2;
+ g.strokeStyle = opts.hover === 'footer:' + b.id ? '#5fd0ff' : 'rgba(148,163,184,0.6)';
+ g.stroke();
+ g.fillStyle = '#f3f4f6';
+ g.font = `600 22px system-ui, sans-serif`;
+ g.textAlign = 'center';
+ g.fillText(b.label, bx + bw / 2, by + (footer - 22) / 2);
+ hits.push({ id: 'footer:' + b.id, key: '', kind: 'footer', footer: b.id, x: bx, y: by, w: bw, h: footer - 22 });
+ });
+ }
+ return hits;
+}
+
+/* ------------------------------------------------------------------ the meshes ----- */
+
+/**
+ * @typedef {{name: string, mesh: THREE.Mesh, canvas: HTMLCanvasElement, g: CanvasRenderingContext2D,
+ * texture: THREE.CanvasTexture, w: number, h: number, hits: HitRect[], sig: string}} Surface
+ */
+
+/** @type {Record} */
+const surfaces = {};
+
+/** @param {string} name @param {number} pxW @param {number} pxH @param {number} worldW */
+function surface(name, pxW, pxH, worldW) {
+ let s = surfaces[name];
+ const scene = /** @type {any} */ (get(globalScene));
+ if (!s) {
+ const canvas = document.createElement('canvas');
+ canvas.width = pxW;
+ canvas.height = pxH;
+ const g = /** @type {CanvasRenderingContext2D} */ (canvas.getContext('2d'));
+ const texture = new THREE.CanvasTexture(canvas);
+ texture.colorSpace = THREE.SRGBColorSpace;
+ texture.anisotropy = 4;
+ const worldH = (worldW * pxH) / pxW;
+ const mesh = new THREE.Mesh(
+ new THREE.PlaneGeometry(worldW, worldH),
+ new THREE.MeshBasicMaterial({ map: texture, transparent: true, depthTest: false, depthWrite: false, side: THREE.DoubleSide })
+ );
+ mesh.name = name;
+ mesh.renderOrder = 1000;
+ mesh.visible = false;
+ mesh.frustumCulled = false;
+ mesh.userData.localOnly = true;
+ s = { name, mesh, canvas, g, texture, w: worldW, h: worldH, hits: [], sig: '' };
+ surfaces[name] = s;
+ }
+ if (scene && s.mesh.parent !== scene) scene.add(s.mesh);
+ return s;
+}
+
+/** give a surface a new canvas size and world size (a new crop) @param {Surface} s
+ * @param {number} pxW @param {number} pxH @param {number} worldW */
+function resizeSurface(s, pxW, pxH, worldW) {
+ if (s.canvas.width === pxW && s.canvas.height === pxH && Math.abs(s.w - worldW) < 1e-6) return;
+ s.canvas.width = pxW;
+ s.canvas.height = pxH;
+ // a CanvasTexture keeps its GPU allocation at the OLD size unless it is disposed
+ s.texture.dispose();
+ s.texture.needsUpdate = true;
+ s.w = worldW;
+ s.h = (worldW * pxH) / pxW;
+ s.mesh.geometry.dispose();
+ s.mesh.geometry = new THREE.PlaneGeometry(s.w, s.h);
+}
+
+/* -------------------------------------------------------------------- the state ---- */
+
+/** LOCAL: the head-locked strip, on by default */
+function stripOn() {
+ return safeStorage.getItem(STRIP_KEY) !== 'false';
+}
+/** @param {boolean} on */
+export function setVrStrip(on) {
+ safeStorage.setItem(STRIP_KEY, on ? 'true' : 'false');
+}
+
+const panelYaw = { value: NaN, following: false };
+/** the laser's current panel target per controller slot, for the hover ring */
+const hover = /** @type {(string | null)[]} */ ([null, null]);
+const debug = { presses: 0, pokes: 0, lastPress: /** @type {string | null} */ (null), edits: 0 };
+
+const _head = new THREE.Vector3();
+const _headQ = new THREE.Quaternion();
+const _fwd = new THREE.Vector3();
+const _up = new THREE.Vector3();
+const _v = new THREE.Vector3();
+const _e = new THREE.Euler();
+
+/** the headset's world pose @returns {{position: THREE.Vector3, quaternion: THREE.Quaternion} | null} */
+function headPose() {
+ const renderer = /** @type {any} */ (get(globalRenderer));
+ if (!renderer?.xr?.isPresenting) return null;
+ const cam = renderer.xr.getCamera();
+ cam.updateMatrixWorld?.(true);
+ cam.getWorldPosition(_head);
+ cam.getWorldQuaternion(_headQ);
+ return { position: _head, quaternion: _headQ };
+}
+
+/** @param {THREE.Quaternion} q yaw of a quaternion (radians) */
+function yawOf(q) {
+ _e.setFromQuaternion(q, 'YXZ');
+ return _e.y;
+}
+
+/**
+ * Where the panel should yaw: it FOLLOWS lazily, with HYSTERESIS โ a glance inside
+ * FOLLOW_DEG leaves it alone, and once the head has turned past that the panel eases ALL
+ * the way back in front (stopping at SETTLE_DEG). Without the second half a big turn left
+ * it parked FOLLOW_DEG off to the side, forever. Pure over (current, head, dt, state);
+ * `state.following` carries the latch between frames. Exported.
+ * @param {number} current @param {number} head @param {number} dt seconds
+ * @param {{following: boolean}} state
+ * @returns {number}
+ */
+export function followYaw(current, head, dt, state) {
+ if (!Number.isFinite(current)) {
+ state.following = false;
+ return head;
+ }
+ let d = head - current;
+ d = Math.atan2(Math.sin(d), Math.cos(d));
+ const off = Math.abs(d);
+ if (!state.following && off > (FOLLOW_DEG * Math.PI) / 180) state.following = true;
+ if (state.following && off < (SETTLE_DEG * Math.PI) / 180) state.following = false;
+ if (!state.following) return current;
+ return current + d * Math.min(1, dt * 3);
+}
+
+/**
+ * One frame of the VR game UI. `opts.head` injects a head pose and `opts.hands` the two
+ * controller world poses โ the suites drive both; the live frame reads the XR camera and
+ * the controllers. Returns what is on show.
+ * @param {{head?: {position: any, quaternion: any} | null, hands?: ({position: any, quaternion: any} | null)[], dt?: number, force?: boolean}} [opts]
+ */
+export function vrGamePanelFrame(opts = {}) {
+ const head = opts.head === undefined ? headPose() : opts.head;
+ const live = !!head && (opts.force || gameFeelActive());
+ const { panel, overlay } = live ? vrScreens() : { panel: [], overlay: [] };
+ const runtime = get(hudRuntime);
+ const strip = stripOn();
+ void get(hudDocs);
+ void get(hudScreenOverride);
+ void get(gameState);
+
+ // ---- the panel
+ const entry = panel[0] ?? null;
+ const crop = panelCrop(entry?.screen);
+ const board = surface('vr-game-panel', Math.round(crop.w * SCALE), Math.round((crop.h + FOOTER_H) * SCALE), crop.w * METRES_PER_PX);
+ board.mesh.visible = !!entry;
+ if (entry && head) {
+ const sig = JSON.stringify([entry.key, entry.screen, runtime, hover, strip, imageTick, valuesSig(entry.screen)]);
+ if (sig !== board.sig) {
+ board.sig = sig;
+ resizeSurface(board, Math.round(crop.w * SCALE), Math.round((crop.h + FOOTER_H) * SCALE), crop.w * METRES_PER_PX);
+ board.hits = drawPanel(board.g, entry, runtime, { hover: hover[0] ?? hover[1], strip, crop });
+ board.texture.needsUpdate = true;
+ }
+ const headYaw = yawOf(head.quaternion);
+ panelYaw.value = followYaw(panelYaw.value, headYaw, opts.dt ?? 1 / 72, panelYaw);
+ _fwd.set(-Math.sin(panelYaw.value), 0, -Math.cos(panelYaw.value));
+ board.mesh.position.copy(head.position).addScaledVector(_fwd, PANEL_DIST);
+ board.mesh.position.y = head.position.y - PANEL_DROP;
+ board.mesh.rotation.set(-0.12, panelYaw.value, 0, 'YXZ');
+ board.mesh.updateMatrixWorld(true);
+ } else if (!entry) panelYaw.value = NaN; // a new menu appears straight ahead
+
+ // ---- the wrist + the strip (the overlay lines; the wrist is there even with none)
+ const lines = overlayLines(
+ overlay.flatMap((o) => o.screen.elements ?? []),
+ runtime
+ );
+ const wrist = surface('vr-game-wrist', 320, 300, WRIST_W);
+ const leftHand = opts.hands?.[0] ?? null;
+ if (live && leftHand) {
+ const sig = JSON.stringify([lines, strip, hover]);
+ if (sig !== wrist.sig) {
+ // a line count change re-sizes the card
+ wrist.sig = sig;
+ wrist.hits = drawCard(wrist.g, lines.length ? lines : ['No score yet'], { title: 'Game', buttons: true, strip, hover: hover[0] ?? hover[1] });
+ wrist.texture.needsUpdate = true;
+ }
+ // up the forearm, facing up off it โ clear of 30b-vr-modes' mode label, which sits on
+ // the same controller at (0, 0.03, 0.07)
+ _v.set(-0.01, 0.035, 0.21).applyQuaternion(leftHand.quaternion).add(leftHand.position);
+ wrist.mesh.position.copy(_v);
+ wrist.mesh.quaternion.copy(leftHand.quaternion).multiply(WRIST_TILT);
+ wrist.mesh.updateMatrixWorld(true);
+ // shown when the card's face points at the head ("turn the wrist to read")
+ _up.set(0, 0, 1).applyQuaternion(wrist.mesh.quaternion);
+ _v.copy(head ? head.position : _v).sub(wrist.mesh.position).normalize();
+ wrist.mesh.visible = _up.dot(_v) > 0.35;
+ } else wrist.mesh.visible = false;
+
+ const stripLines = lines.filter(stripWorthy);
+ const bar = surface('vr-game-strip', 1024, 72, 0.9);
+ bar.mesh.visible = live && strip && stripLines.length > 0;
+ if (bar.mesh.visible && head) {
+ const sig = JSON.stringify(stripLines);
+ if (sig !== bar.sig) {
+ bar.sig = sig;
+ drawCard(bar.g, stripLines, { row: true });
+ bar.texture.needsUpdate = true;
+ }
+ headLocked(bar.mesh, head, 1.3, 0.36);
+ }
+
+ // ---- the announce banner (head-locked; shown in any mode while presenting)
+ const note = get(gameAnnouncement);
+ const banner = surface('vr-game-announce', 1024, 256, 0.95);
+ banner.mesh.visible = !!head && !!note;
+ if (banner.mesh.visible && head && note) {
+ const sig = JSON.stringify([note.id, note.text, note.sub, note.color]);
+ if (sig !== banner.sig) {
+ banner.sig = sig;
+ drawCard(banner.g, [note.text, note.sub].filter(Boolean), { big: true, color: note.color });
+ banner.texture.needsUpdate = true;
+ }
+ headLocked(banner.mesh, head, 1.5, 0.12);
+ const left = note.at + note.ms - Date.now();
+ /** @type {any} */ (banner.mesh.material).opacity = Math.max(0, Math.min(1, left / 300));
+ }
+ return {
+ panel: board.mesh.visible ? entry?.screen?.id ?? null : null,
+ lines,
+ stripLines,
+ wrist: wrist.mesh.visible,
+ strip: bar.mesh.visible,
+ banner: banner.mesh.visible
+ };
+}
+
+const WRIST_TILT = new THREE.Quaternion().setFromEuler(new THREE.Euler(-Math.PI / 2, 0, 0));
+
+/** @param {any} screen the values a menu's inputs hold (a toggle flip must redraw) */
+function valuesSig(screen) {
+ return (screen?.elements ?? []).filter((/** @type {any} */ el) => isInteractiveKind(el.kind)).map((/** @type {any} */ el) => hudValueOf(el.id, el.value));
+}
+
+/** @param {THREE.Object3D} mesh @param {{position: any, quaternion: any}} head @param {number} dist @param {number} up */
+function headLocked(mesh, head, dist, up) {
+ _fwd.set(0, 0, -1).applyQuaternion(head.quaternion);
+ _up.set(0, 1, 0).applyQuaternion(head.quaternion);
+ mesh.position.copy(head.position).addScaledVector(_fwd, dist).addScaledVector(_up, up);
+ mesh.quaternion.copy(head.quaternion);
+ mesh.updateMatrixWorld(true);
+}
+
+/* ---------------------------------------------------------------- interaction ----- */
+
+/** @typedef {{surface: string, hit: HitRect, point: THREE.Vector3, distance: number}} PanelTarget */
+
+/** @param {Surface} s @param {THREE.Vector2} uv @returns {HitRect | null} */
+function hitAtUv(s, uv) {
+ const px = uv.x * s.canvas.width;
+ const py = (1 - uv.y) * s.canvas.height;
+ return s.hits.find((r) => px >= r.x && px <= r.x + r.w && py >= r.y && py <= r.y + r.h) ?? null;
+}
+
+/**
+ * What a ray points at on the panel or the wrist โ a pressable rect or null. Exported for
+ * the sweep (P4) and the suites.
+ * @param {THREE.Raycaster} ray @returns {PanelTarget | null}
+ */
+export function panelTargetAlong(ray) {
+ /** @type {PanelTarget | null} */
+ let best = null;
+ for (const name of ['vr-game-panel', 'vr-game-wrist']) {
+ const s = surfaces[name];
+ if (!s?.mesh.visible) continue;
+ const hit = ray.intersectObject(s.mesh, false)[0];
+ if (!hit?.uv) continue;
+ const rect = hitAtUv(s, hit.uv);
+ if (rect && (!best || hit.distance < best.distance)) best = { surface: name, hit: rect, point: hit.point.clone(), distance: hit.distance };
+ }
+ return best;
+}
+
+/**
+ * The laser's hover ring for slot `index` (redraws the board when it changes).
+ * @param {number} index @param {THREE.Raycaster | null} ray @returns {PanelTarget | null}
+ */
+export function panelHover(index, ray) {
+ const target = ray ? panelTargetAlong(ray) : null;
+ hover[index] = target?.hit.id ?? null;
+ return target;
+}
+
+/**
+ * PRESS a panel target โ the desktop press verbatim. Returns true when something acted.
+ * @param {PanelTarget} target @param {{source?: string, u?: number}} [opts]
+ */
+export function pressPanelTarget(target, opts = {}) {
+ const hit = target?.hit;
+ if (!hit) return false;
+ debug.presses++;
+ debug.lastPress = hit.id;
+ playGameSound('click', target.point ? target.point.toArray() : null);
+ if (hit.kind === 'footer') {
+ if (hit.footer === 'edit') {
+ debug.edits++;
+ // the ONE Edit/Interact switch (30b-vr-modes' Y button calls it too); the store
+ // write is the whole of leaving Interact, the rest re-seats a desktop gizmo
+ setEditorMode('edit');
+ } else if (hit.footer === 'strip') setVrStrip(!stripOn());
+ return true;
+ }
+ const el = elementOf(hit);
+ if (!el) return false;
+ if (el.kind === 'button') fireHudButton(el.id);
+ else if (el.kind === 'toggle') {
+ setHudValue(el.id, !hudValueOf(el.id, el.value), { shared: !!el.shared });
+ fireHudButton(el.id);
+ } else if (el.kind === 'slider') {
+ const min = Number(el.min ?? 0);
+ const max = Number(el.max ?? 100);
+ const step = Number(el.step || 1);
+ const u = Number.isFinite(opts.u) ? Number(opts.u) : 0.5;
+ const raw = min + (max - min) * Math.min(1, Math.max(0, u));
+ setHudValue(el.id, Math.round(raw / step) * step, { shared: !!el.shared });
+ } else if (el.kind === 'dropdown' || el.kind === 'tabs') {
+ const options = hudOptionsOf(el.id, el);
+ if (!options.length) return false;
+ if (el.kind === 'tabs') {
+ const at = Math.max(0, Math.round(Number(hudValueOf(el.id, el.value ?? 0))));
+ setHudValue(el.id, (at + 1) % options.length, { shared: !!el.shared });
+ } else {
+ const held = String(hudValueOf(el.id, el.value ?? options[0]));
+ setHudValue(el.id, options[(Math.max(0, options.indexOf(held)) + 1) % options.length], { shared: !!el.shared });
+ }
+ } else fireHudButton(el.id); // a module kind with sub-presses: its own id pulses
+ return true;
+}
+
+/** @param {HitRect} hit */
+function elementOf(hit) {
+ for (const { screen } of vrScreens().panel) {
+ const el = (screen.elements ?? []).find((/** @type {any} */ e) => e.id === hit.id);
+ if (el) return el;
+ }
+ return null;
+}
+
+/** the u (0..1 across the rect) of a panel target โ a slider's value @param {PanelTarget} target */
+export function uAcross(target) {
+ const s = surfaces[target.surface];
+ if (!s) return 0.5;
+ const local = s.mesh.worldToLocal(target.point.clone());
+ const px = (local.x / s.w + 0.5) * s.canvas.width;
+ return (px - target.hit.x) / Math.max(1, target.hit.w);
+}
+
+/* ------------------------------------------------------------------------ poke ---- */
+
+/** a tip is TOUCHING within this of the plane; it must back off past RELEASE to re-arm */
+const POKE_TOUCH = 0.018;
+const POKE_RELEASE = 0.04;
+const pokeArmed = [true, true];
+
+/**
+ * One frame of the fingertip / controller-tip POKE for slot `index`: pushing the tip
+ * through a button presses it once; pulling back re-arms. Returns the pressed target.
+ * @param {number} index @param {THREE.Vector3 | null} tip world position
+ * @returns {PanelTarget | null}
+ */
+export function pokeFrame(index, tip) {
+ if (!tip) return null;
+ let pressed = null;
+ let nearest = Infinity;
+ for (const name of ['vr-game-panel', 'vr-game-wrist']) {
+ const s = surfaces[name];
+ if (!s?.mesh.visible) continue;
+ const local = s.mesh.worldToLocal(tip.clone());
+ if (Math.abs(local.x) > s.w / 2 || Math.abs(local.y) > s.h / 2) continue;
+ const depth = Math.abs(local.z);
+ nearest = Math.min(nearest, depth);
+ if (depth > POKE_TOUCH || !pokeArmed[index]) continue;
+ const uv = new THREE.Vector2(local.x / s.w + 0.5, local.y / s.h + 0.5);
+ const rect = hitAtUv(s, uv);
+ if (!rect) continue;
+ const target = { surface: name, hit: rect, point: tip.clone(), distance: 0 };
+ pokeArmed[index] = false;
+ debug.pokes++;
+ pressPanelTarget(target, { source: 'poke', u: uAcross(target) });
+ pressed = target;
+ }
+ if (nearest > POKE_RELEASE) pokeArmed[index] = true;
+ return pressed;
+}
+
+/** @returns {{presses: number, pokes: number, lastPress: string | null, edits: number, hover: (string | null)[], strip: boolean, hits: Record}} */
+export function vrGamePanelDebug() {
+ /** @type {Record} */
+ const hits = {};
+ for (const [name, s] of Object.entries(surfaces)) hits[name] = s.hits.map((h) => ({ ...h }));
+ return { ...debug, hover: [...hover], strip: stripOn(), hits };
+}
+
+/** the surfaces' meshes (suites read visibility, poses and canvases) @param {string} name */
+export function vrGameSurface(name) {
+ const s = surfaces[name];
+ return s ? { mesh: s.mesh, canvas: s.canvas } : null;
+}
+
+/** Hide everything (the session ended, the player left the game). */
+export function hideVrGamePanel() {
+ for (const s of Object.values(surfaces)) s.mesh.visible = false;
+ hover[0] = hover[1] = null;
+ panelYaw.value = NaN;
+}
diff --git a/src/lib/vrGrip.js b/src/lib/vrGrip.js
new file mode 100644
index 00000000..a4aaba3a
--- /dev/null
+++ b/src/lib/vrGrip.js
@@ -0,0 +1,74 @@
+// 30b P2: WHAT A VR GRIP TAKES HOLD OF โ a pure leaf (imports nothing), so the rule is
+// unit-tested with no headset and no scene.
+//
+// THE FINDING (the Quest report: "when in game and in edit mode I should be able to move
+// around the world and scale it with grips, now it disables this for some reason"). No
+// code disabled the world grab. It fires only when a grip closes on EMPTY AIR, and a game
+// scene has none: Stars Room is a closed 12 m room (floor, four walls, a ceiling), the
+// football pitch sits in a glass box, Towers stands on a 26 m floor ringed by walls. Every
+// controller ray ends on one of them, and the hand-inside test (100.3) is no better in a
+// room-sized mesh, so every grip GRABBED THE ROOM โ the wall moved with your hand, the world
+// never did. A "normal" editor scene has sky behind everything, which is why it only broke
+// in games.
+//
+// THE RULE: SCENERY is not held by a grip. An object is scenery when its world bounds
+// reach SCENERY_EXTENT on any axis (a floor, a wall, a ceiling, a pitch) or the viewer's
+// HEAD is inside them (you are standing in it โ a room mesh, an arena). A grip ray passes
+// through scenery to what is behind it, and a grip that finds nothing else is empty air โ
+// which in EDIT is the world gesture again, everywhere. Scenery still moves in Edit through
+// every other path (the trigger selects it; the gizmo, the props panel and the menus act on
+// the selection).
+//
+// And by MODE (contract C1): EDIT holds anything that is not scenery; INTERACT holds only
+// what a player may hold โ a dynamic physics body, under a play block whose interaction is
+// 'grab' โ and NEVER moves the world. The first non-scenery hit decides: in Interact a
+// static podium in front of a ball blocks the grab, the way a wall blocks your hand.
+
+/** metres: a world-bounds extent at or past this on any axis makes an object scenery */
+export const SCENERY_EXTENT = 4;
+
+/**
+ * @param {{min: {x: number, y: number, z: number}, max: {x: number, y: number, z: number}} | null} box
+ * the object's WORLD bounds (null / empty = nothing to hold, never scenery)
+ * @param {{x: number, y: number, z: number} | null} head the viewer's head in world space
+ * @returns {boolean}
+ */
+export function isScenery(box, head) {
+ if (!box || !box.min || !box.max) return false;
+ const dx = box.max.x - box.min.x;
+ const dy = box.max.y - box.min.y;
+ const dz = box.max.z - box.min.z;
+ if (!(dx >= 0 && dy >= 0 && dz >= 0)) return false; // empty / NaN bounds
+ if (Math.max(dx, dy, dz) >= SCENERY_EXTENT) return true;
+ if (!head) return false;
+ return (
+ head.x >= box.min.x && head.x <= box.max.x &&
+ head.y >= box.min.y && head.y <= box.max.y &&
+ head.z >= box.min.z && head.z <= box.max.z
+ );
+}
+
+/**
+ * Which candidate a grip takes, in ray order.
+ * @param {{scenery: boolean, grabbable: boolean}[]} candidates top-level objects along the
+ * ray, nearest first (each once)
+ * @param {'edit' | 'interact'} mode
+ * @returns {number} the index taken, or -1 for "nothing" (empty air in Edit = the world)
+ */
+export function pickGripTarget(candidates, mode) {
+ for (let i = 0; i < candidates.length; i++) {
+ const c = candidates[i];
+ if (!c || c.scenery) continue;
+ if (mode === 'interact') return c.grabbable ? i : -1;
+ return i;
+ }
+ return -1;
+}
+
+/**
+ * Does an empty-air grip move the world in this mode? Only Edit's does.
+ * @param {'edit' | 'interact'} mode
+ */
+export function gripMovesWorld(mode) {
+ return mode !== 'interact';
+}
diff --git a/src/lib/wireValidate.js b/src/lib/wireValidate.js
index 134cacc6..9ad3e21f 100644
--- a/src/lib/wireValidate.js
+++ b/src/lib/wireValidate.js
@@ -38,7 +38,17 @@ export function isVec3(v) {
/** A rotation on the wire is an Euler triple or a quaternion. @param {unknown} v */
export function isQuatOrEuler(v) {
- return isFiniteArray(v, 3) || isFiniteArray(v, 4);
+ return isFiniteArray(v, 3) || isFiniteArray(v, 4) || isEulerWithOrder(v);
+}
+
+/**
+ * three's `Euler.toArray()` is `[x, y, z, order]` โ the shape the gizmo, the Explorer drop,
+ * the Inspector and a dozen other senders put on the wire. The appliers read [0..2] only, so
+ * refusing it dropped every one of those moves on the receiving peer (`invalid:move`).
+ * @param {unknown} v
+ */
+export function isEulerWithOrder(v) {
+ return Array.isArray(v) && v.length === 4 && isFiniteArray(v.slice(0, 3), 3) && typeof v[3] === 'string' && /^[XYZ]{3}$/.test(v[3]);
}
/** @param {unknown} v */
diff --git a/src/stores/sceneStore.js b/src/stores/sceneStore.js
index 4ffec3b0..ed4d0f7c 100644
--- a/src/stores/sceneStore.js
+++ b/src/stores/sceneStore.js
@@ -41,6 +41,16 @@ export const isLocked = writable(null);
* @type {import("svelte/store").Writable}
*/
export const playPointerFree = writable(false);
+/**
+ * 30 P1: the editor's CLICK MODE, beside Play rather than inside it. 'edit' (the
+ * default): a viewport click SELECTS and the gizmo attaches. 'interact': a click reaches
+ * module click handlers, On Click nodes and the cursor grab โ play-style, without the
+ * pointer lock and without starting the game โ and selects nothing. LOCAL per peer:
+ * never replicated, never saved (the view-mode rule), so a reload is back in Edit.
+ * Written through objectActions.setEditorMode, which also puts the gizmo away.
+ * @type {import("svelte/store").Writable<'edit' | 'interact'>}
+ */
+export const editorMode = writable('edit');
export const isVRMode = writable(false);
export const vrOverride = writable(false);
export const playerCam = writable(false);
diff --git a/static/llms-full.txt b/static/llms-full.txt
index cb43b90b..4e569f61 100644
--- a/static/llms-full.txt
+++ b/static/llms-full.txt
@@ -282,6 +282,31 @@ api.registerClickHandler((object) => {
Handlers see desktop clicks and VR trigger presses (the exact mesh hit, not the
top-level group). Return `false` to let normal selection continue.
+**Modes (1.17).** The editor has two click modes, Edit (every click selects) and
+Interact (play-style clicks and drags without starting the game), plus Play. A
+handler runs in Interact and Play by default, so an Edit click on your game piece
+selects it like any object. Say so when you want something else:
+
+```js
+api.registerClickHandler(pressKey, { modes: ['interact', 'play'] }); // the default, spelled out
+api.registerClickHandler(pickTool, { modes: ['edit'] }); // an editor TOOL
+```
+
+In a headset the same modes apply (1.17, 30b): Play in VR enters Interact, where the
+trigger clicks (module handlers, On Click) and selects nothing; in Edit it selects as
+before. Handlers also get `ctx = {source, mode}` โ see *Game feel* below for the
+hold-and-sweep and `{sweep: false}`.
+`api.editorMode()` reads the mode on this screen (`'edit'` | `'interact'`) โ for a module
+whose own pointer listeners run outside click routing (a drag), so it can stand down in Edit.
+
+**Listed in the object list (1.17).** Content you build at the scene root shows in
+the object list's *Module content* section โ a read-only row that frames it, hides
+it locally, or opens your toolbox. Give it a readable name:
+
+```js
+api.registerListedGroup('piano-module', { label: 'Piano' });
+```
+
```js
api.registerFrameTask((time) => { /* runs every frame, synced time */ });
@@ -456,6 +481,14 @@ api.onInput((kind, code) => {}); // 'down'/'up' events; returns unsubscri
// 'locomotion' โ VR left-stick locomotion
api.claimInput('keys'); // ALWAYS release when your mode ends
api.releaseInput('keys');
+
+// reading a keydown YOURSELF (a toolbox's own handler)? resolve the key the way the
+// editor does โ `event.key` when it is an ASCII letter/digit, the physical
+// `event.code` position otherwise โ so it works on a Cyrillic/Greek/Hebrew layout:
+window.addEventListener('keydown', (e) => {
+ if (api.keyOf(e) === 'G') grab(); // 'G' on QWERTY, AZERTY, Dvorak AND ะะฆะฃะะะ
+ if (api.letterOf(e) === 'w') drive(); // lowercase form; named keys ('Escape') unchanged
+});
```
### Pointer ray (190)
@@ -473,6 +506,100 @@ api.registerFrameTask(() => {
});
```
+### Free-cursor games (1.17)
+
+A board, puzzle or instrument game can play with the real mouse cursor and no
+pointer lock: publish `userData.play.cursor = 'free'` on your scene-root group
+(or the scene's physics block sets `play.cursor: 'free'`). `api.pointerRay()` is
+then the cursor's ray in play; in a locked game it is the CROSSHAIR ray (the view's
+centre), never the stale mouse position the lock pinned.
+
+### Storage (1.17)
+
+```js
+// JSON on THIS device, namespaced to your module (tp:mod::), 256 KB per
+// module; never replicated, never saved into a scene, survives disable/remove.
+if (api.storage) {
+ const progress = api.storage.get('progress', { unlocked: 1 });
+ api.storage.set('progress', progress); // -> true, or false over the quota
+ api.storage.keys(); api.storage.bytes(); api.storage.remove('progress');
+ api.storage.clear(); // your "Reset progress"
+}
+```
+
+Feature-detect it; on an older app write the SAME key yourself
+(`localStorage['tp:mod::'] = JSON.stringify(value)`) so progress carries over.
+
+### Game feel: sound, music, haptics, effects, banners (1.17, roadmap 30b)
+
+Everything here is LOCAL to the device it runs on โ broadcast your own op
+(`api.send`) when every peer should hear, feel or see it โ and all of it is
+feature-detected, so a module written against it still loads on an older app.
+
+```js
+register(api) {
+ api.registerClickHandler((object, ctx) => {
+ if (!object.userData.ring) return false;
+ const at = object.getWorldPosition(new api.THREE.Vector3()).toArray();
+ api.playSound?.('success', at);
+ api.effects?.burst(at, { kind: 'sparkle' });
+ api.announce?.('Ring ' + object.userData.ring + ' reached', { sub: '+50' });
+ api.hapticPattern?.('success'); // both hands; 'left' | 'right' for one
+ return true;
+ });
+ api.game.onChange?.(() => (api.game.roundUnderway() ? api.music?.play('arcade') : api.music?.stop()));
+}
+```
+
+- **`api.playSound(name, position?)`** โ `true` when a sound started. Twenty
+ procedural game sounds, no assets: `click` `pop` `whoosh` `success` `fail` `hit`
+ `kick` `shoot` `laser` `explosion` `coin` `levelup` `goal` `whistle` `cheer`
+ `step` `ring` `sparkle` `hurt` `portal`, plus the ping chimes `ding` (the default),
+ `chime`, `pluck`, `bell`. An unknown name is a quiet no-op (it used to play the
+ ding). `position` spatialises it. Played at the player's "Game sounds" volume.
+- **`api.music.play(preset, {volume?})` / `api.music.stop()`** โ a looping
+ procedural track under the effects, at the player's "Music" volume: `arcade`
+ `ambient` `dungeon` `stadium` `space` `puzzle` `studio`. It is TEMPO-SYNCED to the
+ session clock (two peers on one preset hear the same bar), plays only in Interact
+ or Play (`false` from the editor) and stops by itself when the player goes back to
+ editing. `api.music.current()` names what is playing; `api.music.presets()` lists them.
+- **`api.hapticPattern(name, hand?)`** โ `tap` `bump` `hit` `success` `fail`
+ `rumble` `heartbeat` on the VR controllers. `api.haptic(intensity, ms, hand?)`
+ still exists. BOTH are silent in Edit mode (vibration is for playing), and core
+ already plays the defaults for you in Interact/Play: a `tap` when the laser enters
+ something clickable, a `bump` when a press lands, a `hit` on a grab, and a knock
+ sized by how hard the hand hit.
+- **`api.effects.burst([x, y, z], {kind?, color?, count?})`** โ a short, pooled
+ particle burst at the scene root: `sparkle` (default), `confetti`, `smoke`,
+ `sparks`; `count` 1..96. Nothing is saved or replicated.
+- **`api.announce(text, {sub?, ms?, color?})`** โ a big centred banner ("GOAL!",
+ "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).
+
+**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
+work with the laser and trigger and with a poke. The other screens (score, timer,
+level) read as short lines on a card on the left wrist and a strip across the top
+of the view. The board and the wrist card always carry an **Edit mode** button.
+Nothing to do on your side โ author the HUD once and it works on both.
+
+**Hold the trigger and sweep (VR, Interact/Play).** While the trigger is held, the
+controller TIP clicks each clickable it passes over โ the one it is NEAREST to, so a
+glide along a keyboard plays one key at a time โ once, and again after it leaves and
+comes back: piano keys, pads, toggles, HUD buttons; the laser does the same for things
+out of reach. "Clickable" (also what earns the hover `tap`) means: under a group you
+passed to `registerInteractiveGroup`, a part of an audio device (`userData.device` on
+an ancestor โ each key/pad is its own control), an object an On Click node targets, or
+an object you mark `userData.clickable = true`. Your handler gets a second argument
+`ctx = {source, mode}`: `source` is `'click'` (desktop), `'trigger'` (the VR press
+itself) or `'sweep'` (a later entry while held). A control that must not be swept
+(a knob you drag, a dot you carry) opts out:
+
+```js
+api.registerClickHandler(pickDot, { sweep: false }); // presses still reach it; sweeps do not
+```
+
### Physics (P-A)
All mutations are INITIATOR-ONLY โ the peer that started the simulation steps
@@ -620,8 +747,9 @@ api.scene(); // THREE.Scene
api.objectsGroup(); // the replicated objects root (add scene content here)
api.peerId(); // our peer id (undefined before the mesh is up)
api.toast('hi'); // toast in the corner
-api.haptic(0.6, 60); // buzz the VR controllers (no-op on desktop);
+api.haptic(0.6, 60); // buzz the VR controllers (no-op on desktop and in Edit);
api.haptic(0.6, 60, 'right'); // optional hand targets one controller (17-A1)
+api.hapticPattern('success'); // a named pattern (1.17, see Game feel)
api.isVR(); // true inside a VR session
api.vrHand('left'); // one hand's WORLD pose + buttons, or null:
// {position, quaternion, trigger, gripped, connected}
diff --git a/tests/e2e/author-kit.test.cjs b/tests/e2e/author-kit.test.cjs
new file mode 100644
index 00000000..ea204515
--- /dev/null
+++ b/tests/e2e/author-kit.test.cjs
@@ -0,0 +1,419 @@
+// 30 author-kit: what a template/game DEF can say. The builder (scripts/author-templates.cjs)
+// grew primitives, light kinds, physical/toon materials and object flags; each is only real
+// if it survives the .tpscene round trip, so this suite AUTHORS a def-under-test through the
+// real script (`--def --only --out `), then LOADS the written file back
+// through sessions.readSessionZip + applySession โ the Templates modal's own read path โ and
+// reads each object's geometry / material / userData / clip. One check per feature.
+//
+// Run: APP_URL=https://theprototype.app:5233/ npm run e2e -- author-kit
+// (the authoring pass drives its own headless browser against the same APP_URL)
+const h = require('./helpers.cjs');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const { execFileSync } = require('child_process');
+
+const SLUG = 'author-kit-test';
+
+/** The def-under-test: every new field once, on a named object. */
+const DEF = {
+ kind: 'template',
+ slug: SLUG,
+ title: 'Author kit test',
+ description: 'every author-kit field, once',
+ license: 'CC0-1.0',
+ author: 'theprototype',
+ objects: [
+ { type: 'box', name: 'Floor', color: 0x808890, size: [20, 0.2, 20], pos: [0, -0.1, 0] },
+ { type: 'box', name: 'Rounded', color: 0xd97706, size: [2, 1, 1.2], bevel: 0.2, pos: [-4, 0.5, 0] },
+ { type: 'capsule', name: 'Capsule', color: 0x3b82f6, r: 0.4, h: 1.2, pos: [-2, 1, 0] },
+ { type: 'plane', name: 'Plane', color: 0x22c55e, size: [2, 3], pos: [0, 1.5, -3] },
+ { type: 'ring', name: 'Ring', color: 0xeab308, r: 1, inner: 0.6, pos: [2, 1.5, -3] },
+ { type: 'icosahedron', name: 'Ico', color: 0xa855f7, r: 0.6, detail: 1, pos: [2, 0.6, 0] },
+ { type: 'dodecahedron', name: 'Dodeca', color: 0xec4899, r: 0.6, pos: [4, 0.6, 0] },
+ {
+ type: 'sphere',
+ name: 'Glass',
+ color: 0xffffff,
+ r: 0.6,
+ pos: [0, 0.6, 2],
+ clearcoat: 0.8,
+ clearcoatRoughness: 0.15,
+ transmission: 0.9,
+ thickness: 0.5,
+ ior: 1.45,
+ sheen: 0.4,
+ sheenColor: 0xff0000,
+ roughness: 0.1
+ },
+ { type: 'sphere', name: 'Plain physical', color: 0x999999, r: 0.3, pos: [-2, 0.3, 2], physical: true },
+ { type: 'box', name: 'Glow', color: 0x111111, size: [0.5, 0.5, 0.5], pos: [2, 0.25, 2], emissive: 0x00ffcc, emissiveIntensity: 2.5 },
+ { type: 'cone', name: 'Faceted', color: 0x999999, r: 0.4, h: 0.8, pos: [4, 0.4, 2], flatShading: true, side: 'double' },
+ { type: 'sphere', name: 'Toon', color: 0x44aa88, r: 0.5, pos: [-4, 0.5, 2], toon: true },
+ { type: 'box', name: 'Shell', color: 0xffffff, size: [8, 3, 0.1], pos: [0, 1.5, -5], opacity: 0.1, pick: 'through' },
+ { type: 'cylinder', name: 'Coin', color: 0xfacc15, r: 0.3, h: 0.05, pos: [-4, 1.5, -2], rot: [Math.PI / 2, 0, 0], anim: 'Turntable' },
+ { type: 'box', name: 'Door', color: 0x8b5a2b, size: [1, 2, 0.1], pos: [4, 1, -2], origin: [-0.5, 0, 0], anim: ['door', 'Pulse'] },
+ { type: 'sphere', name: 'Fountain', color: 0x999999, r: 0.2, pos: [0, 0.2, 4], particles: { preset: 'sparkles', count: 33 } },
+ { type: 'box', name: 'Brazier', color: 0x333333, size: [0.4, 0.4, 0.4], pos: [-2, 0.2, 4], particles: 'fire' },
+ { type: 'light', kind: 'spot', name: 'Spot', color: 0xffeecc, intensity: 20, angle: 0.5, penumbra: 0.4, pos: [0, 6, 4], target: [0, 0, 0] },
+ { type: 'light', kind: 'directional', name: 'Key', color: 0xffffff, intensity: 2, pos: [10, 14, 8], target: [0, 0, 0], shadowMapSize: 1024 },
+ { type: 'light', kind: 'hemisphere', name: 'Fill', color: 0xbbddff, groundColor: 0x332211, intensity: 0.6, pos: [0, 5, 0] },
+ { type: 'light', name: 'Bulb', color: 0xffaa55, intensity: 3, distance: 8, pos: [3, 2, 3] }
+ ]
+};
+
+/** Author the def through the real script into a fresh temp folder; returns the .tpscene
+ * bytes and the thumbnail bytes. A STRING is a slug the script already knows (a DEFS entry
+ * or a module-owned def from the sibling modules checkout).
+ * @param {any} def @param {string} tag */
+function author(def, tag) {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'author-kit-' + tag + '-'));
+ const slug = typeof def === 'string' ? def : def.slug;
+ const args = ['--only', slug, '--out', path.join(dir, 'out')];
+ if (typeof def !== 'string') {
+ const file = path.join(dir, 'def.json');
+ fs.writeFileSync(file, JSON.stringify(def));
+ args.unshift('--def', file);
+ }
+ const out = path.join(dir, 'out');
+ const script = path.join(__dirname, '../../scripts/author-templates.cjs');
+ const log = execFileSync('node', [script, ...args], {
+ env: { ...process.env, APP_URL: h.URL },
+ encoding: 'utf8',
+ timeout: 240000
+ });
+ console.log(log.trim().split('\n').filter((l) => /B, thumb|WARN|FATAL|PAGEERROR|GPU/.test(l)).join('\n'));
+ const section = ['games', 'templates', 'examples', 'contests'].find((d) => fs.existsSync(path.join(out, d, slug)));
+ if (!section) return { scene: Buffer.alloc(0), thumb: null };
+ const base = path.join(out, section, slug);
+ return {
+ scene: fs.readFileSync(path.join(base, 'scene.tpscene')),
+ thumb: fs.existsSync(path.join(base, 'thumb.webp')) ? fs.readFileSync(path.join(base, 'thumb.webp')) : null
+ };
+}
+
+/** Load a .tpscene into the page through the Templates modal's own read path. */
+async function load(page, bytes) {
+ return page.evaluate(async (b64) => {
+ const s = window.__stores;
+ const bin = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
+ const payload = await s.sessions.readSessionZip(bin.buffer);
+ if (!payload) return false;
+ await s.sessions.applySession(payload, { backup: false, replicate: false });
+ return true;
+ }, bytes.toString('base64'));
+}
+
+h.run(async () => {
+ // ---- P0: primitives, lights, materials, flags -------------------------------------
+ const authored = author(DEF, 'p0');
+ check(authored.scene.length > 1000, 'the def authored a .tpscene (' + authored.scene.length + ' B)');
+
+ const browser = await h.launch({ args: h.GPU_ARGS });
+ const peer = await h.setupPage(browser, 'author-kit', { context: { viewport: { width: 1540, height: 774 } } });
+ const page = peer.page;
+ await page.waitForFunction(() => !!window.__stores?.sessions && !!window.__stores?.animationPreview, { timeout: 30000 });
+ check(await load(page, authored.scene), 'the authored file loads through readSessionZip + applySession');
+ await page.waitForTimeout(800);
+
+ const r = await page.evaluate(() => {
+ const s = window.__stores;
+ /** @type {any} */ let group;
+ s.objectsGroup.subscribe((g) => (group = g))();
+ const by = (/** @type {string} */ n) => group.getObjectByName(n);
+ const geo = (/** @type {string} */ n) => by(n)?.geometry;
+ const mat = (/** @type {string} */ n) => by(n)?.material;
+ /** @type {any} */ let anims;
+ s.animationPreview.animations.subscribe((v) => (anims = v))();
+ const clipNames = (/** @type {string} */ n) => Object.values(anims[by(n)?.uuid]?.clips ?? {}).map((/** @type {any} */ c) => c.name);
+ const rounded = geo('Rounded');
+ rounded.computeBoundingBox();
+ const bb = rounded.boundingBox;
+ // a rounded box's corner is CUT: no vertex reaches the sharp corner's |x|+|y|+|z|
+ const pos = rounded.attributes.position.array;
+ let cornerMax = 0;
+ for (let i = 0; i < pos.length; i += 3) cornerMax = Math.max(cornerMax, Math.abs(pos[i]) + Math.abs(pos[i + 1]) + Math.abs(pos[i + 2]));
+ const aim = (/** @type {string} */ n, /** @type {number[]} */ t) => {
+ const l = by(n);
+ l.updateMatrixWorld(true);
+ const wp = l.getWorldPosition(new s.THREE.Vector3());
+ const fwd = new s.THREE.Vector3(0, 0, -1).applyQuaternion(l.getWorldQuaternion(new s.THREE.Quaternion()));
+ return fwd.dot(new s.THREE.Vector3(...t).sub(wp).normalize());
+ };
+ const key = by('Key');
+ const cam = key?.shadow?.camera;
+ return {
+ roundedType: rounded.type,
+ roundedVerts: rounded.attributes.position.count,
+ roundedSize: [bb.max.x - bb.min.x, bb.max.y - bb.min.y, bb.max.z - bb.min.z],
+ cornerMax,
+ capsule: geo('Capsule')?.type,
+ capsuleRadius: geo('Capsule')?.parameters?.radius,
+ plane: geo('Plane')?.type,
+ ring: geo('Ring')?.type,
+ ringInner: geo('Ring')?.parameters?.innerRadius,
+ ico: geo('Ico')?.type,
+ icoDetail: geo('Ico')?.parameters?.detail,
+ dodeca: geo('Dodeca')?.type,
+ glass: {
+ physical: !!mat('Glass')?.isMeshPhysicalMaterial,
+ clearcoat: mat('Glass')?.clearcoat,
+ clearcoatRoughness: mat('Glass')?.clearcoatRoughness,
+ transmission: mat('Glass')?.transmission,
+ thickness: mat('Glass')?.thickness,
+ ior: mat('Glass')?.ior,
+ sheen: mat('Glass')?.sheen,
+ sheenColor: mat('Glass')?.sheenColor?.getHexString()
+ },
+ plainPhysical: !!mat('Plain physical')?.isMeshPhysicalMaterial,
+ floorStandard: mat('Floor')?.type,
+ glow: { emissive: mat('Glow')?.emissive?.getHexString(), intensity: mat('Glow')?.emissiveIntensity },
+ faceted: { flat: mat('Faceted')?.flatShading, side: mat('Faceted')?.side, double: s.THREE.DoubleSide },
+ toon: !!mat('Toon')?.isMeshToonMaterial,
+ shellPick: by('Shell')?.userData?.pick,
+ floorPick: by('Floor')?.userData?.pick ?? null,
+ coinClips: clipNames('Coin'),
+ doorClips: clipNames('Door'),
+ doorOrigin: by('Door')?.userData?.origin,
+ fountain: by('Fountain')?.userData?.particles,
+ brazier: by('Brazier')?.userData?.particles?.preset,
+ spot: {
+ is: !!by('Spot')?.isSpotLight,
+ angle: by('Spot')?.angle,
+ penumbra: by('Spot')?.penumbra,
+ shadow: by('Spot')?.castShadow,
+ aim: aim('Spot', [0, 0, 0])
+ },
+ key: {
+ is: !!key?.isDirectionalLight,
+ shadow: key?.castShadow,
+ aim: aim('Key', [0, 0, 0]),
+ frustum: cam ? [cam.left, cam.right, cam.bottom, cam.top, cam.near, cam.far] : null,
+ mapSize: key?.shadow?.mapSize?.x
+ },
+ hemi: { is: !!by('Fill')?.isHemisphereLight, ground: by('Fill')?.groundColor?.getHexString() },
+ bulb: { is: !!by('Bulb')?.isPointLight, distance: by('Bulb')?.distance }
+ };
+ });
+ console.log(JSON.stringify(r));
+
+ // primitives
+ check(r.roundedType === 'BufferGeometry', 'bevel: the rounded box is baked into a plain BufferGeometry (ObjectLoader rebuilds it) โ ' + r.roundedType);
+ check(r.roundedVerts > 36, 'bevel: it carries the rounded tessellation (' + r.roundedVerts + ' verts)');
+ check(r.roundedSize.every((v, i) => Math.abs(v - [2, 1, 1.2][i]) < 1e-3), 'bevel: its outer size is still the authored size ' + r.roundedSize.map((v) => v.toFixed(3)));
+ check(r.cornerMax < 2.1 - 0.05, 'bevel: the corners are cut (max |x|+|y|+|z| ' + r.cornerMax.toFixed(3) + ' < a sharp 2.1)');
+ check(r.capsule === 'CapsuleGeometry' && Math.abs(r.capsuleRadius - 0.4) < 1e-6, 'capsule: CapsuleGeometry r=0.4 (' + r.capsule + ')');
+ check(r.plane === 'PlaneGeometry', 'plane: PlaneGeometry (' + r.plane + ')');
+ check(r.ring === 'RingGeometry' && Math.abs(r.ringInner - 0.6) < 1e-6, 'ring: RingGeometry inner 0.6 (' + r.ring + ')');
+ check(r.ico === 'IcosahedronGeometry' && r.icoDetail === 1, 'icosahedron: IcosahedronGeometry detail 1 (' + r.ico + ')');
+ check(r.dodeca === 'DodecahedronGeometry', 'dodecahedron: DodecahedronGeometry (' + r.dodeca + ')');
+ // materials
+ check(r.glass.physical, 'physical: a physical-only field makes a MeshPhysicalMaterial');
+ check(Math.abs(r.glass.clearcoat - 0.8) < 1e-6, 'physical: clearcoat 0.8 survives (' + r.glass.clearcoat + ')');
+ check(Math.abs(r.glass.clearcoatRoughness - 0.15) < 1e-6, 'physical: clearcoatRoughness 0.15 survives');
+ check(Math.abs(r.glass.transmission - 0.9) < 1e-6, 'physical: transmission 0.9 survives (' + r.glass.transmission + ')');
+ check(Math.abs(r.glass.thickness - 0.5) < 1e-6, 'physical: thickness 0.5 survives');
+ check(Math.abs(r.glass.ior - 1.45) < 1e-6, 'physical: ior 1.45 survives (' + r.glass.ior + ')');
+ check(Math.abs(r.glass.sheen - 0.4) < 1e-6 && r.glass.sheenColor === 'ff0000', 'physical: sheen 0.4 + sheenColor #ff0000 survive (' + r.glass.sheenColor + ')');
+ check(r.plainPhysical, 'physical: `physical: true` alone makes a MeshPhysicalMaterial');
+ check(r.floorStandard === 'MeshStandardMaterial', 'an object using none of it stays MeshStandardMaterial (' + r.floorStandard + ')');
+ check(r.glow.emissive === '00ffcc' && Math.abs(r.glow.intensity - 2.5) < 1e-6, 'emissive + emissiveIntensity 2.5 survive (' + JSON.stringify(r.glow) + ')');
+ check(r.faceted.flat === true, 'flatShading survives');
+ check(r.faceted.side === r.faceted.double, "side: 'double' survives as DoubleSide");
+ check(r.toon, 'toon: MeshToonMaterial');
+ // flags
+ check(r.shellPick === 'through', "pick: 'through' lands on userData.pick");
+ check(r.floorPick === null, 'an unflagged object carries no pick key');
+ check(r.coinClips.includes('Turntable'), 'anim: the Turntable preset is an authored clip on the coin (' + r.coinClips + ')');
+ check(r.doorClips.includes('Door') && r.doorClips.includes('Pulse'), 'anim: a list of presets, by key or by name (' + r.doorClips + ')');
+ check(Array.isArray(r.doorOrigin) && r.doorOrigin[0] === -0.5, 'origin: the hinge offset lands on userData.origin (' + JSON.stringify(r.doorOrigin) + ')');
+ check(r.fountain?.preset === 'sparkles' && r.fountain?.count === 33 && r.fountain?.sprite === 'star', 'particles: a preset + a patch (count 33) on userData.particles');
+ check(r.brazier === 'fire', "particles: a bare preset name ('fire')");
+ // lights
+ check(r.spot.is && Math.abs(r.spot.angle - 0.5) < 1e-6 && Math.abs(r.spot.penumbra - 0.4) < 1e-6, 'spot: SpotLight with angle 0.5 + penumbra 0.4');
+ check(r.spot.shadow === true, 'spot: casts shadows by default (createLight convention)');
+ check(r.spot.aim > 0.999, 'spot: aimed by rotation at its target (forward . to-target = ' + r.spot.aim.toFixed(4) + ')');
+ check(r.key.is && r.key.shadow === true, 'directional: DirectionalLight casting shadows');
+ check(r.key.aim > 0.999, 'directional: aimed at its target (' + r.key.aim.toFixed(4) + ')');
+ check(
+ !!r.key.frustum && r.key.frustum[1] > 5.5 && r.key.frustum[1] < 40 && r.key.frustum[4] > 0.1 && r.key.frustum[5] > r.key.frustum[4] + 10,
+ 'directional: its shadow frustum is FITTED to the scene, not three\'s default ยฑ5 (' + (r.key.frustum ?? []).map((v) => v.toFixed(1)) + ')'
+ );
+ check(r.key.mapSize === 1024, 'directional: shadowMapSize 1024 survives (' + r.key.mapSize + ')');
+ check(r.hemi.is && r.hemi.ground === '332211', 'hemisphere: HemisphereLight with its ground colour');
+ check(r.bulb.is && r.bulb.distance === 8, 'point: the existing point light is unchanged');
+
+ // ---- P1: a custom sky ---------------------------------------------------------------
+ const SKY = {
+ kind: 'template',
+ slug: 'author-kit-sky',
+ title: 'Author kit sky',
+ description: 'a custom env: gradient sky, fog, ground, sun, hemi',
+ license: 'CC0-1.0',
+ author: 'theprototype',
+ env: {
+ preset: 'custom',
+ exposure: 1.2,
+ background: { top: '#ff2020', bottom: 0x2020ff },
+ fog: { color: '#6070a0', near: 20, far: 90 },
+ ground: { color: '#3a5a2a' },
+ sun: { color: '#fff0dd', intensity: 2.2, dir: [1, 2, 0.5] },
+ hemi: { sky: '#cfe0ff', ground: 0x303820, intensity: 0.7 }
+ },
+ // looking steeply UP, so the whole frame is sky (no grid, no ground, no object)
+ view: { pos: [0, 1.6, 6], target: [0, 40, -12] },
+ objects: [{ type: 'box', name: 'Block', color: 0x999999, size: [1, 1, 1], pos: [0, 0.5, 0] }]
+ };
+ const sky = author(SKY, 'p1');
+ check(await load(page, sky.scene), 'the custom-sky file loads');
+ await page.waitForTimeout(1200);
+ const e = await page.evaluate(() => {
+ const s = window.__stores;
+ /** @type {any} */ let state;
+ s.environment.environment.subscribe((v) => (state = v))();
+ /** @type {any} */ let scene;
+ s.globalScene.subscribe((v) => (scene = v))();
+ /** @type {any} */ let renderer;
+ s.globalRenderer.subscribe((v) => (renderer = v))();
+ const ground = scene.getObjectByName('env-ground');
+ const catcher = scene.getObjectByName('env-shadow-catcher');
+ return {
+ preset: state.preset,
+ exposure: state.exposure,
+ custom: state.customPreset,
+ backgroundIsTexture: !!scene.background?.isTexture,
+ fog: scene.fog ? { color: scene.fog.color.getHexString(), near: scene.fog.near } : null,
+ groundVisible: !!ground?.visible,
+ groundColor: ground?.material?.color?.getHexString(),
+ catcherVisible: !!catcher?.visible,
+ rendererExposure: renderer?.toneMappingExposure
+ };
+ });
+ console.log(JSON.stringify(e));
+ const c = e.custom ?? {};
+ check(e.preset === 'custom' && Math.abs(e.exposure - 1.2) < 1e-9, "env: preset 'custom' with the def's exposure 1.2 as the STATE multiplier");
+ check(c.exposure === 1 && Math.abs(e.rendererExposure - 1.2) < 1e-6, 'env: exposure is applied once, not squared (renderer ' + e.rendererExposure + ')');
+ check(c.gradient?.top === '#ff2020' && c.gradient?.bottom === '#2020ff', 'env: the gradient {top, bottom} is saved (numbers become #hex)');
+ check(c.background === '#2020ff', 'env: `background` keeps a flat horizon colour beside the gradient (older peers read it)');
+ check(e.backgroundIsTexture, 'env: the live scene background is the gradient texture');
+ check(c.fog?.color === '#6070a0' && c.fog?.near === 20 && e.fog?.color === '6070a0', 'env: fog {color, near, far} is applied');
+ check(c.ground?.color === '#3a5a2a' && e.groundVisible && e.groundColor === '3a5a2a', 'env: the ground disc shows in the authored colour');
+ check(!e.catcherVisible, 'env: the shadow catcher stands down while the ground receives the shadows');
+ const sunDir = c.sun?.position ? c.sun.position.map((v) => v / Math.hypot(...c.sun.position)) : [];
+ const want = [1, 2, 0.5].map((v) => v / Math.hypot(1, 2, 0.5));
+ check(sunDir.length === 3 && sunDir.every((v, i) => Math.abs(v - want[i]) < 1e-3) && c.sun.intensity === 2.2 && c.sun.color === '#fff0dd', 'env: sun {color, intensity, dir} -> the rig sun position along dir');
+ check(c.hemi?.sky === '#cfe0ff' && c.hemi?.ground === '#303820' && c.hemi?.intensity === 0.7, 'env: hemi {sky, ground, intensity}');
+
+ // the pixels: the sky's upper band is red-heavy and its lower band blue-heavy
+ const frame = await h.grabFrame(peer);
+ const bands = await page.evaluate(
+ async ({ b64 }) => {
+ const img = new Image();
+ img.src = 'data:image/png;base64,' + b64;
+ await img.decode();
+ const canvas = document.createElement('canvas');
+ canvas.width = img.width;
+ canvas.height = img.height;
+ const ctx = canvas.getContext('2d');
+ ctx.drawImage(img, 0, 0);
+ // clear of the Connect pill (top centre) and the Controls HUD (bottom centre)
+ const band = (/** @type {number} */ y0, /** @type {number} */ y1) => {
+ const x0 = Math.round(img.width * 0.08);
+ const w = Math.round(img.width * 0.22);
+ const d = ctx.getImageData(x0, Math.round(img.height * y0), w, Math.round(img.height * (y1 - y0))).data;
+ const sum = [0, 0, 0];
+ for (let i = 0; i < d.length; i += 4) for (let k = 0; k < 3; k++) sum[k] += d[i + k];
+ return sum.map((v) => v / (d.length / 4));
+ };
+ return { top: band(0.18, 0.26), bottom: band(0.74, 0.82) };
+ },
+ { b64: frame.toString('base64') }
+ );
+ console.log('sky bands', JSON.stringify(bands));
+ check(bands.top[0] - bands.bottom[0] > 60, 'pixels: the upper sky is redder than the lower (R ' + bands.top[0].toFixed(0) + ' vs ' + bands.bottom[0].toFixed(0) + ')');
+ check(bands.bottom[2] - bands.top[2] > 60, 'pixels: the lower sky is bluer than the upper (B ' + bands.bottom[2].toFixed(0) + ' vs ' + bands.top[2].toFixed(0) + ')');
+
+ // ---- P2: a thumbnail that looks like the game -----------------------------------------
+ // The card is rendered with the SCENE's look โ its background (flat or gradient), fog,
+ // environment rig and authored lights โ not a private grey studio. Three measurements:
+ // ยท the SKY def's card (its `view` looks straight up) shows the authored gradient;
+ // ยท the P0 def's card (studio + its own lights) reads (centre luminance > 0.2);
+ // ยท the Waves def from the sibling modules checkout โ the 1690-byte near-blank case to
+ // beat โ shows its OWN sunset sky, not the old grey, and carries more picture.
+ // (Waves itself is dark: its live frames read 0.11-0.13 on this metric, so a truthful card
+ // cannot clear 0.2 until its look is raised โ QUESTIONS-30-author-kit fork 1.)
+ const stats = (/** @type {Buffer | null} */ webp) =>
+ page.evaluate(async (b64) => {
+ if (!b64) return null;
+ const img = new Image();
+ img.src = 'data:image/webp;base64,' + b64;
+ await img.decode();
+ const canvas = document.createElement('canvas');
+ canvas.width = img.width;
+ canvas.height = img.height;
+ const ctx = canvas.getContext('2d');
+ ctx.drawImage(img, 0, 0);
+ const mean = (/** @type {number} */ x, /** @type {number} */ y, /** @type {number} */ w, /** @type {number} */ hh) => {
+ const d = ctx.getImageData(Math.round(x), Math.round(y), Math.round(w), Math.round(hh)).data;
+ const sum = [0, 0, 0];
+ for (let i = 0; i < d.length; i += 4) for (let k = 0; k < 3; k++) sum[k] += d[i + k];
+ return sum.map((v) => v / (d.length / 4));
+ };
+ const W = img.width;
+ const H = img.height;
+ const c = mean(W / 4, H / 4, W / 2, H / 2);
+ return {
+ w: W,
+ h: H,
+ // the centre clip: the middle half on each axis, Rec.709 luma in 0..1
+ lum: (0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]) / 255,
+ top: mean(0, 0, W, H * 0.12),
+ bottom: mean(0, H * 0.88, W, H * 0.12),
+ corner: mean(4, 4, 24, 16)
+ };
+ }, webp ? webp.toString('base64') : null);
+
+ const skyCard = await stats(sky.thumb);
+ console.log('sky card', JSON.stringify(skyCard));
+ check(!!skyCard && skyCard.w === 480 && skyCard.h === 270, 'card: 480x270');
+ check(!!skyCard && skyCard.top[0] - skyCard.bottom[0] > 60 && skyCard.bottom[2] - skyCard.top[2] > 60,
+ 'card: the sky def card shows the authored GRADIENT (top R ' + skyCard?.top[0].toFixed(0) + ' / bottom B ' + skyCard?.bottom[2].toFixed(0) + ')');
+ const kitCard = await stats(authored.thumb);
+ console.log('kit card', JSON.stringify(kitCard));
+ check(!!kitCard && kitCard.lum > 0.2, 'card: a lit studio scene reads (centre luminance ' + kitCard?.lum.toFixed(3) + ' > 0.2)');
+
+ const wavesDef = [path.resolve(__dirname, '../../../modules'), path.resolve(__dirname, '../../../theprototype.app-modules')]
+ .map((root) => path.join(process.env.MODULES_REPO || root, 'modules/waves/waves.def.json'))
+ .find((f) => fs.existsSync(f));
+ if (!wavesDef) console.log('SKIP waves card: no modules/waves/waves.def.json in the sibling modules checkout');
+ else {
+ const wavesEnv = JSON.parse(fs.readFileSync(wavesDef, 'utf8')).env;
+ const waves = author('waves', 'p2');
+ const size = waves.thumb?.length ?? 0;
+ const card = await stats(waves.thumb);
+ // the sky the def AUTHORS: a custom sky's own background (a gradient's TOP colour โ the
+ // corner sampled is the card's top-left), else the named preset's (30-visuals-mod gave
+ // Waves a custom dusk gradient; the check predates it)
+ const authoredBg = typeof wavesEnv === 'object' && wavesEnv ? wavesEnv.background : null;
+ const want = await page.evaluate(({ preset, bg }) => {
+ const hexc = (bg && typeof bg === 'object' ? bg.top : bg) || window.__stores.environment.ENVIRONMENT_PRESETS[preset]?.background || '#000000';
+ const n = parseInt(String(hexc).replace('#', '').slice(0, 6), 16);
+ return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
+ }, { preset: wavesEnv?.preset ?? wavesEnv, bg: authoredBg });
+ console.log('waves card', size, JSON.stringify(card), 'scene background', want);
+ check(size > 1690 * 1.2, 'waves: the card carries more picture than the old 1690-byte render (' + size + ' B)');
+ check(!!card && card.corner.every((v, i) => Math.abs(v - want[i]) < 12),
+ "waves: the card's sky is the scene's OWN background " + JSON.stringify(want) + ', not a private grey (' + card?.corner.map((v) => v.toFixed(0)) + ')');
+ // QUESTIONS-30-author-kit fork 1: the 0.2 bar holds once Waves has a readable look of
+ // its own (30-visuals-mod) โ asserted only when the def carries that look
+ if (authoredBg) check(!!card && card.lum > 0.2, 'waves: with its own readable look the card reads (centre luminance ' + card?.lum.toFixed(3) + ' > 0.2)');
+ if (process.env.AUTHOR_KIT_SAVE && waves.thumb) fs.writeFileSync(process.env.AUTHOR_KIT_SAVE, waves.thumb);
+ }
+
+ await h.finish(browser);
+});
+
+/** @param {boolean} ok @param {string} label */
+function check(ok, label) {
+ h.check(ok, label);
+}
diff --git a/tests/e2e/console-hygiene.test.cjs b/tests/e2e/console-hygiene.test.cjs
new file mode 100644
index 00000000..10ae74ce
--- /dev/null
+++ b/tests/e2e/console-hygiene.test.cjs
@@ -0,0 +1,121 @@
+// 30 P0 โ CONSOLE HYGIENE. Three warnings every session printed, each a real fault in
+// miniature and each reported from the diagnostics log of a user's Quest:
+//
+// 1. `WebGLShadowMap: PCFSoftShadowMap has been deprecated. Using PCFShadowMap instead.`
+// threlte's