diff --git a/MODULES.md b/MODULES.md index f72638a3..58c0b736 100644 --- a/MODULES.md +++ b/MODULES.md @@ -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. @@ -478,6 +481,76 @@ if (api.storage) { 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 @@ -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} diff --git a/scripts/author-templates.cjs b/scripts/author-templates.cjs index 97bcf029..b8503fd9 100644 --- a/scripts/author-templates.cjs +++ b/scripts/author-templates.cjs @@ -329,14 +329,40 @@ function towersGraph() { N('storedlast', 'storedvalue', 'This round', 1960, 1180, { key: 'towers-last', output: 'number', fallback: 0 }); N('hfinal', 'hudtext', 'HUD final height', 2200, 1220, { element: 'final-height', format: 'This round: {v} m', decimals: 0, value: 0 }); E('storedlast', 'hfinal', 'value'); - // reaching the top rung earns a sparkle burst on the pad - N('pfx', 'particle', '4m sparkle', 1000, 1400, { - mode: 'burst', count: 120, lifetime: 1.4, speed: 2.5, gravity: 0, - turbulence: 0.4, sizeStart: 0.12, opacity: 0.9, sprite: 'star', blending: 'additive', space: 'world' - }); - N('selpad', 'objectselector', 'Build pad', 1240, 1400, { selected: 'Build pad' }); - E('enr4', 'pfx', 'trigger'); - E('pfx', 'selpad'); + // ---- 30b: the rings are MILESTONES. The first crossing of a ring in a round (a perRound + // Once — a crate re-entering the sensor must not re-announce) tells every player: a banner, + // a burst AT the ring, a chime from the ring, and a success buzz in VR. The gold ring at + // the top is the big one: confetti, the level-up fanfare, "Top of the tower!". The Game + // Feel nodes are LOCAL on every peer from the replicated stamp, so no message of their own. + // (It replaces 30's particle emitter on the pad: the burst pool costs no emitter slot.) + N('buzzring', 'hapticpulse', 'Buzz: a ring reached', 1720, 1480, { pattern: 'success', hand: 'both' }); + for (let i = 1; i <= 4; i++) { + const y = 960 + (i - 1) * 150; + const top = i === 4; + N('first' + i, 'once', 'First time at ' + i + 'm', 2440, y, { perRound: true, pulse: 0.3 }); + E('enr' + i, 'first' + i, 'trigger'); + N('say' + i, 'announce', top ? 'Say: top!' : 'Say: ring ' + i, 2680, y, top + ? { text: 'Top of the tower!', sub: '4 m — the gold ring', seconds: 2.6, color: '#ffc640', decimals: 0 } + : { text: 'Ring ' + i + ' reached', sub: i + ' m — keep stacking', seconds: 1.8, color: '#9ee6ff', decimals: 0 }); + E('first' + i, 'say' + i, 'trigger'); + N('fx' + i, 'effectburst', 'Burst at ring ' + i, 2920, y, { kind: top ? 'confetti' : 'sparkle', count: top ? 96 : 56, lift: 0, color: '' }); + E('first' + i, 'fx' + i, 'trigger'); + E('selr' + i, 'fx' + i, 'at'); + // an additive sparkle alone is faint against a daylight sky: a small confetti puff + // beside it reads at any exposure (the top ring's burst IS confetti already) + if (!top) { + N('puff' + i, 'effectburst', 'Puff at ring ' + i, 2920, y + 70, { kind: 'confetti', count: 36, lift: 0.1, color: '' }); + E('first' + i, 'puff' + i, 'trigger'); + E('selr' + i, 'puff' + i, 'at'); + } + N('chime' + i, 'gamesound', top ? 'Fanfare at the top' : 'Chime at ring ' + i, 3160, y, { sound: top ? 'levelup' : 'ring' }); + E('first' + i, 'chime' + i, 'trigger'); + E('selr' + i, 'chime' + i, 'at'); + E('first' + i, 'buzzring', 'trigger'); + } + N('topsparkle', 'effectburst', 'Sparkle at the top', 2920, 1560, { kind: 'sparkle', count: 80, lift: 0.4, color: '#ffe08a' }); + E('first4', 'topsparkle', 'trigger'); + E('selr4', 'topsparkle', 'at'); // ---- the stars — collectible-module touch pickups (shared team score) ------- for (let i = 1; i <= 3; i++) { @@ -384,8 +410,45 @@ function towersGraph() { N('gotime', 'setgamestate', 'Time over', 1000, 2240, { state: 'over', outcome: "Time's up!", reset: false }); E('alltime', 'gotime', 'trigger'); + // ---- 30b: the round's sound, and the crates' ------------------------------------------ + // Music: the arcade loop while you are in the game (Interact or Play; never the editor). + N('music', 'gamemusic', 'Arcade music', 40, 2480, { preset: 'arcade', volume: 0.45, while: 'always' }); + // Every menu button clicks. + N('click', 'gamesound', 'Button click', 280, 2480, { sound: 'click' }); + for (const b of ['bstart', 'bagain', 'bresume', 'brestart', 'bquit', 'breplay']) E(b, 'click', 'trigger'); + // A round starts with a whistle and a banner. It ENDS without one: the Round over panel is + // the result (a banner over it read twice), so the end is a cheer or a whistle + confetti. + N('saygo', 'announce', 'Say: build!', 1720, 1640, { text: 'Build!', sub: 'Stack crates on the glowing pad — reach the gold ring', seconds: 2.2, color: '#ffd45e', decimals: 0 }); + E('onround', 'saygo', 'trigger'); + N('whistle', 'gamesound', 'Round start whistle', 1720, 1720, { sound: 'whistle' }); + E('onround', 'whistle', 'trigger'); + N('cheer', 'gamesound', 'Cheer', 1240, 2080, { sound: 'cheer' }); + E('allwin', 'cheer', 'trigger'); + N('confetti', 'effectburst', 'Confetti for everyone', 1240, 2160, { kind: 'confetti', count: 96, lift: 0, color: '' }); + E('allwin', 'confetti', 'trigger'); + N('upwhistle', 'gamesound', 'Final whistle', 1240, 2380, { sound: 'whistle' }); + E('alltime', 'upwhistle', 'trigger'); + // Each crate: a pop when a player lifts it (On Grab) and a knock when it lands on + // something (On Impact past a gentle landing), both placed AT the crate. + TOWERS_CRATES.forEach((name, k) => { + const y = 2560 + k * 110; + N('csel' + k, 'objectselector', name, 280, y, { selected: name }); + N('cgrab' + k, 'ongrab', name + ' grabbed', 40, y, { pulse: 0.3 }); + E('cgrab' + k, 'csel' + k); + N('cpop' + k, 'gamesound', name + ' lift', 520, y, { sound: 'pop' }); + E('cgrab' + k, 'cpop' + k, 'trigger'); + E('csel' + k, 'cpop' + k, 'at'); + N('cland' + k, 'onimpact', name + ' lands', 760, y, { pulse: 0.3, minStrength: 0.8 }); + E('cland' + k, 'csel' + k); + N('cknock' + k, 'gamesound', name + ' knock', 1000, y, { sound: 'hit' }); + E('cland' + k, 'cknock' + k, 'trigger'); + E('csel' + k, 'cknock' + k, 'at'); + }); + return g.done(); } +/** the nine crates the sound chains above name (the objects below) */ +const TOWERS_CRATES = ['Cube 1', 'Cube 2', 'Cube 3', 'Cube 4', 'Cube 5', 'Cube 6', 'Plank 1', 'Plank 2', 'Plank 3']; const TOWERS_HUD_PANEL = { bg: 'rgba(14, 20, 32, 0.9)', @@ -491,7 +554,9 @@ const TOWERS_DEF = { bounds: { limit: -20, action: 'respawn' }, material: { friction: 0.7, restitution: 0.05 }, damping: { linear: 0.05, angular: 0.3 }, - play: { interaction: 'grab', grounded: false, simOnPlay: true } + // 30b: a SPAWN — Play (and Interact in VR) puts you 4.5 m in front of the pad, facing + // it, with a podium on each hand (yaw 0 faces -Z) + play: { interaction: 'grab', grounded: false, simOnPlay: true, spawn: { position: [0, 0, 4.5], yaw: 0 } } }, // the shell floor (fork 11): ao -> AgX -> bloom -> smaa, bloom a touch higher so the // rings, the trim and the pad read as light @@ -521,12 +586,15 @@ const TOWERS_DEF = { showWhile: 'menu', input: 'menu', elements: [ - { id: 'menu-panel', kind: 'panel', anchor: 'center', x: 0, y: 0, w: 480, h: 380, z: 0, label: '', style: TOWERS_HUD_PANEL }, - { id: 'title', kind: 'text', anchor: 'center', x: 0, y: -135, w: 420, h: 54, z: 1, label: 'TOWERS', style: { size: 42, weight: '700', color: '#ffd45e', align: 'center' } }, - { id: 'subtitle', kind: 'text', anchor: 'center', x: 0, y: -84, w: 440, h: 44, z: 1, label: 'Grab the crates and build the tallest tower on the glowing pad. Touch the floating stars.', style: { size: 14, color: '#d8dee9', align: 'center' }, wrap: true }, - { id: 'best-read', kind: 'text', anchor: 'center', x: 0, y: -34, w: 420, h: 24, z: 1, label: 'Your best tower: 0 m', style: { size: 15, weight: '600', color: '#9ee6ff', align: 'center' } }, - { id: 'start-btn', kind: 'button', anchor: 'center', x: 0, y: 28, w: 240, h: 50, z: 1, label: 'Start round', enabled: true, style: TOWERS_BTN }, - { id: 'menu-hint', kind: 'text', anchor: 'center', x: 0, y: 120, w: 440, h: 40, z: 1, label: 'Grab: hold click · Push/pull: wheel · Fly: Q/E · Pause: P', style: { size: 12, color: '#8b97a8', align: 'center' }, wrap: true } + { id: 'menu-panel', kind: 'panel', anchor: 'center', x: 0, y: 0, w: 500, h: 460, z: 0, label: '', style: TOWERS_HUD_PANEL }, + { id: 'title', kind: 'text', anchor: 'center', x: 0, y: -176, w: 420, h: 54, z: 1, label: 'TOWERS', style: { size: 42, weight: '700', color: '#ffd45e', align: 'center' } }, + // 30b: HOW TO PLAY, said once, plainly — the goal, the milestones, the clock + { id: 'howto-title', kind: 'text', anchor: 'center', x: 0, y: -132, w: 440, h: 20, z: 1, label: 'HOW TO PLAY', style: { size: 12, weight: '700', color: '#9ee6ff', align: 'center' } }, + { id: 'subtitle', kind: 'text', anchor: 'center', x: 0, y: -94, w: 450, h: 60, z: 1, label: 'Grab crates from the podiums and stack them on the glowing pad. Each ring your tower reaches is a milestone — the gold ring at 4 m is the top. Touch the three floating stars. Three minutes a round.', style: { size: 14, color: '#d8dee9', align: 'center' }, wrap: true }, + { id: 'best-read', kind: 'text', anchor: 'center', x: 0, y: -44, w: 420, h: 24, z: 1, label: 'Your best tower: 0 m', style: { size: 15, weight: '600', color: '#9ee6ff', align: 'center' } }, + { id: 'start-btn', kind: 'button', anchor: 'center', x: 0, y: 14, w: 240, h: 50, z: 1, label: 'Start round', enabled: true, style: TOWERS_BTN }, + { id: 'menu-hint', kind: 'text', anchor: 'center', x: 0, y: 88, w: 460, h: 36, z: 1, label: 'Desktop: hold click to grab · wheel pushes/pulls · Q/E fly · P pause', style: { size: 12, color: '#8b97a8', align: 'center' }, wrap: true }, + { id: 'menu-hint-vr', kind: 'text', anchor: 'center', x: 0, y: 128, w: 460, h: 36, z: 1, label: 'VR: grip grabs a crate · left stick walks · Y switches to Edit mode', style: { size: 12, color: '#8b97a8', align: 'center' }, wrap: true } ] }, { @@ -641,12 +709,11 @@ const TOWERS_DEF = { // wire resolves to and Select passes a string through raw — recorded as a follow-up // (a Select typed by its wired inputs, or a Select Colour node). The Round-over title // uses the same trick into HUD Text's `format`; -// · THE POOLED BURST (30): the runtime renders at most 8 emitters and a node wired to 24 -// stars is 24 of them — the "Particle emitter cap (8) reached" toast on LOAD. So there -// is ONE burst emitter on a `Burst anchor`, and a Script node moves the anchor onto the -// star that was just hit: the star's number is Σ i·hit_i (each On Hit reads 1 for its -// 0.3 s pulse), and the same sum is the burst's trigger. Two hits inside one pulse sum -// to a wrong index and simply burst nowhere new — a sparkle, not state; +// · THE BURST (30b): 30 pooled ONE particle emitter on a `Burst anchor` a Script moved onto +// the star just hit, because a particle node wired to 24 stars is 24 of the runtime's 8 +// emitters. The Game Feel Effect Burst is pooled by the CORE (twelve short-lived systems at +// the scene root, no emitter slot), so each star has its own burst node now and the anchor, +// the Script and the hit-index sum are gone; // · the spawner RECYCLES oldest-out at maxAlive, it does not refuse — "More stars" // therefore never fails, and the room holds at most 27 + 32 dynamic bodies; // · the chime is a def-level `sounds` entry (A4, additive): fetched like `music`, @@ -797,10 +864,9 @@ function starsGraph() { E('timeandplay', 'alltime', 'condition'); N('gotime', 'setgamestate', 'Time over', 1000, 1760, { state: 'over', outcome: "Time's up!", reset: false }); E('alltime', 'gotime', 'trigger'); - // ---- per star: a chime on any hit, a per-player touch on MY hit, a perRound latch that - // paints the star lit during a round, and its share of the pooled burst's index -------- + // ---- per star: a chime on any hit, a per-player touch on MY hit, and a perRound latch + // that paints the star lit during a round ------------------------------------------------ let prevSum = ''; - let prevIdx = ''; for (let i = 1; i <= 24; i++) { const y = 1900 + (i - 1) * 180; N('sel' + i, 'objectselector', 'Star ' + i, 1000, y, { selected: 'Star ' + i }); @@ -821,46 +887,35 @@ function starsGraph() { N('col' + i, 'setcolor', 'Star ' + i + ' paint', 1720, y, { color: STAR_DIM, whilePlaying: true }); E('lit' + i, 'col' + i, 'color'); E('col' + i, 'sel' + i); - N('idx' + i, 'math', 'Star ' + i + ' index', 280, y, { op: 'mul', a: 0, b: i }); - E('hit' + i, 'idx' + i, 'a'); if (i === 2) { N('sum2', 'math', 'Lit 1-2', 1960, y, { op: 'add', a: 0, b: 0 }); E('lat1', 'sum2', 'a'); E('lat2', 'sum2', 'b'); prevSum = 'sum2'; - N('isum2', 'math', 'Hit index 1-2', 520, y, { op: 'add', a: 0, b: 0 }); - E('idx1', 'isum2', 'a'); - E('idx2', 'isum2', 'b'); - prevIdx = 'isum2'; } else if (i > 2) { N('sum' + i, 'math', 'Lit 1-' + i, 1960, y, { op: 'add', a: 0, b: 0 }); E(prevSum, 'sum' + i, 'a'); E('lat' + i, 'sum' + i, 'b'); prevSum = 'sum' + i; - N('isum' + i, 'math', 'Hit index 1-' + i, 520, y, { op: 'add', a: 0, b: 0 }); - E(prevIdx, 'isum' + i, 'a'); - E('idx' + i, 'isum' + i, 'b'); - prevIdx = 'isum' + i; } } - // ---- the pooled burst: one emitter, moved onto the star just hit ------------------- - N('selanchor', 'objectselector', 'Burst anchor', 1000, 1400, { selected: 'Burst anchor' }); - N('hop', 'script', 'Stand on the hit star', 760, 1400, { - code: - '// the pooled burst: stand on the star that was just hit (a = its number, 0 = none).\n' + - '// Deterministic — every peer derives the same index from the replicated hit stamps.\n' + - "const n = Math.round(Number(data.a) || 0);\n" + - "const star = n >= 1 && n <= 24 && object.parent ? object.parent.getObjectByName('Star ' + n) : null;\n" + - 'if (star) object.position.copy(star.position);\n' - }); - E('isum24', 'hop', 'a'); - E('hop', 'selanchor'); - N('pool', 'particle', 'Pooled star burst', 760, 1300, { - mode: 'burst', count: 60, lifetime: 0.9, speed: 1.8, gravity: 0, - turbulence: 0.3, sizeStart: 0.09, opacity: 0.95, sprite: 'star', blending: 'additive', space: 'world' - }); - E('isum24', 'pool', 'trigger'); - E('pool', 'selanchor'); + // ---- 30b: each star SPARKLES where it is hit, and LIGHTING it (the first hit of the round, + // a perRound Once) pays a coin chime and a haptic tap. The Game Feel burst is POOLED by + // the core (twelve short-lived systems at the scene root, no emitter slot), so 24 of them + // cost nothing between hits — which retires 30's one-emitter-plus-Script anchor trick. + N('buzzlit', 'hapticpulse', 'Buzz: a star lit', 1960, 1400, { pattern: 'tap', hand: 'both' }); + for (let i = 1; i <= 24; i++) { + const y = 1900 + (i - 1) * 180; + N('fx' + i, 'effectburst', 'Star ' + i + ' sparkle', 2200, y, { kind: 'sparkle', count: 28, lift: 0, color: '' }); + E('hit' + i, 'fx' + i, 'trigger'); + E('sel' + i, 'fx' + i, 'at'); + N('first' + i, 'once', 'Star ' + i + ' first lit', 2440, y, { perRound: true, pulse: 0.3 }); + E('hit' + i, 'first' + i, 'trigger'); + N('coin' + i, 'gamesound', 'Star ' + i + ' coin', 2680, y, { sound: 'coin' }); + E('first' + i, 'coin' + i, 'trigger'); + E('sel' + i, 'coin' + i, 'at'); + E('first' + i, 'buzzlit', 'trigger'); + } // ---- the round's result, kept where the round's end cannot erase it ------------------ N('hlit', 'hudtext', 'HUD lit', 2200, 2000, { element: 'lit-read', format: 'Lit: {v} / 24', decimals: 0, value: 0 }); E('sum24', 'hlit', 'value'); @@ -915,6 +970,29 @@ function starsGraph() { E('storedbest', 'hbest', 'value'); N('hbest0', 'hudtext', 'HUD best (start)', 3400, 2700, { element: 'best-read', format: 'Your best round: {v} / 24 stars', decimals: 0, value: 0 }); E('storedbest', 'hbest0', 'value'); + + // ---- 30b: the round says what is happening, and sounds like it ----------------------- + N('music', 'gamemusic', 'Space music', 40, 1240, { preset: 'space', volume: 0.45, while: 'always' }); + N('click', 'gamesound', 'Button click', 280, 1100, { sound: 'click' }); + for (const b of ['bstart0', 'bfree', 'bstart', 'bagain', 'breplay', 'bresume', 'brestart', 'bquit', 'bmore']) E(b, 'click', 'trigger'); + N('saygo', 'announce', 'Say: light them', 2680, 2540, { text: 'Light every star!', sub: 'Two minutes — knock each one', seconds: 2.2, color: '#ffe08a', decimals: 0 }); + E('onround', 'saygo', 'trigger'); + N('whistle', 'gamesound', 'Round start whistle', 2680, 2620, { sound: 'whistle' }); + E('onround', 'whistle', 'trigger'); + N('sayfree', 'announce', 'Say: free play', 520, 110, { text: 'Free play', sub: 'Knock the stars around — P or the menu to start a round', seconds: 2, color: '#9ee6ff', decimals: 0 }); + E('bfree', 'sayfree', 'trigger'); + N('fanfare', 'gamesound', 'Win fanfare', 2920, 2230, { sound: 'levelup' }); + E('allwin', 'fanfare', 'trigger'); + N('upwhistle', 'gamesound', 'Final whistle', 1240, 1920, { sound: 'whistle' }); + E('alltime', 'upwhistle', 'trigger'); + // the round's end, whichever way: confetti in front of every player and a cheer, with the + // results on the Round over screen (the VR board in a headset) — NO banner at the end: it + // read twice over the results panel + N('onover', 'ongamestate', 'When the round ends', 3160, 2700, { state: 'over', edge: 'enter', pulse: 0.3 }); + N('confetti', 'effectburst', 'Confetti', 3400, 2700, { kind: 'confetti', count: 96, lift: 0, color: '' }); + E('onover', 'confetti', 'trigger'); + N('cheer', 'gamesound', 'Cheer', 3400, 2780, { sound: 'cheer' }); + E('onover', 'cheer', 'trigger'); return g.done(); } @@ -958,7 +1036,8 @@ const STARS_DEF = { material: { friction: 0.1, restitution: 0.85 }, damping: { linear: 0.35, angular: 0.2 }, ccd: false, - play: { interaction: 'grab', grounded: false, simOnPlay: true }, + // 30b: a SPAWN just inside the south glass, facing the stars (yaw 0 faces -Z) + play: { interaction: 'grab', grounded: false, simOnPlay: true, spawn: { position: [0, 0, 5.2], yaw: 0 } }, knock: { enabled: true, gain: 1, maxSpeed: 10, radius: 0.12, spin: 0.6 } }, post: { @@ -989,11 +1068,14 @@ const STARS_DEF = { elements: [ { id: 'start-panel', kind: 'panel', anchor: 'center', x: 0, y: 0, w: 460, h: 400, z: 0, label: '', style: STARS_HUD_PANEL }, { id: 'start-title', kind: 'text', anchor: 'center', x: 0, y: -145, w: 420, h: 48, z: 1, label: 'STARS ROOM', style: { size: 36, weight: '700', color: '#ffd45e', align: 'center' } }, - { id: 'start-sub', kind: 'text', anchor: 'center', x: 0, y: -92, w: 400, h: 44, z: 1, label: 'Zero gravity. Knock the crystal stars with your hands in VR, or walk into them.', style: STARS_TEXT, wrap: true }, + // 30b: HOW TO PLAY + { id: 'howto-title', kind: 'text', anchor: 'center', x: 0, y: -112, w: 400, h: 18, z: 1, label: 'HOW TO PLAY', style: { size: 12, weight: '700', color: '#9ee6ff', align: 'center' } }, + { id: 'start-sub', kind: 'text', anchor: 'center', x: 0, y: -80, w: 420, h: 44, z: 1, label: 'Zero gravity. Knock every crystal star once to light it — in VR swing your hands through them, on a desktop walk into them.', style: STARS_TEXT, wrap: true }, { id: 'best-read', kind: 'text', anchor: 'center', x: 0, y: -44, w: 400, h: 24, z: 1, label: 'Your best round: 0 / 24 stars', style: { size: 14, weight: '600', color: '#9ee6ff', align: 'center' } }, { id: 'go-btn', kind: 'button', anchor: 'center', x: 0, y: 12, w: 260, h: 48, z: 1, label: 'Start round', enabled: true, style: { ...STARS_BTN, size: 17 } }, { id: 'free-btn', kind: 'button', anchor: 'center', x: 0, y: 70, w: 260, h: 42, z: 1, label: 'Free play', enabled: true, style: STARS_QUIET }, - { id: 'start-hint', kind: 'text', anchor: 'center', x: 0, y: 150, w: 420, h: 40, z: 1, label: 'Light every star in two minutes · P: menu', style: { size: 12, color: '#8b97a8', align: 'center' }, wrap: true } + { id: 'start-hint', kind: 'text', anchor: 'center', x: 0, y: 128, w: 420, h: 22, z: 1, label: 'Light all 24 in two minutes · P: menu', style: { size: 12, color: '#8b97a8', align: 'center' }, wrap: true }, + { id: 'start-hint-vr', kind: 'text', anchor: 'center', x: 0, y: 156, w: 420, h: 22, z: 1, label: 'VR: left stick walks · grip grabs · Y switches to Edit mode', style: { size: 12, color: '#8b97a8', align: 'center' }, wrap: true } ] }, { @@ -1085,9 +1167,6 @@ const STARS_DEF = { opacity: 1, fadeIn: 0.3, fadeOut: 0.25, sprite: 'star', blending: 'additive', spin: 0, space: 'local' } }, - // the pooled burst's anchor — parked under the deck until a hit moves it (the emitter - // auto-fires once when it is built, and that must happen out of sight) - { type: 'empty', name: 'Burst anchor', pos: [0, -3, 0] }, { type: 'light', name: 'Room light', kind: 'point', color: 0xa8bcff, intensity: 20, distance: 18, pos: [0, 5.5, 0] }, { type: 'light', name: 'Deck glow', kind: 'point', color: 0x7fd4ff, intensity: 4, distance: 9, pos: [0, 0.6, 0] }, // the two physical buttons (an onclick fires from a VR ray — the HUD is not in a headset) @@ -1703,8 +1782,36 @@ function jamGraph() { E('bquit', 'doquit', 'trigger'); E('bmenu', 'doquit', 'trigger'); E('bquit', 'quithide', 'trigger'); + + // ---- 30b: the shell sounds like a game — but never plays music over the band. The end + // of a session is confetti, the fanfare and a buzz; its words are the Session complete + // panel (a banner over the panel read twice) ----------------------------------------------- + N('click', 'gamesound', 'Button click', 280, 1360, { sound: 'click' }); + for (const b of ['bgo', 'bresume', 'brestart', 'breplay', 'bquit', 'bmenu']) E(b, 'click', 'trigger'); + N('saygo', 'announce', 'Say: go', 1000, 40, { text: 'Go!', sub: 'Press ▶ on the Transport — keep it going for ' + JAM_BARS + ' bars', seconds: 2, color: '#ffb060', decimals: 0 }); + E('countdone', 'saygo', 'trigger'); + N('donefx', 'effectburst', 'Confetti', 2440, 400, { kind: 'confetti', count: 96, lift: 0, color: '' }); + E('allwin', 'donefx', 'trigger'); + N('donesnd', 'gamesound', 'Fanfare', 2680, 400, { sound: 'levelup' }); + E('allwin', 'donesnd', 'trigger'); + N('donebuzz', 'hapticpulse', 'Buzz: done', 2680, 480, { pattern: 'success', hand: 'both' }); + E('allwin', 'donebuzz', 'trigger'); return g.done(); } +/** 30b: where a VR player stands in the Jam Room — the middle of the cockpit (feet) */ +const JAM_SPAWN = [1.0, 0, -1.0]; +/** 30b: a stand/desk under a device: a top slab and two end panels, dark wood, select-through + * @param {string} name @param {number[]} at the floor centre @param {number[]} size [w, top height, d] */ +const jamStand = (name, at, size) => { + const [w, h, d] = size; + const wood = { color: 0x3a2618, physical: true, roughness: 0.55, clearcoat: 0.3, pick: 'through' }; + const alongX = w >= d; + return [ + { type: 'box', name: name, size: [w, 0.05, d], pos: [at[0], h - 0.025, at[2]], bevel: 0.015, bevelSegments: 1, ...wood }, + { type: 'box', name: name + ' end A', size: alongX ? [0.05, h - 0.05, d * 0.9] : [w * 0.9, h - 0.05, 0.05], pos: alongX ? [at[0] - w / 2 + 0.06, (h - 0.05) / 2, at[2]] : [at[0], (h - 0.05) / 2, at[2] - d / 2 + 0.06], ...wood }, + { type: 'box', name: name + ' end B', size: alongX ? [0.05, h - 0.05, d * 0.9] : [w * 0.9, h - 0.05, 0.05], pos: alongX ? [at[0] + w / 2 - 0.06, (h - 0.05) / 2, at[2]] : [at[0], (h - 0.05) / 2, at[2] + d / 2 - 0.06], ...wood } + ]; +}; /** a warm lamp: a pole, a glowing shade and its light, as ONE group * @param {string} name @param {number[]} pos */ const jamLamp = (name, pos) => ({ @@ -1740,8 +1847,14 @@ const JAM_DEF = { sun: { color: '#ffe2c0', intensity: 1.6, dir: [0.4, 1, 0.7] } }, // no simulation (nothing here has a body); a FREE cursor, because you play the - // instruments by pointing at them — no pointer lock, the real cursor clicks keys and pads - physics: { play: { cursor: 'free', simOnPlay: false } }, + // instruments by pointing at them — no pointer lock, the real cursor clicks keys and pads. + // 30b: a VR-ONLY spawn INSIDE the band (see JAM_SPAWN): the headset stands at the + // cockpit, where the controller tip reaches the piano, the drums, the sampler, the + // transport and the mixer; a desktop keeps the overview it always had (a level eye at the + // cockpit would see only the piano). + physics: { play: { cursor: 'free', simOnPlay: false, spawn: { position: JAM_SPAWN, yaw: 0, vrOnly: true } } }, + // 30b: NO Game Music node, on purpose — the room IS the music (the transport, the drums and + // the piano are what you hear), so a procedural `studio` loop would only fight the band. post: { enabled: true, effects: [ @@ -1754,7 +1867,7 @@ const JAM_DEF = { changedAt: 0 }, // inside the room, a step back from the band: Play starts from the editor camera's spot - view: { pos: [1.0, 2.4, 2.4], target: [1.0, 0.8, -3.2] }, + view: { pos: [1.0, 2.5, 2.2], target: [1.0, 0.7, -1.4] }, thumb: { camera: 'Card camera' }, graphs: { scene: jamGraph() }, hud: { @@ -1770,10 +1883,13 @@ const JAM_DEF = { elements: [ { id: 'start-panel', kind: 'panel', anchor: 'center', x: 0, y: 0, w: 480, h: 360, z: 0, label: '', style: JAM_PANEL }, { id: 'start-title', kind: 'text', anchor: 'center', x: 0, y: -125, w: 420, h: 48, z: 1, label: 'JAM ROOM', style: { size: 38, weight: '700', color: '#ffb060', align: 'center' } }, - { id: 'start-sub', kind: 'text', anchor: 'center', x: 0, y: -72, w: 420, h: 44, z: 1, label: 'Press Start, then ▶ on the Transport, and keep the band going for eight bars.', style: { size: 14, color: '#f1e6dc', align: 'center' }, wrap: true }, - { id: 'best-read', kind: 'text', anchor: 'center', x: 0, y: -24, w: 420, h: 24, z: 1, label: 'Your best tempo: 0 BPM', style: { size: 15, weight: '600', color: '#ffd9a8', align: 'center' } }, + // 30b: HOW TO PLAY + { id: 'howto-title', kind: 'text', anchor: 'center', x: 0, y: -94, w: 420, h: 18, z: 1, label: 'HOW TO PLAY', style: { size: 12, weight: '700', color: '#ffd9a8', align: 'center' } }, + { id: 'start-sub', kind: 'text', anchor: 'center', x: 0, y: -62, w: 440, h: 44, z: 1, label: 'Press Start, then ▶ on the Transport, and keep the band going for eight bars. Play the piano, the drum grid and the pads on top of it.', style: { size: 14, color: '#f1e6dc', align: 'center' }, wrap: true }, + { id: 'best-read', kind: 'text', anchor: 'center', x: 0, y: -18, w: 420, h: 24, z: 1, label: 'Your best tempo: 0 BPM', style: { size: 15, weight: '600', color: '#ffd9a8', align: 'center' } }, { id: 'go-btn', kind: 'button', anchor: 'center', x: 0, y: 40, w: 240, h: 50, z: 1, label: 'Start jam', enabled: true, style: JAM_BTN }, - { id: 'start-hint', kind: 'text', anchor: 'center', x: 0, y: 122, w: 440, h: 40, z: 1, label: 'Click keys, pads and the drum grid · BPM −/+ on the Transport · P: menu', style: { size: 12, color: '#b8a594', align: 'center' }, wrap: true } + { id: 'start-hint', kind: 'text', anchor: 'center', x: 0, y: 108, w: 440, h: 22, z: 1, label: 'Click keys, pads and the drum grid · BPM −/+ on the Transport · P: menu', style: { size: 12, color: '#b8a594', align: 'center' }, wrap: true }, + { id: 'start-hint-vr', kind: 'text', anchor: 'center', x: 0, y: 136, w: 440, h: 22, z: 1, label: 'VR: hold the trigger and sweep across keys and pads · Y: Edit mode', style: { size: 12, color: '#b8a594', align: 'center' }, wrap: true } ] }, { @@ -1850,7 +1966,7 @@ const JAM_DEF = { // warm light: two floor lamps and a spot on the beat lab jamLamp('Lamp left', [-4.1, 0, -5.4]), jamLamp('Lamp right', [6.3, 0, -5.4]), - { type: 'light', name: 'Stage spot', kind: 'spot', color: 0xffd9a8, intensity: 60, angle: 0.7, penumbra: 0.6, distance: 14, pos: [1, 5.2, 1.5], target: [0.9, 0, -2.6] }, + { type: 'light', name: 'Stage spot', kind: 'spot', color: 0xffd9a8, intensity: 60, angle: 0.7, penumbra: 0.6, distance: 14, pos: [1, 5.2, 1.5], target: [1.0, 0.6, -1.3] }, // props: an amp stack, a plant, a stool { type: 'group', name: 'Amp', pos: [-3.6, 0, -3.4], rot: [0, 0.5, 0], @@ -1877,22 +1993,38 @@ const JAM_DEF = { { type: 'cylinder', name: 'Stool foot', color: 0x2b2420, r: 0.2, h: 0.03, pos: [0, 0.015, 0], physical: true, metalness: 0.6, roughness: 0.35 } ] }, - { type: 'camera', name: 'Card camera', pos: [4.2, 2.6, 2.6], lookAt: [0.9, 0.5, -2.7], fov: 55 } + // 30b: the cockpit's furniture — what the devices stand on, at hand height. Select- + // through, so a press or the laser always reaches the device on top. + ...jamStand('Keyboard stand', [1.0, 0, -2.0], [1.84, 0.74, 1.0]), + ...jamStand('Drum desk', [0.1, 0, -0.85], [0.72, 0.78, 1.12]), + ...jamStand('Right desk', [1.75, 0, -0.85], [0.56, 0.78, 1.2]), + ...jamStand('Pedal board', [2.65, 0, -0.9], [0.36, 0.46, 1.9]), + // the speakers stand above the keys, so the laser (and the sound) clears the piano + ...jamStand('Speaker stand left', [0.1, 0, -3.1], [0.44, 0.75, 0.44]), + ...jamStand('Speaker stand right', [1.9, 0, -3.1], [0.44, 0.75, 0.44]), + { type: 'camera', name: 'Card camera', pos: [4.2, 2.6, 2.6], lookAt: [1.0, 0.7, -1.3], fov: 55 } ], - // a semicircle facing the spawn at the origin; both speakers turned to face the listener + // 30b: THE COCKPIT — a U of stands around the VR spawn (JAM_SPAWN, facing -Z): the piano + // straight ahead with its keys at hand height, the drum machine on the left desk, the + // sampler and the mixer on the right desk, the transport on its stand behind-left, all + // turned to face the player; the pedal chain on its board beyond the right desk and the + // two speakers on stands behind the piano, both in the laser's reach and line of sight. (30's + // semicircle faced a desktop view: in VR the piano sat on the FLOOR and the drums were + // two metres from anyone.) Each yaw turns the device's player side (+Z; the transport's + // panel is on -Z) toward the spawn. layout: [ - { kind: 'mod-music-lab-transport', pos: [-2.6, 0.5, -1.8] }, - { kind: 'mod-music-lab-piano', pos: [-1.3, 0, -2.4] }, - { kind: 'mod-music-lab-drums', pos: [0.3, 0.8, -2.8] }, - { kind: 'mod-music-lab-sampler', pos: [1.5, 0.8, -2.8] }, - { kind: 'mod-music-lab-speaker', index: 0, pos: [-1.0, 0.35, -4.4], yaw: Math.PI }, - { kind: 'mod-music-lab-speaker', index: 1, pos: [1.6, 0.35, -4.4], yaw: Math.PI }, - { kind: 'mod-music-fx-mixer', pos: [3.2, 0.8, -2.6] }, - { kind: 'mod-music-fx-filter', pos: [2.4, 0.5, -0.9] }, - { kind: 'mod-music-fx-distortion', pos: [3.0, 0.5, -0.9] }, - { kind: 'mod-music-fx-bitcrush', pos: [3.6, 0.5, -0.9] }, - { kind: 'mod-music-fx-delay', pos: [4.2, 0.5, -0.9] }, - { kind: 'mod-music-fx-reverb', pos: [4.8, 0.5, -0.9] } + { kind: 'mod-music-lab-piano', pos: [1.0, 0.8, -2.0], yaw: 0 }, + { kind: 'mod-music-lab-drums', pos: [0.1, 0.85, -0.85], yaw: Math.PI / 2 }, + { kind: 'mod-music-lab-transport', pos: [0.7, 0.5, -0.2], yaw: Math.atan2(-0.3, 0.8) }, + { kind: 'mod-music-lab-sampler', pos: [1.75, 0.85, -1.17], yaw: -Math.PI / 2 }, + { kind: 'mod-music-fx-mixer', pos: [1.75, 0.85, -0.6], yaw: -Math.PI / 2 }, + { kind: 'mod-music-lab-speaker', index: 0, pos: [0.1, 1.1, -3.1], yaw: Math.PI }, + { kind: 'mod-music-lab-speaker', index: 1, pos: [1.9, 1.1, -3.1], yaw: Math.PI }, + { kind: 'mod-music-fx-filter', pos: [2.65, 0.5, -1.7], yaw: -Math.PI / 2 }, + { kind: 'mod-music-fx-distortion', pos: [2.65, 0.5, -1.3], yaw: -Math.PI / 2 }, + { kind: 'mod-music-fx-bitcrush', pos: [2.65, 0.5, -0.9], yaw: -Math.PI / 2 }, + { kind: 'mod-music-fx-delay', pos: [2.65, 0.5, -0.5], yaw: -Math.PI / 2 }, + { kind: 'mod-music-fx-reverb', pos: [2.65, 0.5, -0.1], yaw: -Math.PI / 2 } ], generate: [ { menu: 'Music Lab: piano + speaker', moduleId: 'music-lab', waitMs: 1500 }, diff --git a/src/App.svelte b/src/App.svelte index 40cf9592..2b993efc 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -478,9 +478,13 @@ import { startMusicToolbox } from './lib/musicToolbox' import('./lib/importGate'), import('./lib/decimate'), import('./lib/playCursor'), - import('./lib/gameStorage') - ]).then(([sceneStore, appStore, flowStore, meshEdit, vrControls, autosave, voiceChat, audioEngineLib, musicClockLib, audioDevicesLib, audioPatchLib, vrPatchLib, musicToolboxLib, micCaptureLib, annotationsHandler, flowRuntime, history, materialsHandler, objectActions, commandsHandler, moduleSDK, drawModeLib, pathCapture, lockControl, prefabsLib, physics, jointsLib, possessLib, handModelsLib, terrainSculptLib, userModulesLib, environmentLib, sceneMusicLib, animatedImports, fileHandler, fileWindowsLib, sceneBounds, cameraClip, ping, sessionsLib, geometryEdit, lightParams, shadowDefaultsLib, paletteLib, viewModeLib, inputRuntimeLib, shortcutsLib, themesLib, vrRadialMenu, vrPaletteLib, vrWindowPosesLib, vrKeyboardLib, faceEditLib, meshToolParamsLib, avatarModelLib, explorerLib, bottomDock, explorerDrop, assetShare, soundRuntime, dungeonPlay, sceneAssetsLib, THREE, GLTFExporterModule, snappingLib, flowSocketsLib, networkQualityLib, packsLib, customNodesLib, nodesHandlerLib, nodeCatalogLib, objectMenuLib, animationPreviewLib, aiProvidersLib, aiToolsLib, aiAssistantLib, meshProvidersLib, meshJobsLib, flowGraphsLib, objectFlowLib, peerServerLib, cloudHooksLib, cloudPluginLib, connectionStateLib, peerApprovalLib, particleRuntimeLib, particleActionsLib, particlePresetsLib, versionLib, whatsNewLib, confirmDialogLib, scenePhysicsLib, playInteractLib, moveSmoothingLib, knockLib, playSettingsLib, colliderSpecLib, colliderHelpersLib, colliderEditLib, editSessionLib, trackpadNavLib, vrSleeveLib, gridSettingsLib, viewPrefsLib, cameraBookmarksLib, cameraObjectsLib, cameraHelpersLib, onionSkinLib, cameraPreviewLib, addObjectsLib, cameraPipLib, inputDeviceLib, sceneTemplatesLib, bvhPickingLib, multiTransformLib, objectOriginLib, moduleGalleryLib, uvEditorLib, uvUnwrapLib, meshTopologyLib, meshBudgetLib, proportionalLib, proportionalRingLib, scenePickLib, snapEngineLib, meshPivotLib, selectionPrefsLib, editOverlaysLib, objectPermissionsLib, scenePostLib, postEffectsLib, viewportOverridesLib, postprocessingModule, shaderBackendsLib, shaderGraphLib, shaderSyncLib, shaderTexturesLib, shaderCatalogLib, unitsLib, postBackendsLib, workspaceLib, editResumeLib, moduleRequirementsLib, hudDocsLib, hudSyncLib, idbLib, hudKindsLib, hudImagesLib, gameStateLib, gameSyncLib, hudActionsLib, moduleNodeIOLib, moduleToolboxesLib, splineTubeLib, splineToolLib, splineEditLib, terrainCarveLib, flattenActionsLib, hudViewportDragLib, gamepadPrefsLib, charControllerLib, hudRichTextLib, moduleHudKindsLib, hudMinimapLib, hudArrangeLib, gamePresenceLib, levelsLib, peerVarsLib, projectManifestLib, projectFileLib, transientObjectsLib, spawnerLib, triggerSyncLib, saveNameLib, sceneIdentityLib, importDuplicatesLib, peerScenesLib, sharedLibraryLib, transferLedgerLib, explorerViewLib, filePreviewLib, saveAsLib, windowTabsLib, colocationLib, colocationCalibrateLib, colocationPresenceLib, xrAnchorsLib, colocationAnchorsLib, colocationNudgeLib, mountedVolumesLib, storageUsageLib, scenePrivacyLib, touchControlsLib, playModeLib, objectListNavLib, inviteLinksLib, helperLayerLib, explorerClipboardLib, lookPresenceLib, postGraphsLib, postGraphPresetsLib, materialSharingLib, diagnosticsLib, wireValidateLib, wireErrorsLib, safeStorageLib, sceneBudgetLib, overloadGuardLib, qualityGovernorLib, importBudgetLib, importGateLib, decimateLib, playCursorLib, gameStorageLib]) => { - window.__stores = { ...sceneStore, ...appStore, ...flowStore, meshEdit, vrControls, autosave, voiceChat, audioEngine: audioEngineLib, musicClock: musicClockLib, audioDevices: audioDevicesLib, audioPatch: audioPatchLib, vrPatch: vrPatchLib, musicToolbox: musicToolboxLib, micCapture: micCaptureLib, annotationsHandler, flowRuntime, history, materialsHandler, objectActions, commandsHandler, moduleSDK, drawMode: drawModeLib, pathCapture, lockControl, prefabs: prefabsLib, physics, joints: jointsLib, possess: possessLib, handModels: handModelsLib, terrainSculpt: terrainSculptLib, userModules: userModulesLib, environment: environmentLib, sceneMusic: sceneMusicLib, animatedImports, fileHandler, fileWindows: fileWindowsLib, sceneBounds, cameraClip, ping, sessions: sessionsLib, geometryEdit, lightParams, shadowDefaults: shadowDefaultsLib, palette: paletteLib, viewModeCtl: viewModeLib, inputRuntime: inputRuntimeLib, shortcutsRegistry: shortcutsLib, themes: themesLib, vrRadialMenu, vrPalette: vrPaletteLib, vrWindowPoses: vrWindowPosesLib, vrKeyboard: vrKeyboardLib, faceEdit: faceEditLib, meshToolParams: meshToolParamsLib, avatarModel: avatarModelLib, explorer: explorerLib, bottomDock, explorerDrop, assetShare, soundRuntime, dungeonPlay, sceneAssets: sceneAssetsLib, THREE, GLTFExporterModule, snapping: snappingLib, flowSockets: flowSocketsLib, networkQuality: networkQualityLib, packs: packsLib, customNodes: customNodesLib, nodesHandler: nodesHandlerLib, nodeCatalog: nodeCatalogLib, objectMenu: objectMenuLib, animationPreview: animationPreviewLib, aiProviders: aiProvidersLib, aiTools: aiToolsLib, aiAssistant: aiAssistantLib, meshProviders: meshProvidersLib, meshJobs: meshJobsLib, flowGraphsCtl: flowGraphsLib, objectFlow: objectFlowLib, peerServer: peerServerLib, cloudHooks: cloudHooksLib, cloudPlugin: cloudPluginLib, connectionState: connectionStateLib, peerApproval: peerApprovalLib, particleRuntime: particleRuntimeLib, particleActions: particleActionsLib, particlePresets: particlePresetsLib, version: versionLib, whatsNew: whatsNewLib, confirmDialog: confirmDialogLib, scenePhysics: scenePhysicsLib, playInteract: playInteractLib, moveSmoothing: moveSmoothingLib, knock: knockLib, playSettings: playSettingsLib, colliderSpec: colliderSpecLib, colliderHelpers: colliderHelpersLib, colliderEdit: colliderEditLib, editSession: editSessionLib, trackpadNav: trackpadNavLib, vrSleeve: vrSleeveLib, gridSettings: gridSettingsLib, viewPrefs: viewPrefsLib, cameraBookmarks: cameraBookmarksLib, cameraObjects: cameraObjectsLib, cameraHelpers: cameraHelpersLib, onionSkin: onionSkinLib, cameraPreview: cameraPreviewLib, addObjects: addObjectsLib, cameraPip: cameraPipLib, inputDevice: inputDeviceLib, sceneTemplates: sceneTemplatesLib, bvhPicking: bvhPickingLib, multiTransform: multiTransformLib, objectOrigin: objectOriginLib, moduleGallery: moduleGalleryLib, uvEditor: uvEditorLib, uvUnwrap: uvUnwrapLib, meshTopology: meshTopologyLib, meshBudget: meshBudgetLib, proportional: proportionalLib, proportionalRing: proportionalRingLib, scenePick: scenePickLib, snapEngine: snapEngineLib, meshPivot: meshPivotLib, selectionPrefs: selectionPrefsLib, editOverlays: editOverlaysLib, objectPermissions: objectPermissionsLib, scenePost: scenePostLib, postEffects: postEffectsLib, viewportOverrides: viewportOverridesLib, postprocessing: postprocessingModule, shaderBackends: shaderBackendsLib, shaderGraph: shaderGraphLib, shaderSync: shaderSyncLib, shaderTextures: shaderTexturesLib, shaderCatalog: shaderCatalogLib, units: unitsLib, postBackends: postBackendsLib, workspace: workspaceLib, editResume: editResumeLib, moduleRequirements: moduleRequirementsLib, hudDocs: hudDocsLib, hudSync: hudSyncLib, idb: idbLib, hudKinds: hudKindsLib, hudImages: hudImagesLib, gameState: gameStateLib, gameSync: gameSyncLib, hudActions: hudActionsLib, moduleNodeIO: moduleNodeIOLib, moduleToolboxes: moduleToolboxesLib, splineTube: splineTubeLib, splineTool: splineToolLib, splineEdit: splineEditLib, terrainCarve: terrainCarveLib, flattenActions: flattenActionsLib, hudViewportDrag: hudViewportDragLib, gamepadPrefs: gamepadPrefsLib, charController: charControllerLib, hudRichText: hudRichTextLib, moduleHudKinds: moduleHudKindsLib, hudMinimap: hudMinimapLib, hudArrange: hudArrangeLib, gamePresence: gamePresenceLib, levels: levelsLib, peerVars: peerVarsLib, projectManifest: projectManifestLib, projectFile: projectFileLib, transientObjects: transientObjectsLib, spawner: spawnerLib, triggerSync: triggerSyncLib, saveName: saveNameLib, sceneIdentity: sceneIdentityLib, importDuplicates: importDuplicatesLib, peerScenes: peerScenesLib, sharedLibrary: sharedLibraryLib, transferLedger: transferLedgerLib, explorerView: explorerViewLib, filePreview: filePreviewLib, saveAs: saveAsLib, windowTabs: windowTabsLib, colocation: colocationLib, colocationCalibrate: colocationCalibrateLib, colocationPresence: colocationPresenceLib, xrAnchors: xrAnchorsLib, colocationAnchors: colocationAnchorsLib, colocationNudge: colocationNudgeLib, mountedVolumes: mountedVolumesLib, storageUsage: storageUsageLib, scenePrivacy: scenePrivacyLib, touchControls: touchControlsLib, playMode: playModeLib, objectListNav: objectListNavLib, inviteLinks: inviteLinksLib, helperLayer: helperLayerLib, explorerClipboard: explorerClipboardLib, lookPresence: lookPresenceLib, postGraphs: postGraphsLib, postGraphPresets: postGraphPresetsLib, materialSharing: materialSharingLib, diagnostics: diagnosticsLib, wireValidate: wireValidateLib, wireErrors: wireErrorsLib, safeStorage: safeStorageLib, sceneBudget: sceneBudgetLib, overloadGuard: overloadGuardLib, qualityGovernor: qualityGovernorLib, importBudget: importBudgetLib, importGate: importGateLib, decimate: decimateLib, playCursor: playCursorLib, gameStorage: gameStorageLib } + import('./lib/gameStorage'), + // 30b (vr-play): the game-feel leaves under ONE name + import('./lib/gameKit'), + import('./lib/moduleWorld'), + import('./lib/playSpawn') + ]).then(([sceneStore, appStore, flowStore, meshEdit, vrControls, autosave, voiceChat, audioEngineLib, musicClockLib, audioDevicesLib, audioPatchLib, vrPatchLib, musicToolboxLib, micCaptureLib, annotationsHandler, flowRuntime, history, materialsHandler, objectActions, commandsHandler, moduleSDK, drawModeLib, pathCapture, lockControl, prefabsLib, physics, jointsLib, possessLib, handModelsLib, terrainSculptLib, userModulesLib, environmentLib, sceneMusicLib, animatedImports, fileHandler, fileWindowsLib, sceneBounds, cameraClip, ping, sessionsLib, geometryEdit, lightParams, shadowDefaultsLib, paletteLib, viewModeLib, inputRuntimeLib, shortcutsLib, themesLib, vrRadialMenu, vrPaletteLib, vrWindowPosesLib, vrKeyboardLib, faceEditLib, meshToolParamsLib, avatarModelLib, explorerLib, bottomDock, explorerDrop, assetShare, soundRuntime, dungeonPlay, sceneAssetsLib, THREE, GLTFExporterModule, snappingLib, flowSocketsLib, networkQualityLib, packsLib, customNodesLib, nodesHandlerLib, nodeCatalogLib, objectMenuLib, animationPreviewLib, aiProvidersLib, aiToolsLib, aiAssistantLib, meshProvidersLib, meshJobsLib, flowGraphsLib, objectFlowLib, peerServerLib, cloudHooksLib, cloudPluginLib, connectionStateLib, peerApprovalLib, particleRuntimeLib, particleActionsLib, particlePresetsLib, versionLib, whatsNewLib, confirmDialogLib, scenePhysicsLib, playInteractLib, moveSmoothingLib, knockLib, playSettingsLib, colliderSpecLib, colliderHelpersLib, colliderEditLib, editSessionLib, trackpadNavLib, vrSleeveLib, gridSettingsLib, viewPrefsLib, cameraBookmarksLib, cameraObjectsLib, cameraHelpersLib, onionSkinLib, cameraPreviewLib, addObjectsLib, cameraPipLib, inputDeviceLib, sceneTemplatesLib, bvhPickingLib, multiTransformLib, objectOriginLib, moduleGalleryLib, uvEditorLib, uvUnwrapLib, meshTopologyLib, meshBudgetLib, proportionalLib, proportionalRingLib, scenePickLib, snapEngineLib, meshPivotLib, selectionPrefsLib, editOverlaysLib, objectPermissionsLib, scenePostLib, postEffectsLib, viewportOverridesLib, postprocessingModule, shaderBackendsLib, shaderGraphLib, shaderSyncLib, shaderTexturesLib, shaderCatalogLib, unitsLib, postBackendsLib, workspaceLib, editResumeLib, moduleRequirementsLib, hudDocsLib, hudSyncLib, idbLib, hudKindsLib, hudImagesLib, gameStateLib, gameSyncLib, hudActionsLib, moduleNodeIOLib, moduleToolboxesLib, splineTubeLib, splineToolLib, splineEditLib, terrainCarveLib, flattenActionsLib, hudViewportDragLib, gamepadPrefsLib, charControllerLib, hudRichTextLib, moduleHudKindsLib, hudMinimapLib, hudArrangeLib, gamePresenceLib, levelsLib, peerVarsLib, projectManifestLib, projectFileLib, transientObjectsLib, spawnerLib, triggerSyncLib, saveNameLib, sceneIdentityLib, importDuplicatesLib, peerScenesLib, sharedLibraryLib, transferLedgerLib, explorerViewLib, filePreviewLib, saveAsLib, windowTabsLib, colocationLib, colocationCalibrateLib, colocationPresenceLib, xrAnchorsLib, colocationAnchorsLib, colocationNudgeLib, mountedVolumesLib, storageUsageLib, scenePrivacyLib, touchControlsLib, playModeLib, objectListNavLib, inviteLinksLib, helperLayerLib, explorerClipboardLib, lookPresenceLib, postGraphsLib, postGraphPresetsLib, materialSharingLib, diagnosticsLib, wireValidateLib, wireErrorsLib, safeStorageLib, sceneBudgetLib, overloadGuardLib, qualityGovernorLib, importBudgetLib, importGateLib, decimateLib, playCursorLib, gameStorageLib, gameKitLib, moduleWorldLib, playSpawnLib]) => { + window.__stores = { ...sceneStore, ...appStore, ...flowStore, meshEdit, vrControls, autosave, voiceChat, audioEngine: audioEngineLib, musicClock: musicClockLib, audioDevices: audioDevicesLib, audioPatch: audioPatchLib, vrPatch: vrPatchLib, musicToolbox: musicToolboxLib, micCapture: micCaptureLib, annotationsHandler, flowRuntime, history, materialsHandler, objectActions, commandsHandler, moduleSDK, drawMode: drawModeLib, pathCapture, lockControl, prefabs: prefabsLib, physics, joints: jointsLib, possess: possessLib, handModels: handModelsLib, terrainSculpt: terrainSculptLib, userModules: userModulesLib, environment: environmentLib, sceneMusic: sceneMusicLib, animatedImports, fileHandler, fileWindows: fileWindowsLib, sceneBounds, cameraClip, ping, sessions: sessionsLib, geometryEdit, lightParams, shadowDefaults: shadowDefaultsLib, palette: paletteLib, viewModeCtl: viewModeLib, inputRuntime: inputRuntimeLib, shortcutsRegistry: shortcutsLib, themes: themesLib, vrRadialMenu, vrPalette: vrPaletteLib, vrWindowPoses: vrWindowPosesLib, vrKeyboard: vrKeyboardLib, faceEdit: faceEditLib, meshToolParams: meshToolParamsLib, avatarModel: avatarModelLib, explorer: explorerLib, bottomDock, explorerDrop, assetShare, soundRuntime, dungeonPlay, sceneAssets: sceneAssetsLib, THREE, GLTFExporterModule, snapping: snappingLib, flowSockets: flowSocketsLib, networkQuality: networkQualityLib, packs: packsLib, customNodes: customNodesLib, nodesHandler: nodesHandlerLib, nodeCatalog: nodeCatalogLib, objectMenu: objectMenuLib, animationPreview: animationPreviewLib, aiProviders: aiProvidersLib, aiTools: aiToolsLib, aiAssistant: aiAssistantLib, meshProviders: meshProvidersLib, meshJobs: meshJobsLib, flowGraphsCtl: flowGraphsLib, objectFlow: objectFlowLib, peerServer: peerServerLib, cloudHooks: cloudHooksLib, cloudPlugin: cloudPluginLib, connectionState: connectionStateLib, peerApproval: peerApprovalLib, particleRuntime: particleRuntimeLib, particleActions: particleActionsLib, particlePresets: particlePresetsLib, version: versionLib, whatsNew: whatsNewLib, confirmDialog: confirmDialogLib, scenePhysics: scenePhysicsLib, playInteract: playInteractLib, moveSmoothing: moveSmoothingLib, knock: knockLib, playSettings: playSettingsLib, colliderSpec: colliderSpecLib, colliderHelpers: colliderHelpersLib, colliderEdit: colliderEditLib, editSession: editSessionLib, trackpadNav: trackpadNavLib, vrSleeve: vrSleeveLib, gridSettings: gridSettingsLib, viewPrefs: viewPrefsLib, cameraBookmarks: cameraBookmarksLib, cameraObjects: cameraObjectsLib, cameraHelpers: cameraHelpersLib, onionSkin: onionSkinLib, cameraPreview: cameraPreviewLib, addObjects: addObjectsLib, cameraPip: cameraPipLib, inputDevice: inputDeviceLib, sceneTemplates: sceneTemplatesLib, bvhPicking: bvhPickingLib, multiTransform: multiTransformLib, objectOrigin: objectOriginLib, moduleGallery: moduleGalleryLib, uvEditor: uvEditorLib, uvUnwrap: uvUnwrapLib, meshTopology: meshTopologyLib, meshBudget: meshBudgetLib, proportional: proportionalLib, proportionalRing: proportionalRingLib, scenePick: scenePickLib, snapEngine: snapEngineLib, meshPivot: meshPivotLib, selectionPrefs: selectionPrefsLib, editOverlays: editOverlaysLib, objectPermissions: objectPermissionsLib, scenePost: scenePostLib, postEffects: postEffectsLib, viewportOverrides: viewportOverridesLib, postprocessing: postprocessingModule, shaderBackends: shaderBackendsLib, shaderGraph: shaderGraphLib, shaderSync: shaderSyncLib, shaderTextures: shaderTexturesLib, shaderCatalog: shaderCatalogLib, units: unitsLib, postBackends: postBackendsLib, workspace: workspaceLib, editResume: editResumeLib, moduleRequirements: moduleRequirementsLib, hudDocs: hudDocsLib, hudSync: hudSyncLib, idb: idbLib, hudKinds: hudKindsLib, hudImages: hudImagesLib, gameState: gameStateLib, gameSync: gameSyncLib, hudActions: hudActionsLib, moduleNodeIO: moduleNodeIOLib, moduleToolboxes: moduleToolboxesLib, splineTube: splineTubeLib, splineTool: splineToolLib, splineEdit: splineEditLib, terrainCarve: terrainCarveLib, flattenActions: flattenActionsLib, hudViewportDrag: hudViewportDragLib, gamepadPrefs: gamepadPrefsLib, charController: charControllerLib, hudRichText: hudRichTextLib, moduleHudKinds: moduleHudKindsLib, hudMinimap: hudMinimapLib, hudArrange: hudArrangeLib, gamePresence: gamePresenceLib, levels: levelsLib, peerVars: peerVarsLib, projectManifest: projectManifestLib, projectFile: projectFileLib, transientObjects: transientObjectsLib, spawner: spawnerLib, triggerSync: triggerSyncLib, saveName: saveNameLib, sceneIdentity: sceneIdentityLib, importDuplicates: importDuplicatesLib, peerScenes: peerScenesLib, sharedLibrary: sharedLibraryLib, transferLedger: transferLedgerLib, explorerView: explorerViewLib, filePreview: filePreviewLib, saveAs: saveAsLib, windowTabs: windowTabsLib, colocation: colocationLib, colocationCalibrate: colocationCalibrateLib, colocationPresence: colocationPresenceLib, xrAnchors: xrAnchorsLib, colocationAnchors: colocationAnchorsLib, colocationNudge: colocationNudgeLib, mountedVolumes: mountedVolumesLib, storageUsage: storageUsageLib, scenePrivacy: scenePrivacyLib, touchControls: touchControlsLib, playMode: playModeLib, objectListNav: objectListNavLib, inviteLinks: inviteLinksLib, helperLayer: helperLayerLib, explorerClipboard: explorerClipboardLib, lookPresence: lookPresenceLib, postGraphs: postGraphsLib, postGraphPresets: postGraphPresetsLib, materialSharing: materialSharingLib, diagnostics: diagnosticsLib, wireValidate: wireValidateLib, wireErrors: wireErrorsLib, safeStorage: safeStorageLib, sceneBudget: sceneBudgetLib, overloadGuard: overloadGuardLib, qualityGovernor: qualityGovernorLib, importBudget: importBudgetLib, importGate: importGateLib, decimate: decimateLib, playCursor: playCursorLib, gameStorage: gameStorageLib, gameKit: gameKitLib, moduleWorld: moduleWorldLib, playSpawn: playSpawnLib } }) } }) diff --git a/src/components/LockHighlights.svelte b/src/components/LockHighlights.svelte index c61921b1..50e2811a 100644 --- a/src/components/LockHighlights.svelte +++ b/src/components/LockHighlights.svelte @@ -4,6 +4,8 @@ import { lockedObjects, objectsGroup } from '../stores/sceneStore'; import { peers } from '../stores/appStore'; import { peerColor } from '$lib/lockControl'; + // 30b P1: who-holds-what boxes are editor information - none in Interact/Play + import { editorHelpersShown } from '$lib/helperLayer'; // Wireframe box around every object locked by ANOTHER peer, tinted with // that peer's color (same hash as pings/cursors). Boxes track the object @@ -16,6 +18,7 @@ const helpers = new Map(); useTask(() => { + group.visible = $editorHelpersShown; /** @type {any} */ const peer = $peers; const scene = $objectsGroup; diff --git a/src/components/Outline.svelte b/src/components/Outline.svelte index 760800b0..ccbf5950 100644 --- a/src/components/Outline.svelte +++ b/src/components/Outline.svelte @@ -1,5 +1,5 @@ +{#if $gameAnnouncement && !$isVRMode && layerAllowed} + {#key $gameAnnouncement.id} +
+
{$gameAnnouncement.text}
+ {#if $gameAnnouncement.sub}
{$gameAnnouncement.sub}
{/if} +
+ {/key} +{/if} + {#if anyVisible}
{#each elements as el (el.__key + ':' + el.id)} @@ -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) */ 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} + + + + 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)}% + diff --git a/src/components/play/PointerLockControls.svelte b/src/components/play/PointerLockControls.svelte index faa9a53a..c8f9b900 100644 --- a/src/components/play/PointerLockControls.svelte +++ b/src/components/play/PointerLockControls.svelte @@ -1,12 +1,13 @@ 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 c07a0725..1abce886 100644 --- a/src/extensions/Grid.svelte +++ b/src/extensions/Grid.svelte @@ -1,11 +1,12 @@ - {#if showGrid && ($isLocked !== true || $helpersInPlay)} + {#if showGrid && $editorHelpersShown}