Skip to content
Merged
78 changes: 76 additions & 2 deletions MODULES.md
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,10 @@ api.registerClickHandler(pressKey, { modes: ['interact', 'play'] }); // the defa
api.registerClickHandler(pickTool, { modes: ['edit'] }); // an editor TOOL
```

The VR trigger has no editor mode yet and still offers every handler.
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.

Expand Down Expand Up @@ -478,6 +481,76 @@ if (api.storage) {
Feature-detect it; on an older app write the SAME key yourself
(`localStorage['tp:mod:<id>:<key>'] = 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
Expand Down Expand Up @@ -625,8 +698,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}
Expand Down
8 changes: 5 additions & 3 deletions src/App.svelte

Large diffs are not rendered by default.

17 changes: 15 additions & 2 deletions src/components/Scene.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,10 @@
// the annotation is TS syntax — a JSDoc @type cast is ignored here (the documented trap).
let knifeFrom: number[] | null = null;
import { peerScenes } from '$lib/peerScenes';
import { initVRControls, updateVRControls, raycastMenu, raycastPanel, raycastPalette, raycastProps, raycastPrefabs, raycastKeyboard, raycastChat, raycastEdit, raycastSnap, raycastSettings, raycastApprove, placePrefabGhost, vrFaceTrigger, vrVertexTrigger, vrVertexGrabStart, vrVertexGrabEnd, beginStretchSliderDrag, endStretchSliderDrag, executeVRMenuAction, resetWorldRig, onInputSourcesChange, worldToContentPose, boxSelectStart, boxSelectEnd, boxSelectActive, applyVRFrameRate, shouldSendHands, onHandPinchStart, onHandPinchEnd, pinchMenuToggledAt, firePingIfArmed, vrModuleTriggerStart, vrModuleTriggerEnd, vrModuleSelectSwallowed, handSnapshot, vrGrabbedUuid, hapticPulse } from '$lib/vrControls';
import { initVRControls, updateVRControls, raycastMenu, raycastPanel, raycastPalette, raycastProps, raycastPrefabs, raycastKeyboard, raycastChat, raycastEdit, raycastSnap, raycastSettings, raycastApprove, placePrefabGhost, vrFaceTrigger, vrVertexTrigger, vrVertexGrabStart, vrVertexGrabEnd, beginStretchSliderDrag, endStretchSliderDrag, executeVRMenuAction, resetWorldRig, onInputSourcesChange, worldToContentPose, boxSelectStart, boxSelectEnd, boxSelectActive, applyVRFrameRate, shouldSendHands, onHandPinchStart, onHandPinchEnd, pinchMenuToggledAt, firePingIfArmed, vrModuleTriggerStart, vrModuleTriggerEnd, vrModuleSelectSwallowed, handSnapshot, vrGrabbedUuid, hapticKnock } from '$lib/vrControls';
// 30b (vr-play): the game in your hands — hover/press haptics (P1), the sweep (P4)
import { startVrGameInput, stopVrGameInput } from '$lib/vrGameInput';
import { gameFeelActive } from '$lib/gameFeel';
import { vrKeyboardTarget } from '$lib/vrKeyboard';
import { measureMode, measureClick } from '$lib/measure';
import { pinsGroup, openAnnotation, showNotePins } from '$lib/annotationsHandler';
Expand Down Expand Up @@ -1386,6 +1389,13 @@
tempMatrix.identity().extractRotation(controller.matrixWorld);
selectionRaycaster.ray.origin.setFromMatrixPosition(controller.matrixWorld);
selectionRaycaster.ray.direction.set(0, 0, -1).applyMatrix4(tempMatrix);
// 30b (C3): in Interact/Play a VR press is a CLICK — module handlers, On Click,
// the miss — and selects nothing, ever (the desktop Interact rule); the sweep
// already fired anything it knew was clickable and swallowed this release
if (gameFeelActive()) {
interactClick(selectionRaycaster);
return;
}
raycastSelect();
};
// 182: hold-to-move a vertex — grab on trigger press, drop on release
Expand Down Expand Up @@ -1419,7 +1429,9 @@
// this seam rather than an import (knock.js stays off vrControls' 3500 lines),
// and the two "what am I holding" reads keep a probe off its own carried object.
// A2: the hand that hit gets a buzz — LOCAL, the same seam shape as the hand poses
startKnock({ hands: handSnapshot, heldUuids: () => [carriedUuid(), vrGrabbedUuid()], haptic: hapticPulse });
startKnock({ hands: handSnapshot, heldUuids: () => [carriedUuid(), vrGrabbedUuid()], haptic: hapticKnock });
// 30b: game feel in VR (a frame hook + a trigger hook through vrControls' registries)
startVrGameInput();

xrControllers.forEach((controller) => {
controller.addEventListener('select', onXRSelect);
Expand All @@ -1434,6 +1446,7 @@
offKeyProbe(); // 30 P1
stopPlayInteract(); // 21-B B3 (releases any carried body with zero velocity)
stopKnock(); // 24-A A1
stopVrGameInput(); // 30b
element.removeEventListener('pointerdown', onPointerDown);
element.removeEventListener('contextmenu', onContextMenu);
element.removeEventListener('webglcontextlost', onContextLost);
Expand Down
56 changes: 56 additions & 0 deletions src/components/hud/HudLayer.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@
import { claimInput, releaseInput, onInput } from '$lib/inputRuntime';
import { gamepadPrefs } from '$lib/gamepadPrefs';
import { fireHudButton } from '$lib/flowRuntime';
// 30b (vr-play): api.announce's banner — drawn here on the desktop, and by the VR game
// panel's head-locked banner in a headset (DOM is invisible there)
import { gameAnnouncement } from '$lib/gameAnnounce';

// 21-D5: WHICH documents are on screen — the scene HUD, plus the one keyed by the
// camera being looked through (attaching a HUD to a camera IS keying it by that
Expand Down Expand Up @@ -343,6 +346,15 @@
}
</script>

{#if $gameAnnouncement && !$isVRMode && layerAllowed}
{#key $gameAnnouncement.id}
<div id="game-announce" class="game-announce" role="status" aria-live="polite" style="--announce-ms: {$gameAnnouncement.ms}ms">
<div class="game-announce-title" style:color={$gameAnnouncement.color}>{$gameAnnouncement.text}</div>
{#if $gameAnnouncement.sub}<div class="game-announce-sub">{$gameAnnouncement.sub}</div>{/if}
</div>
{/key}
{/if}

{#if anyVisible}
<div id="hud-layer" class="hud-layer" class:hud-authoring={!playing} class:hud-inert={gameInEditor} data-authoring={!playing} data-inert={gameInEditor}>
{#each elements as el (el.__key + ':' + el.id)}
Expand Down Expand Up @@ -380,6 +392,50 @@
.hud-slot {
position: absolute;
}
/* 30b: api.announce — a big centred banner over the game, never clickable, gone by
itself (the store clears it; the fade only dresses the exit) */
.game-announce {
position: fixed;
left: 50%;
top: 32%;
transform: translate(-50%, -50%);
z-index: var(--z-hud, 45);
pointer-events: none;
text-align: center;
padding: 18px 40px;
border-radius: 18px;
background: rgb(10 14 22 / 0.72);
box-shadow: 0 10px 40px rgb(0 0 0 / 0.45);
animation: game-announce-in 220ms ease-out, game-announce-out 300ms ease-in calc(var(--announce-ms, 1800ms) - 300ms) forwards;
}
.game-announce-title {
font-size: clamp(32px, 6vw, 72px);
font-weight: 800;
letter-spacing: 0.02em;
line-height: 1.05;
text-shadow: 0 2px 12px rgb(0 0 0 / 0.6);
}
.game-announce-sub {
margin-top: 6px;
font-size: clamp(14px, 2vw, 22px);
color: #e5e7eb;
}
@keyframes game-announce-in {
from {
opacity: 0;
transform: translate(-50%, -50%) scale(0.85);
}
}
@keyframes game-announce-out {
to {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.game-announce {
animation: none;
}
}
/* 30 P1: a previewed GAME screen is a picture of the menu, not the menu — every click
goes through it to the viewport (the elements opt back INTO pointer events one by
one, so the override has to reach them) */
Expand Down
14 changes: 14 additions & 0 deletions src/components/menu/Settings.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -767,6 +770,17 @@
</span>
{/if}
</SettingRow>
<p class="ui-section-label">Sound</p>
<SettingRow name="Game sounds">
<svelte:fragment slot="control"><input id="setting-game-sound-volume" type="range" style="width: 100%" min="0" max="1" step="0.05" bind:value={$gameSoundVolume} aria-label="Game sounds volume" /></svelte:fragment>
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)}%
</SettingRow>
<SettingRow name="Music">
<svelte:fragment slot="control"><input id="setting-game-music-volume" type="range" style="width: 100%" min="0" max="1" step="0.05" bind:value={$gameMusicVolume} aria-label="Game music volume" /></svelte:fragment>
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)}%
</SettingRow>
<p class="ui-section-label">Notifications</p>
<SettingRow name="Welcome on start">
<svelte:fragment slot="control"><Toggle bind:checked={$showWelcomeOnStart} /></svelte:fragment>
Expand Down
Loading
Loading