diff --git a/CHANGELOG.md b/CHANGELOG.md index 73225935..dac5cd03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,77 @@ per release, newest first. HTML comments like this one are stripped before rendering, so maintainer notes stay out of the user-facing window. --> +## 1.13.0 โ€” Knock it about ๐Ÿช + +### โœจ Knock things about (roadmap #24) + +- ๐ŸŒ  **Hit a floating object with your hand and it flies off.** In VR your hands, and on + desktop the camera you walk with, now knock a physics object at the speed you hit it โ€” + the first thing a zero-g room needs and the thing grab-and-throw never covered. Configure + Scene โ–ธ Physics โ–ธ **Knock** turns it on for a scene and sets the strength, the reach and + the spin; it is off in every scene that does not ask for it. +- ๐Ÿ’ฅ **An On Hit node** fires when something is knocked, with how hard (`speed`) and whether + it was you (`byMe`), so a graph can burst particles in proportion or count only your own + touches. **Who** can be narrowed to anyone / me / others. +- ๐ŸŒŸ **The Stars Room** โ€” a new template in Games. Twenty-four stars and two planets float in + a room with no gravity; knock them, watch them chime and drift, add more from the HUD, or + press Start for a round that ends when every star is lit. Nothing to install. +- ๐Ÿ”„ **Someone joining a running simulation now knows it is running**, so their knocks and + their grab work from the first second instead of after the next restart. +- ๐Ÿฉน Fixed on the way: a Player Variable now reaches the node it is wired to, and a colour, + shader-uniform or device effect keeps working on an object the physics simulation owns. + +### ๐ŸŽจ One scene look, and materials you can share + +- ๐Ÿ‘€ **Watching someone shows you their look.** Watch a peer and you see the camera they are + looking through, its grade, their view mode and their scene-look switches โ€” the banner says + when something cannot be adopted. It ends when you stop watching. +- ๐ŸŒ“ **Shader graphs have a Post domain**: build a post-processing effect as a node graph and + drop it into the scene look. Ships with Posterise, Ordered dither, Edge detect (ink) and a + graph-built Ambient occlusion. +- ๐Ÿ—‚๏ธ **Configure Scene โ–ธ Scene look** is now one section for the whole look โ€” the post stack, + the scene's default material and per-object shaders โ€” with a line saying what it costs. +- ๐Ÿ™ˆ **Scene shaders can be switched off on your own screen** (Configure Scene โ–ธ View โ–ธ + Overrides), the way post already could. Nobody else's view changes. +- ๐Ÿ”— **Duplicate can share a material instead of copying it** (Settings โ–ธ Scene โ–ธ Duplicate). + A shared material carries its link through save, undo, a peer's copy and a late joiner, and + the Material section has **Unlink** when you want your own again. + +### ๐Ÿงฑ Modelling, windows and the node editor + +- ๐Ÿซฑ **Proportional editing reaches your peers.** Drag a vertex with a falloff radius and the + whole neighbourhood now arrives on every other screen (in one step, when you let go) โ€” + before, only the vertices you had selected moved for anybody else. +- ๐Ÿ”ƒ **Proportional rotate and scale**, not just move: the falloff blends the turn and the + scale toward identity across the radius, so a rotation twists the surrounding surface + instead of leaving it behind. +- ๐Ÿ’ฌ Vertex slide now says why it stands down while a custom pivot is placed, instead of + quietly doing nothing. +- ๐ŸชŸ **Two windows on one screen edge.** Drop a second window onto a docked one and the edge + splits into two stacked panels with a divider you can drag; the share is remembered per side. +- ๐Ÿ–ฑ๏ธ **Settings โ–ธ Input โ–ธ Node editor โ–ธ Mouse bindings** โ€” keep Classic (left-drag pans, as + always) or switch to Select-first, where a left drag draws a selection rectangle, dragging + any selected node moves the whole set, and the right button pans. + +### โšฝ VR Football (a module) + +- ๐Ÿฅ… **Football** is a new module in Browse: two floating gates, one ball, red against blue, + played with your hands. Install it, open its toolbox and **Build pitch** lays out the pitch, + the gates and the rules in your scene. Goals go to whoever touched the ball last, own goals + land on the right sheet, and the mode decides when the match ends (first to N, a clock, or + either). Scores, touches and own goals are per player, the gate lamps read from across the + room, and a saved scene keeps the match log. Two people in one room can play it colocated. + (A ready-made Football template for the Games tab is not published yet โ€” the toolbox recipe + is how you get a pitch today.) + +### ๐Ÿ› ๏ธ For people building on it + +- ๐ŸฅŠ **`api.onHit(cb)` and `api.hitLog()`** โ€” a module can react to a knock (uuid, who, how + hard, where) or read the recent ones, which is what the football module's last-touch rule + stands on. +- ๐Ÿงฉ The template author script gained one graph builder and a remap that resolves every + object named in node data, so a game def can name objects instead of carrying uuids. + ## 1.12.0 โ€” Hold together ๐Ÿ›ก๏ธ ### ๐Ÿ›ก๏ธ Connections that recover, and sessions with a size (roadmap #27 + #25) diff --git a/CLAUDE.md b/CLAUDE.md index ba754958..393395eb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2013,6 +2013,51 @@ loadable play content. Everything a user does must be visible to connected peers `docs/plans-core/`, local `../theprototype.app-cloud`). This repo's `/docs` is gitignored scratch space (pointer READMEs inside). +- `src/lib/knock.js` + `knockMath.js` (24-A) โ€” A HAND KNOCKS A BODY. `knockMath` is the pure + leaf (THREE + `throwVelocity`): a 6-sample/100ms probe ring, `contactOf` (sphere vs bounding + sphere; `approach` = closing speed along the normal), `knockResponse` (`v' = v_body + n * + approach * gain`, infinite-mass hand, spin from the TANGENTIAL slip โ€” a sphere contact is + central, so `r ร— ฮ”v` is zero by construction), `cooldownStep`. `knock.js` is the runtime: + VR hands arrive through a seam `Scene.svelte` passes in (it never imports vrControls), the + desktop camera is a 0.35 m head probe, both carried into the objects group's frame. The + HITTER broadcasts `{type:'hit'}` whoever it is; the INITIATOR applies it (`physics.applyHit`, + the throw's sibling โ€” `clampThrow` + the scene's `maxSpeed`, CCD over 5 m/s); a + non-initiator predicts locally behind `knock.predict` and withdraws after 400 ms. + `hit` is CONTENT (gateable, room-scoped), `by` is stamped from the connection, and its `at` + is **`sessionNow()`** โ€” A2 folds it into the trigger log beside every other stamp, so it has + to be on 25-E's session clock, not the sender's raw one. `scenePhysics` carries the nested + `knock` block, `enabled:false`, which is what keeps every saved scene byte-identical. +- `src/lib/lookPresence.js` (P2) โ€” a peer's LOOK as PRESENCE, in the `campreview` shape: + `{camera, mode, overrides:{post,shaders}, look}` sent on change and in the `getmodulestate` + reply, dropped at both `finalizeDisconnect` sites. Watching a peer resolves the post chain + from THEIR row (`Outline.svelte`), and 26-D's quality governor still applies on top โ€” the + governor is this machine giving up post to hold its frames, which watching must not undo. +- `src/lib/postGraphs.js` + `postGraphPresets.js` (P4) โ€” a shader graph whose output IS a + post-processing effect. `shaderGraph`'s `registerPostDomain` seam keeps the compiler core + shared (`createCompiler(graph, outputType)` in `shaderCompile.js`); the post domain differs + only in its inputs (SceneColor/SceneDepth/SceneNormal/UV/Time/Resolution) and its terminal + (`postOutput`), plus `EffectAttribute.DEPTH` and a NormalPass added on demand. +- `src/lib/materialSharing.js` (D2) โ€” two objects, ONE material, by id. The id is scene data + and survives every carrier (wire, autosave, sessions, undo, GLTF rebuild) because + `startMaterialSharing()` re-unifies by id whenever the scene changes; the SENDER fans the + per-object messages a receiver already understands, so the wire is byte-unchanged and no + capability-gate entry or older-peer story is needed. `materialsHandler`'s material-TYPE + swap is the one op that replaces the instance rather than writing into it, so it hands the + new instance to the sharers too. +- `src/lib/flowPrefs.js` (114) โ€” the node editor's mouse bindings as a LOCAL pref leaf + (svelte/store + safeStorage): `classic` (left-drag pans, the default and byte-identical to + every version before it) or `select` (left-drag rectangle-selects, right-drag pans). +- `docking.js` (81.4) โ€” an edge holds TWO stacked panels: `docked` is + `{left: string[], right: string[]}`, `dockSplit:` is the share, and a DOCKED window's + rect belongs to docking.js โ€” so `dragWindow`'s and the object list's reveal clamps stand + down for it, exactly as they already do for a tab member. +- `meshEdit.js` (F1/F3) โ€” a proportional drag ends in ONE whole-geometry `meshgeo` commit + (`commitFalloffSnapshot`), applied LOCALLY as well as broadcast: `applyMeshGeo` rebuilds the + receiver index-EXPANDED while a `/create Plane` is indexed, so both sides must swap together + or the sender's next `verts` indices address a layout the peer no longer holds. The selection + is re-found by POSITION after the swap. `applyPivotTransform` now covers the falloff for + rotate and scale by slerping the rotation and lerping the scale toward identity per vertex. + ## Replication golden rules 1. Every mutation = apply locally + `$peers.send({type, ...})`; receivers apply WITHOUT @@ -4180,6 +4225,37 @@ loadable play content. Everything a user does must be visible to connected peers sometimes without even an unused-selector warning); svg `className` is an SVGAnimatedString โ€” e2e reads `getAttribute('class')` and selects `svg`, not `i`. +- **`git checkout --theirs -- ` during a merge discards the WHOLE ours side of that + file, not just the conflicted hunk.** Resolving App.svelte's debug-hook tails that way + silently dropped the one `import('./lib/knock')` line that had auto-merged cleanly 100 lines + above, leaving a destructure with one more name than the import list โ€” every module after it + bound to its neighbour. The three tails (the `Promise.all` import list, the destructured + parameter list, the `window.__stores` object) must always COUNT EQUAL and be in the same + order; check that after any merge that touches them. +- **An attribute INDEX recorded before a `meshgeo` commit addresses a DIFFERENT vertex after + it** โ€” the commit rebuilds the mesh index-expanded (81 entries โ†’ 384 in triangle order). Any + fixture or handle map captured before it must be re-found by POSITION, not carried over. +- **A handle hung past a docked window's edge is clipped away and takes no pointer events** โ€” + every docked window is `overflow-hidden`. The 81.4 divider sits INSIDE the top panel; the + width grip only works because half of its 6px is inside. +- **Chromium fires `contextmenu` on the PRESS**, so a travel check there is always zero. + Decide click-versus-drag on pointerup (114's pane menu does). +- **The node pane's empty bottom-right corner is the MINIMAP** โ€” a drag there pans at the + minimap's own scale (measured 631px for an 80px gesture). +- **`.peer-watch` is worn by TWO buttons** โ€” Watch, and the join-a-peer's-camera button + (`.peer-watch.peer-preview`) that only renders while that peer previews a camera. A suite + selecting the bare class joins the camera instead of watching and reads the right answer for + the wrong reason; `.spectator-exit` has the same shape. +- **A type check cannot tell a shader-driven material from its base** โ€” the injected material + is a CLONE, so both read `MeshStandardMaterial`. Measure material IDENTITY instead. +- **A guard that is redundant on the happy path is not proven by the happy path**: D2's fan and + its applier link both survived deletion until the suite staged the one state each exists for. +- **A constant in a shader graph must be a Float node** โ€” the arithmetic nodes take operands + from sockets and have no params, so authored `{b: 0.5}` node data is silently ignored. +- **A perRound latch count reads 0 the instant the round ends** (the Infinity cutoff), so a + suite that polls `flowValues` for "all N lit" can only ever catch it for one publish. Assert + the TRIGGER LOG instead, folded to seconds-of-day the way `retiredByRound` does. + ## Verification (mandatory before commit) PIXEL features (post-processing, outlines, AO) are asserted through the helpers diff --git a/scripts/author-templates.cjs b/scripts/author-templates.cjs index e120d62e..fb271b7c 100644 --- a/scripts/author-templates.cjs +++ b/scripts/author-templates.cjs @@ -38,6 +38,21 @@ // becomes its content hash, so a Sound node can play the same bytes) ยท `view` (the // editor camera the file opens on) ยท `thumb.camera` (render the card through a named // camera object). A def with `music` exports WITH assets, so the bytes ride the .tpscene. +// +// 24-A A3 (the PR #192 follow-up): the node/edge helpers are ONE module-scope +// `graphBuilder()` and `remapData` walks every own string field. Both are meant to leave +// every earlier def byte-identical, and that is CHECKED, not believed โ€” build the same def +// with the script before and after the change and compare the two trees: +// +// APP_URL=https://theprototype.app:5177/ node scripts/author-templates.cjs --only towers --out /tmp/towers-before +// (edit) APP_URL=https://theprototype.app:5177/ node scripts/author-templates.cjs --only towers --out /tmp/towers-after +// node scripts/compare-authored.cjs /tmp/towers-before /tmp/towers-after +// +// compare-authored.cjs unzips each .tpscene, strips what a build mints afresh every run +// (`changedAt`/`createdAt`/`at` stamps, the session uuid, and every object uuid โ€” replaced +// by its order of first appearance, so the graph's remapped references still have to +// agree) and diffs the rest; the thumbnails are compared by size only (an offscreen render +// is not bit-stable across GPU drivers). const { chromium } = require('playwright'); const fs = require('fs'); const path = require('path'); @@ -79,16 +94,15 @@ const ONLY = // {mode:'static'|'dynamic', mass, restitution, friction}. const gray = { floor: 0x8b939c, block: 0xaab2bd, wall: 0x99a3ae, accent: 0xd97706 }; -// ---- B8: Towers, the first GAME def ------------------------------------------- -// A DATA-ONLY game: core nodes + a HUD document + the collectible module. Rebuilt -// from the first playthrough's findings โ€” the clever sensor-conveyor spawner cascaded -// once you grabbed a crate (spawn -> falls into the zone -> jitters out -> spawns -// again), the emissive shader docs read "strange", and the night look was black on the -// user's display. So: crates are PRE-PLACED dynamic objects (grabbable, stable, no -// churn โ€” the plan's own "Towers pre-places crates"); the look is a lit preset plus -// material emissive, no shader graphs; every node carries a label; and a pause menu -// (P) gives a Restart-while-playing button. -function towersGraph() { +// ---- 24-A A3: the ONE graph builder every def authors its nodes through ----------- +// Hoisted from towersGraph()/beatGraph(), which each carried a local copy (PR #192's +// review asked for this the moment a second game arrived). Byte-identical output: the +// `class: 'w-[150px]'`, the label rule (a programmatic node with no label renders a blank +// card) and the editor's CANONICAL edge id โ€” `e-[.]- +// [.]` (Nodes.svelte / hudActions.makeEdge) โ€” which peer dedupe depends on. +// The E signature is the beat graph's superset: a Sequence step is a SOURCE handle, and a +// three-argument call (every Towers edge) produces exactly the id it always did. +function graphBuilder() { /** @type {any[]} */ const nodes = []; /** @type {any[]} */ const edges = []; /** every node gets a LABEL โ€” a programmatic node with none renders a blank card. @@ -97,16 +111,36 @@ function towersGraph() { nodes.push({ id, type, position: { x, y }, data: { label, ...data }, class: 'w-[150px]' }); return id; }; - // the editor's canonical edge id (hudActions.makeEdge) โ€” peer dedupe depends on it - /** @param {string} source @param {string} target @param {string} [handle] */ - const E = (source, target, handle) => { + /** @param {string} source @param {string} target @param {string} [targetHandle] @param {string} [sourceHandle] */ + const E = (source, target, targetHandle, sourceHandle) => { edges.push({ - id: 'e-' + source + '-' + target + (handle ? '.' + handle : ''), + id: 'e-' + source + (sourceHandle ? '.' + sourceHandle : '') + '-' + target + (targetHandle ? '.' + targetHandle : ''), source, target, - ...(handle ? { targetHandle: handle } : {}) + ...(sourceHandle ? { sourceHandle } : {}), + ...(targetHandle ? { targetHandle } : {}) }); }; + return { N, E, nodes, edges, done: () => ({ nodes, edges }) }; +} + +// 24-A A3: the node-data keys that are HUMAN TEXT and must never be remapped, even when +// their value happens to equal a def-local object's name โ€” a HUD text whose format is +// literally "Build pad" must stay text, and a variable NAMED like an object is a name. +const HUMAN_TEXT_KEYS = new Set(['label', 'format', 'text', 'placeholder', 'name']); + +// ---- B8: Towers, the first GAME def ------------------------------------------- +// A DATA-ONLY game: core nodes + a HUD document + the collectible module. Rebuilt +// from the first playthrough's findings โ€” the clever sensor-conveyor spawner cascaded +// once you grabbed a crate (spawn -> falls into the zone -> jitters out -> spawns +// again), the emissive shader docs read "strange", and the night look was black on the +// user's display. So: crates are PRE-PLACED dynamic objects (grabbable, stable, no +// churn โ€” the plan's own "Towers pre-places crates"); the look is a lit preset plus +// material emissive, no shader graphs; every node carries a label; and a pause menu +// (P) gives a Restart-while-playing button. +function towersGraph() { + const g = graphBuilder(); + const { N, E } = g; // ---- round control --------------------------------------------------------- // Start from the menu: entering 'playing' from menu BUMPS the round and re-stamps @@ -216,7 +250,7 @@ function towersGraph() { N('gotime', 'setgamestate', 'Time over', 1000, 2240, { state: 'over', outcome: "Time's up!", reset: false }); E('alltime', 'gotime', 'trigger'); - return { nodes, edges }; + return g.done(); } const TOWERS_HUD_PANEL = { @@ -354,6 +388,317 @@ const TOWERS_DEF = { ] }; +// ---- 24-A A4: Stars Room, the second GAME def ------------------------------------- +// The first game that needs NO module download: a zero-g room you knock stars around +// in (A1's knock, A2's On Hit), pure core. Sandbox by default (locked fork 4): the sim +// runs on Play, the stars react, the touch leaderboard counts; the P menu (or the +// physical Start pad, for VR โ€” F8: the DOM HUD is invisible in a headset) opens the +// OPTIONAL round: light every star. Design notes worth keeping: +// ยท the default screen is \`input: 'game'\` โ€” a \`menu\`-input screen visible while +// playing releases the pointer lock (21-E3), which would make free play unplayable +// on desktop; every button lives on the P menu, plus two onclick PADS for VR; +// ยท the lit colour rides latch -> Select -> Set Color. The editor would refuse to draw +// Select (number) into Set Color's colour input, but the runtime reads whatever the +// 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 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\`, +// dropped into the Explorer, addressed by \`'$sound:'\` from a Sound node โ€” NOT +// \`music\`, which would also fill the scene's background-music slot; +// ยท dark by nature, so authored on a LIT preset at low exposure + bloom (F14, the +// Towers finding); the look on the user's display is owed, never assumed. +const STARS_HUD_PANEL = { + bg: 'rgba(8, 10, 24, 0.9)', + radius: 16, + border: '1px solid rgba(255, 212, 94, 0.25)' +}; +const STARS_CHIME = { + key: 'chime', + name: 'impact-glass.ogg', + url: 'https://cdn.jsdelivr.net/gh/theprototype-app/packs@v1/audio-essentials/assets/impact-glass.ogg', + sha256: '9252d50bfb85edb17d6073c4a7806e10cdb9de56d3dbfc93a4b9727146d2df6d', + credit: { what: 'Impact Glass', author: 'Kenney', license: 'CC0-1.0', source: 'https://kenney.nl/assets/impact-sounds' } +}; +const STAR_DIM = '#3b3f66'; +const STAR_LIT = '#ffe08a'; +/** a seeded LCG, so the lattice jitter is DATA and two builds place every star alike + * @param {number} seed */ +function seeded(seed) { + let s = seed >>> 0; + return () => { + s = (Math.imul(s, 1664525) + 1013904223) >>> 0; + return s / 4294967296; + }; +} +/** 24 stars on a jittered 4 x 6 lattice at hand height (0.8-2.6 m), r 0.16-0.3 */ +function starObjects() { + const rand = seeded(24); + const palette = [ + { color: 0xffe08a, emissive: 0xffcf50 }, + { color: 0x9ad0ff, emissive: 0x5aa8ff }, + { color: 0xffb0d8, emissive: 0xff6ab0 }, + { color: 0xc8ffb0, emissive: 0x8aff6a } + ]; + /** @type {any[]} */ const out = []; + let i = 0; + for (let gx = 0; gx < 4; gx++) + for (let gz = 0; gz < 6; gz++) { + i++; + const x = -3.9 + gx * 2.6 + (rand() - 0.5) * 1.2; + const z = -4.5 + gz * 1.8 + (rand() - 0.5) * 1.0; + const y = 0.8 + rand() * 1.8; + const r = 0.16 + rand() * 0.14; + const p = palette[(i - 1) % palette.length]; + out.push({ + type: 'sphere', name: 'Star ' + i, color: p.color, r: +r.toFixed(3), + pos: [+x.toFixed(2), +y.toFixed(2), +z.toFixed(2)], + emissive: p.emissive, emissiveIntensity: 1.2, roughness: 0.4, + physics: { mode: 'dynamic', mass: 0.2, restitution: 0.9, friction: 0.1 } + }); + } + return out; +} + +function starsGraph() { + const g = graphBuilder(); + const { N, E } = g; + // ---- the round: Start (P menu or the VR pad) -> playing; Back to menu ------------ + N('bstart', 'hudbutton', 'Start button', 40, 40, { element: 'start-btn' }); + N('gostart', 'setgamestate', 'Start round', 280, 40, { state: 'playing', outcome: '', reset: false }); + E('bstart', 'gostart', 'trigger'); + N('padstart', 'onclick', 'Start pad clicked', 40, 120, { pulse: 0.3 }); + N('selstartpad', 'objectselector', 'Start pad', 280, 120, { selected: 'Start pad' }); + E('padstart', 'selstartpad'); + E('padstart', 'gostart', 'trigger'); + N('starthide', 'hudscreen', 'Close menu on start', 520, 40, { screen: 'pause', action: 'hide' }); + E('bstart', 'starthide', 'trigger'); + N('bagain', 'hudbutton', 'Play again button', 40, 200, { element: 'again-btn' }); + N('gomenu', 'setgamestate', 'Back to menu', 280, 200, { state: 'menu', outcome: '', reset: true }); + E('bagain', 'gomenu', 'trigger'); + // ---- the P menu (Towers' pause, plus Start / More stars) -------------------------- + N('pkey', 'keypress', 'Press P', 40, 340, { code: 'KeyP', edge: 'down', pulse: 0.3 }); + N('pausetoggle', 'hudscreen', 'Toggle menu', 280, 340, { screen: 'pause', action: 'toggle' }); + E('pkey', 'pausetoggle', 'trigger'); + N('bresume', 'hudbutton', 'Resume button', 40, 490, { element: 'resume-btn' }); + N('resumehide', 'hudscreen', 'Close menu', 280, 490, { screen: 'pause', action: 'hide' }); + E('bresume', 'resumehide', 'trigger'); + N('brestart', 'hudbutton', 'Restart button', 40, 640, { element: 'restart-btn' }); + N('restartreset', 'setgamestate', 'Restart: to menu', 280, 640, { state: 'menu', outcome: '', reset: true }); + N('restartdelay', 'delay', 'Restart: wait', 520, 640, { seconds: 0.2, pulse: 0.3 }); + N('restartplay', 'setgamestate', 'Restart: play', 760, 640, { state: 'playing', outcome: '', reset: false }); + N('restarthide', 'hudscreen', 'Close menu on restart', 280, 760, { screen: 'pause', action: 'hide' }); + E('brestart', 'restartreset', 'trigger'); + E('brestart', 'restartdelay', 'trigger'); + E('restartdelay', 'restartplay', 'trigger'); + E('brestart', 'restarthide', 'trigger'); + N('bquit', 'hudbutton', 'Quit to free play button', 40, 790, { element: 'quit-btn' }); + N('doquit', 'setgamestate', 'Quit to free play', 280, 790, { state: 'menu', outcome: '', reset: true }); + N('quithide', 'hudscreen', 'Close menu on quit', 520, 790, { screen: 'pause', action: 'hide' }); + E('bquit', 'doquit', 'trigger'); + E('bquit', 'quithide', 'trigger'); + // ---- More stars: the menu button or the VR pad spawns 3 copies of the template ---- + N('bmore', 'hudbutton', 'More stars button', 40, 940, { element: 'more-btn' }); + N('padmore', 'onclick', 'More pad clicked', 40, 1020, { pulse: 0.3 }); + N('selmorepad', 'objectselector', 'More stars pad', 280, 1020, { selected: 'More stars pad' }); + E('padmore', 'selmorepad'); + N('seltpl', 'objectselector', 'Star template', 280, 940, { selected: 'Star template' }); + // \`at\` is an OFFSET from the template (under the floor at y -2): y 4 lands copies at 2 m + N('spawn', 'spawn', 'Spawn 3 stars', 520, 940, { x: 0, y: 4, z: 0, count: 3, maxAlive: 32, interval: 0.5, spread: 1.5 }); + E('bmore', 'spawn', 'trigger'); + E('padmore', 'spawn', 'trigger'); + E('seltpl', 'spawn', 'source'); + // ---- touches: per-player rows (one writer each), the sum, the leaderboard --------- + N('touch', 'setvariable', 'Count my touch', 520, 1240, { name: 'touches', value: 1, op: 'add', scope: 'player' }); + N('mytouch', 'peervariable', 'My touches', 40, 1390, { name: 'touches', read: 'mine', peer: '', fallback: 0 }); + N('hmine', 'hudtext', 'HUD my touches', 280, 1390, { element: 'touches-read', format: 'Your touches: {v}', decimals: 0, value: 0 }); + E('mytouch', 'hmine', 'value'); + N('hmine2', 'hudtext', 'HUD my touches (round)', 520, 1390, { element: 'touches-read-2', format: 'Your touches: {v}', decimals: 0, value: 0 }); + E('mytouch', 'hmine2', 'value'); + N('sumtouch', 'peervariable', 'All touches', 40, 1540, { name: 'touches', read: 'sum', peer: '', fallback: 0 }); + N('hsum', 'hudtext', 'HUD all touches', 280, 1540, { element: 'total-read', format: 'Touches: {v}', decimals: 0, value: 0 }); + E('sumtouch', 'hsum', 'value'); + N('board', 'leaderboard', 'Touch leaderboard', 520, 1540, { element: 'board', variable: 'touches', order: 'desc', format: '{name} โ€” {v}', decimals: 0, limit: 8 }); + N('board2', 'leaderboard', 'Touch leaderboard (round)', 760, 1540, { element: 'board-2', variable: 'touches', order: 'desc', format: '{name} โ€” {v}', decimals: 0, limit: 8 }); + // ---- the round clock --------------------------------------------------------- + N('clock', 'gametime', 'Round clock', 40, 1690, { read: 'elapsed', length: 600 }); + N('hclock', 'hudtext', 'HUD clock', 280, 1690, { element: 'clock', format: '{v}s', decimals: 0, value: 0 }); + E('clock', 'hclock', 'value'); + N('hfinal', 'hudtext', 'HUD final time', 520, 1690, { element: 'final-time', format: 'Every star lit in {v}s', decimals: 0, value: 0 }); + E('clock', 'hfinal', 'value'); + // ---- per star: burst + chime on any hit, a per-player touch on MY hit, a perRound + // latch that paints the star lit during a round --------------------------------------- + let prevSum = ''; + for (let i = 1; i <= 24; i++) { + const y = 1900 + (i - 1) * 180; + N('sel' + i, 'objectselector', 'Star ' + i, 1000, y, { selected: 'Star ' + i }); + N('hit' + i, 'onhit', 'Star ' + i + ' hit', 40, y, { pulse: 0.3, minSpeed: 0.3, who: 'anyone' }); + E('hit' + i, 'sel' + i); + N('mulc' + i, 'math', 'Burst size ' + i, 280, y, { op: 'mul', a: 0, b: 15 }); + E('hit' + i, 'mulc' + i, 'a', 'speed'); + N('pfx' + i, 'particle', 'Star ' + i + ' burst', 520, y, { + mode: 'burst', count: 40, lifetime: 0.9, speed: 1.8, gravity: 0, + turbulence: 0.3, sizeStart: 0.08, opacity: 0.9, sprite: 'star', blending: 'additive', space: 'world' + }); + E('hit' + i, 'pfx' + i, 'trigger'); + E('mulc' + i, 'pfx' + i, 'count'); + E('pfx' + i, 'sel' + i); + N('snd' + i, 'sound', 'Star ' + i + ' chime', 760, y, { + hash: '$sound:chime', file: STARS_CHIME.name, volume: 0.7, radius: 8, rolloff: 1, loop: false, playing: false + }); + E('hit' + i, 'snd' + i, 'trigger'); + E('snd' + i, 'sel' + i); + N('me' + i, 'onhit', 'Star ' + i + ' my hit', 40, y + 90, { pulse: 0.3, minSpeed: 0.3, who: 'me' }); + E('me' + i, 'sel' + i); + E('me' + i, 'touch', 'trigger'); + N('lat' + i, 'latch', 'Star ' + i + ' lit', 1240, y, { initial: false, perRound: true }); + E('hit' + i, 'lat' + i, 'set'); + N('lit' + i, 'select', 'Star ' + i + ' colour', 1480, y, { index: 0, a: STAR_DIM, b: STAR_LIT }); + E('lat' + i, 'lit' + i, 'index'); + 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); + 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'; + } 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('hlit', 'hudtext', 'HUD lit', 2200, 2000, { element: 'lit-read', format: 'Lit: {v} / 24', decimals: 0, value: 0 }); + E('sum24', 'hlit', 'value'); + N('alllit', 'compare', 'All lit?', 2200, 2150, { op: 'gte', a: 0, b: 24 }); + E('sum24', 'alllit', 'a'); + N('allwin', 'allplayers', 'Everyone agrees', 2440, 2150, { pulse: 0.3 }); + E('alllit', 'allwin', 'condition'); + N('gowin', 'setgamestate', 'Round won', 2680, 2150, { state: 'over', outcome: 'Every star lit!', reset: false }); + E('allwin', 'gowin', 'trigger'); + return g.done(); +} + +const STARS_TEXT = { size: 13, color: '#d8dee9', align: 'center' }; +const STARS_BTN = { size: 16, weight: '600', bg: '#3b7dd8', color: '#ffffff', radius: 10 }; +const STARS_DEF = { + kind: 'game', + slug: 'stars-room', + title: 'Stars Room', + description: + 'A zero-gravity room full of glowing stars. Knock them with your hands in VR or walk into them; press P for the round: light every star, and see who touched the most.', + license: 'CC0-1.0', + author: 'theprototype', + tags: ['zero-g', 'physics', 'sandbox', 'vr'], + // pure core โ€” the first game that needs no download (no 'modules', no 'installModules') + env: { preset: 'studio', exposure: 0.55 }, + physics: { + gravity: 0, + ground: { enabled: false }, + bounds: { limit: -50, action: 'respawn' }, + material: { friction: 0.1, restitution: 0.85 }, + damping: { linear: 0.35, angular: 0.2 }, + ccd: false, + play: { interaction: 'grab', grounded: false, simOnPlay: true }, + knock: { enabled: true, gain: 1, maxSpeed: 10, radius: 0.12, spin: 0.6 } + }, + post: { + enabled: true, + effects: [ + { id: 'ao', kind: 'ao', enabled: true, params: {} }, + { id: 'tone', kind: 'tonemapping', enabled: true, params: { mode: 'AGX' } }, + { id: 'bloom', kind: 'bloom', enabled: true, params: { intensity: 1.2, luminanceThreshold: 0.55 } }, + { id: 'vig', kind: 'vignette', enabled: true, params: {} }, + { id: 'aa', kind: 'smaa', enabled: true, params: {} } + ], + changedAt: 0 + }, + sounds: [STARS_CHIME], + view: { pos: [0, 3.4, 11], target: [0, 1.6, 0] }, + graphs: { scene: starsGraph() }, + hud: { + scene: { + active: '', + changedAt: 0, + screens: [ + { + id: 'free', + name: 'Free play', + showWhile: 'menu', + input: 'game', + elements: [ + { id: 'free-title', kind: 'text', anchor: 'top-center', x: 0, y: 14, w: 420, h: 30, z: 1, label: 'STARS ROOM ยท free play', style: { size: 18, weight: '700', color: '#ffd45e', align: 'center' } }, + { id: 'free-hint', kind: 'text', anchor: 'top-center', x: 0, y: 44, w: 520, h: 22, z: 1, label: 'Knock the stars. P: menu (start a round, more stars)', style: { size: 12, color: '#8b97a8', align: 'center' } }, + { id: 'touches-read', kind: 'text', anchor: 'top-right', x: 16, y: 14, w: 220, h: 24, z: 1, label: '', style: { size: 14, color: '#ffd45e', align: 'right' } }, + { id: 'total-read', kind: 'text', anchor: 'top-right', x: 16, y: 40, w: 220, h: 22, z: 1, label: '', style: { size: 12, color: '#c8d0dc', align: 'right' } }, + { id: 'board', kind: 'list', anchor: 'top-right', x: 16, y: 70, w: 220, h: 150, z: 1, label: '', title: 'Touches', rowsText: '', rows: 8, rowHeight: 18, style: { size: 12, bg: 'rgba(8, 10, 24, 0.6)', radius: 8, pad: 6 } } + ] + }, + { + id: 'hud', + name: 'Round', + showWhile: 'playing', + input: 'game', + elements: [ + { id: 'lit-read', kind: 'text', anchor: 'top-center', x: 0, y: 14, w: 280, h: 30, z: 1, label: '', style: { size: 18, weight: '600', color: '#ffe08a', align: 'center' } }, + { id: 'clock', kind: 'text', anchor: 'top-center', x: 0, y: 46, w: 120, h: 22, z: 1, label: '', style: { size: 13, color: '#c8d0dc', align: 'center' } }, + { id: 'touches-read-2', kind: 'text', anchor: 'top-right', x: 16, y: 14, w: 220, h: 24, z: 1, label: '', style: { size: 14, color: '#ffd45e', align: 'right' } }, + { id: 'board-2', kind: 'list', anchor: 'top-right', x: 16, y: 44, w: 220, h: 150, z: 1, label: '', title: 'Touches', rowsText: '', rows: 8, rowHeight: 18, style: { size: 12, bg: 'rgba(8, 10, 24, 0.6)', radius: 8, pad: 6 } }, + { id: 'play-hint', kind: 'text', anchor: 'bottom-center', x: 0, y: 12, w: 520, h: 20, z: 1, label: 'Light every star. P: menu', style: { size: 11, color: '#8b97a8', align: 'center' } } + ] + }, + { + id: 'pause', + name: 'Menu', + input: 'menu', + elements: [ + { id: 'pause-panel', kind: 'panel', anchor: 'center', x: 0, y: 0, w: 400, h: 400, z: 0, label: '', style: STARS_HUD_PANEL }, + { id: 'pause-title', kind: 'text', anchor: 'center', x: 0, y: -150, w: 360, h: 36, z: 1, label: 'STARS ROOM', style: { size: 28, weight: '700', color: '#ffd45e', align: 'center' } }, + { id: 'pause-sub', kind: 'text', anchor: 'center', x: 0, y: -112, w: 360, h: 40, z: 1, label: 'Zero gravity. Knock the stars with your hands in VR, or walk into them.', style: STARS_TEXT, wrap: true }, + { id: 'start-btn', kind: 'button', anchor: 'center', x: 0, y: -50, w: 250, h: 42, z: 1, label: 'Start round: light every star', enabled: true, style: STARS_BTN }, + { id: 'restart-btn', kind: 'button', anchor: 'center', x: 0, y: 0, w: 250, h: 42, z: 1, label: 'Restart round', enabled: true, style: { ...STARS_BTN, bg: '#4c9e6a' } }, + { id: 'more-btn', kind: 'button', anchor: 'center', x: 0, y: 50, w: 250, h: 42, z: 1, label: 'More stars', enabled: true, style: { ...STARS_BTN, bg: '#b0863b' } }, + { id: 'resume-btn', kind: 'button', anchor: 'center', x: 0, y: 100, w: 250, h: 42, z: 1, label: 'Resume', enabled: true, style: { ...STARS_BTN, size: 15, weight: '500', bg: '#3a4150', color: '#e5e9f0' } }, + { id: 'quit-btn', kind: 'button', anchor: 'center', x: 0, y: 150, w: 250, h: 42, z: 1, label: 'Quit to free play', enabled: true, style: { ...STARS_BTN, size: 15, weight: '500', bg: '#3a4150', color: '#e5e9f0' } } + ] + }, + { + id: 'over', + name: 'Round over', + showWhile: 'over', + input: 'menu', + elements: [ + { id: 'over-panel', kind: 'panel', anchor: 'center', x: 0, y: 0, w: 420, h: 250, z: 0, label: '', style: STARS_HUD_PANEL }, + { id: 'over-title', kind: 'text', anchor: 'center', x: 0, y: -70, w: 380, h: 40, z: 1, label: 'EVERY STAR LIT', style: { size: 30, weight: '700', color: '#ffd45e', align: 'center' } }, + { id: 'final-time', kind: 'text', anchor: 'center', x: 0, y: -18, w: 380, h: 26, z: 1, label: '', style: { size: 16, color: '#e5e9f0', align: 'center' } }, + { id: 'again-btn', kind: 'button', anchor: 'center', x: 0, y: 58, w: 220, h: 44, z: 1, label: 'Back to free play', enabled: true, style: STARS_BTN } + ] + } + ] + } + }, + objects: [ + // the room: 12 x 7 x 12, walls you can see through and bounce off + { type: 'box', name: 'Floor', color: 0x101626, size: [12, 0.5, 12], pos: [0, -0.25, 0], roughness: 0.9, physics: { mode: 'static', friction: 0.1, restitution: 0.85 } }, + { type: 'box', name: 'Ceiling', color: 0x101626, size: [12, 0.5, 12], pos: [0, 7.25, 0], roughness: 0.9, physics: { mode: 'static', friction: 0.1, restitution: 0.85 } }, + { type: 'box', name: 'Wall north', color: 0x2a3a6a, size: [12.5, 7, 0.5], pos: [0, 3.5, -6], emissive: 0x1a2a5a, emissiveIntensity: 0.5, opacity: 0.12, physics: { mode: 'static', friction: 0.1, restitution: 0.85 } }, + { type: 'box', name: 'Wall south', color: 0x2a3a6a, size: [12.5, 7, 0.5], pos: [0, 3.5, 6], emissive: 0x1a2a5a, emissiveIntensity: 0.5, opacity: 0.12, physics: { mode: 'static', friction: 0.1, restitution: 0.85 } }, + { type: 'box', name: 'Wall west', color: 0x2a3a6a, size: [0.5, 7, 12.5], pos: [-6, 3.5, 0], emissive: 0x1a2a5a, emissiveIntensity: 0.5, opacity: 0.12, physics: { mode: 'static', friction: 0.1, restitution: 0.85 } }, + { type: 'box', name: 'Wall east', color: 0x2a3a6a, size: [0.5, 7, 12.5], pos: [6, 3.5, 0], emissive: 0x1a2a5a, emissiveIntensity: 0.5, opacity: 0.12, physics: { mode: 'static', friction: 0.1, restitution: 0.85 } }, + { type: 'light', name: 'Room light', kind: 'point', color: 0x9fb4ff, intensity: 6, distance: 16, pos: [0, 5.5, 0] }, + // the two physical buttons (an onclick fires from a VR ray โ€” the HUD is not in a headset) + { type: 'box', name: 'Start pad', color: 0x3b7dd8, size: [0.6, 0.16, 0.6], pos: [-1, 0.08, -4.8], emissive: 0x1f4f9f, emissiveIntensity: 0.9, roughness: 0.4, physics: { mode: 'static' } }, + { type: 'box', name: 'More stars pad', color: 0xb0863b, size: [0.6, 0.16, 0.6], pos: [1, 0.08, -4.8], emissive: 0x7a5a1f, emissiveIntensity: 0.9, roughness: 0.4, physics: { mode: 'static' } }, + // the stars, two planets for contrast, and the spawner's template under the floor + ...starObjects(), + { type: 'sphere', name: 'Planet Azure', color: 0x5b7fd6, r: 0.6, pos: [-3, 1.9, 2.2], emissive: 0x1f3f9f, emissiveIntensity: 0.35, roughness: 0.6, physics: { mode: 'dynamic', mass: 2, restitution: 0.7, friction: 0.2 } }, + { type: 'sphere', name: 'Planet Ember', color: 0xd68a5b, r: 0.6, pos: [3.2, 2.3, -2.4], emissive: 0x8f3a1a, emissiveIntensity: 0.35, roughness: 0.6, physics: { mode: 'dynamic', mass: 2, restitution: 0.7, friction: 0.2 } }, + { type: 'sphere', name: 'Star template', color: 0xffe08a, r: 0.22, pos: [0, -2, 0], emissive: 0xffcf50, emissiveIntensity: 1.2, roughness: 0.4, physics: { mode: 'dynamic', mass: 0.2, restitution: 0.9, friction: 0.1 } } + ] +}; + // ---- 28-G: the first two contests โ€” Make a mirror, Follow the beat --------------- // Both are DATA (spec: cloud plans-core/28-g-contests-mirror-and-beat.md). The mirror // starter ships the answer as a faint ghost; the beat starter ships a CC0 track, a @@ -563,25 +908,8 @@ function beatMarkers() { } function beatGraph() { - /** @type {any[]} */ const nodes = []; - /** @type {any[]} */ const edges = []; - /** @param {string} id @param {string} type @param {string} label @param {number} x @param {number} y @param {any} data */ - const N = (id, type, label, x, y, data) => { - nodes.push({ id, type, position: { x, y }, data: { label, ...data }, class: 'w-[150px]' }); - return id; - }; - // the editor's canonical edge id with BOTH handles (Nodes.svelte) โ€” a Sequence step is - // a SOURCE handle, which the Towers helper never needed - /** @param {string} source @param {string} target @param {string} [targetHandle] @param {string} [sourceHandle] */ - const E = (source, target, targetHandle, sourceHandle) => { - edges.push({ - id: 'e-' + source + (sourceHandle ? '.' + sourceHandle : '') + '-' + target + (targetHandle ? '.' + targetHandle : ''), - source, - target, - ...(sourceHandle ? { sourceHandle } : {}), - ...(targetHandle ? { targetHandle } : {}) - }); - }; + const g = graphBuilder(); + const { N, E } = g; // selectors โ€” every trigger and action names its object through one N('selcond', 'objectselector', 'Conductor', 760, 190, { selected: 'Conductor' }); N('selstage', 'objectselector', 'Stage', 760, 340, { selected: 'Stage' }); @@ -663,7 +991,7 @@ function beatGraph() { N('cutdetail', 'setcamera', 'Bar 3: Detail', 520, 1400, { camera: '' }); E('cuts', 'cutdetail', 'trigger', 'step3'); E('seldetail', 'cutdetail', 'camera'); - return { nodes, edges }; + return g.done(); } const BEAT_HUD_PANEL = { @@ -957,6 +1285,7 @@ const DEFS = [ ] }, TOWERS_DEF, + STARS_DEF, MIRROR_DEF, BEAT_DEF ]; @@ -1016,7 +1345,14 @@ const DEFS = [ // CDN, and the file belongs to no repo); the page hands the bytes to the Explorer, // which is what makes them a scene asset the .tpscene bundles. const music = def.music ? { ...def.music, b64: (await fetchMusic(def.music)).toString('base64') } : null; - const out = await page.evaluate(async ({ d, music }) => { + // 24-A A4: one-shot SOUNDS a graph plays (a def-level list, ADDITIVE). Same fetch and + // the same Explorer drop as the track, but NOT the music slot: a chime is a scene + // asset a Sound node addresses through '$sound:', never background music. + const sounds = []; + for (const snd of def.sounds ?? []) sounds.push({ ...snd, b64: (await fetchMusic(snd)).toString('base64') }); + const out = await page.evaluate(async ({ d, music, sounds, humanTextKeys }) => { + // 24-A A3: the module-scope Set does not cross into the page โ€” it arrives as a list + const humanText = new Set(humanTextKeys); const s = window.__stores; const T = s.THREE; s.commandsHandler.sceneCommand('/clear all'); @@ -1183,6 +1519,14 @@ const DEFS = [ named['$music'] = item.hash; s.sceneMusic.commitMusic({ hash: item.hash, name: music.name, volume: music.volume ?? 0.8, playing: false, startedAt: 0 }); } + // 24-A A4: the one-shot sounds โ€” Explorer only (content-hashed), `'$sound:'` + // in node data becomes the hash through the widened remap (A3) + for (const snd of sounds) { + if (!s.explorer) break; + const bin = Uint8Array.from(atob(snd.b64), (ch) => ch.charCodeAt(0)); + const item = await s.explorer.addItemFromBytes(bin.buffer, snd.name, null, { imported: true }); + named['$sound:' + snd.key] = item.hash; + } // 28-G: the editor camera the file opens on (buildSessionPayload saves it). BOTH // the camera and the orbit target, or OrbitControls.update() reverts the move. if (d.view) { @@ -1200,14 +1544,23 @@ const DEFS = [ const grid = (i) => ({ x: 40 + (i % 4) * 220, y: 40 + Math.floor(i / 4) * 140 }); // a node's object reference may be a def-local NAME: `uuid` on effect/anim // nodes, `selected` on an Object Selector (B8 โ€” the selector is how every - // trigger and action names its target, so a game graph is mostly selectors) + // trigger and action names its target), `camera` on the camera nodes (28-G), + // `hash: '$music'` on a Sound node โ€” and, 24-A A3, ANY own string field a + // later node type may add: every string that names a def-local object becomes + // its uuid, string ARRAYS too (a future multi-target node), EXCEPT the human- + // text keys in HUMAN_TEXT_KEYS. `uuid`/`selected`/`camera`/`hash` fall out + // as ordinary cases, so the four earlier rules produce exactly what they did. const remapData = (data) => { const out = { ...(data ?? {}) }; - if (out.uuid && named[out.uuid]) out.uuid = named[out.uuid]; - if (out.selected && named[out.selected]) out.selected = named[out.selected]; - // 28-G: `camera` on setcamera/gamestart/setlook, and the track's hash - if (out.camera && named[out.camera]) out.camera = named[out.camera]; - if (out.hash === '$music' && named['$music']) out.hash = named['$music']; + for (const key of Object.keys(out)) { + if (humanText.has(key)) continue; + const v = out[key]; + if (typeof v === 'string') { + if (named[v]) out[key] = named[v]; + } else if (Array.isArray(v) && v.length && v.every((x) => typeof x === 'string')) { + out[key] = v.map((x) => (named[x] ? named[x] : x)); + } + } return out; }; const resolved = {}; @@ -1288,7 +1641,7 @@ const DEFS = [ const payload = s.sessions.buildSessionPayload(d.title); // 28-G: a def with music exports WITH assets โ€” the track rides the .tpscene // (sceneAssetList lists the music hash and the Sound node's, one blob for both) - const bytes = await s.sessions.exportSessionZip(payload, { assets: !!music, packs: false, flow: true }); + const bytes = await s.sessions.exportSessionZip(payload, { assets: !!music || sounds.length > 0, packs: false, flow: true }); // fitted offscreen thumbnail โ€” the sessions.js renderSceneThumbnail // approach at card size (480x270 webp) @@ -1350,7 +1703,7 @@ const DEFS = [ if (s.animationPreview) s.animationPreview.animationsRestore({}, false); if (s.sceneMusic) s.sceneMusic.musicRestore(null, false); return { bytes: Array.from(bytes), thumb }; - }, { d: def, music }); + }, { d: def, music, sounds, humanTextKeys: [...HUMAN_TEXT_KEYS] }); const bytes = Buffer.from(out.bytes); const thumb = out.thumb ? Buffer.from(out.thumb.split(',')[1], 'base64') : null; built[def.slug] = { entry: def, bytes, thumb }; diff --git a/scripts/compare-authored.cjs b/scripts/compare-authored.cjs new file mode 100644 index 00000000..9554943f --- /dev/null +++ b/scripts/compare-authored.cjs @@ -0,0 +1,129 @@ +// 24-A A3: prove two authored trees are the SAME CONTENT. +// +// node scripts/compare-authored.cjs [--only ] +// +// For every `
//scene.tpscene` under , the same file must exist +// under and their session.json must agree once the things a build mints +// afresh every run are canonicalised: +// ยท the session `id`, `createdAt`, `appVersion` and the inline `thumbnail` (an offscreen +// render is not bit-stable across GPU drivers โ€” its byte LENGTH is reported instead), +// ยท every `changedAt` / `startedAt` / `at` stamp (latest-wins bookkeeping, not content), +// ยท every uuid, replaced by `uuid#` in order of FIRST APPEARANCE โ€” so a graph's +// remapped `selected`/`uuid`/`camera` references still have to point at the same +// objects in the same order, which is exactly what the remap change must preserve. +// `thumb.webp` is compared by size within 25%, and the index.json row (if both trees +// carry one) with `bytes` dropped. Exit 0 = identical, 1 = a difference (printed). +const fs = require('fs'); +const path = require('path'); +const { unzipSync } = require('fflate'); + +const [beforeDir, afterDir] = process.argv.slice(2, 4).map((p) => p && path.resolve(p)); +if (!beforeDir || !afterDir) { + console.error('usage: node scripts/compare-authored.cjs '); + process.exit(2); +} +const onlyFlag = process.argv.indexOf('--only'); +const ONLY = onlyFlag !== -1 ? String(process.argv[onlyFlag + 1] ?? '').split(',').filter(Boolean) : null; + +const UUID = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/g; +const STAMP_KEYS = new Set(['changedAt', 'startedAt', 'at', 'createdAt']); + +/** @param {any} value @param {Map} uuids @param {string} key */ +function canon(value, uuids, key = '') { + if (typeof value === 'string') { + return value.replace(UUID, (u) => { + if (!uuids.has(u)) uuids.set(u, 'uuid#' + uuids.size); + return uuids.get(u) ?? u; + }); + } + if (typeof value === 'number' && STAMP_KEYS.has(key)) return 0; + if (Array.isArray(value)) return value.map((v) => canon(v, uuids, key)); + if (value && typeof value === 'object') { + /** @type {Record} */ const out = {}; + for (const k of Object.keys(value)) out[k] = canon(value[k], uuids, k); + return out; + } + return value; +} + +/** @param {string} file */ +function readScene(file) { + const zip = unzipSync(new Uint8Array(fs.readFileSync(file))); + const entries = Object.keys(zip).sort(); + const session = JSON.parse(Buffer.from(zip['session.json']).toString('utf8')); + const thumbLen = typeof session.thumbnail === 'string' ? session.thumbnail.length : 0; + const { id, createdAt, appVersion, thumbnail, ...rest } = session; + const others = {}; + for (const name of entries) if (name !== 'session.json') others[name] = Buffer.from(zip[name]).toString('utf8'); + return { entries, thumbLen, canonical: canon(rest, new Map()), others }; +} + +/** @param {string} dir @returns {string[]} */ +function scenes(dir) { + /** @type {string[]} */ const out = []; + const walk = (d) => { + for (const name of fs.readdirSync(d)) { + const p = path.join(d, name); + if (fs.statSync(p).isDirectory()) walk(p); + else if (name === 'scene.tpscene') out.push(path.relative(dir, p)); + } + }; + walk(dir); + return out.sort(); +} + +/** first differing line of two JSON dumps @param {string} a @param {string} b */ +function firstDiff(a, b) { + const la = a.split('\n'); + const lb = b.split('\n'); + for (let i = 0; i < Math.max(la.length, lb.length); i++) + if (la[i] !== lb[i]) return { line: i + 1, before: la[i] ?? '', after: lb[i] ?? '' }; + return null; +} + +let failures = 0; +const list = scenes(beforeDir).filter((rel) => !ONLY || ONLY.some((slug) => rel.includes(path.sep + slug + path.sep))); +if (!list.length) { + console.error('no scene.tpscene under ' + beforeDir); + process.exit(2); +} +for (const rel of list) { + const a = path.join(beforeDir, rel); + const b = path.join(afterDir, rel); + if (!fs.existsSync(b)) { + console.log('MISSING ' + rel + ' in ' + afterDir); + failures++; + continue; + } + const A = readScene(a); + const B = readScene(b); + const ja = JSON.stringify(A.canonical, null, 1); + const jb = JSON.stringify(B.canonical, null, 1); + const diff = ja === jb ? null : firstDiff(ja, jb); + const entriesSame = JSON.stringify(A.entries) === JSON.stringify(B.entries); + const othersSame = JSON.stringify(A.others) === JSON.stringify(B.others); + const thumbOk = A.thumbLen === 0 ? B.thumbLen === 0 : Math.abs(A.thumbLen - B.thumbLen) / A.thumbLen < 0.25; + const dir = path.dirname(rel); + const thumbA = path.join(beforeDir, dir, 'thumb.webp'); + const thumbB = path.join(afterDir, dir, 'thumb.webp'); + const sizeA = fs.existsSync(thumbA) ? fs.statSync(thumbA).size : 0; + const sizeB = fs.existsSync(thumbB) ? fs.statSync(thumbB).size : 0; + const webpOk = sizeA === 0 ? sizeB === 0 : Math.abs(sizeA - sizeB) / sizeA < 0.25; + const ok = !diff && entriesSame && othersSame && thumbOk && webpOk; + console.log((ok ? 'SAME ' : 'DIFF ') + rel + ' (' + ja.length + ' canonical chars, thumb ' + A.thumbLen + '/' + B.thumbLen + ', webp ' + sizeA + '/' + sizeB + ')'); + if (diff) console.log(' first difference at canonical line ' + diff.line + ':\n before: ' + diff.before + '\n after: ' + diff.after); + if (!entriesSame) console.log(' zip entries differ: ' + A.entries.join(',') + ' vs ' + B.entries.join(',')); + if (!othersSame) console.log(' a non-session entry differs'); + if (!thumbOk || !webpOk) console.log(' thumbnail size drifted more than 25%'); + if (!ok) failures++; +} +// the index rows, when both trees carry an index.json +const ia = path.join(beforeDir, 'index.json'); +const ib = path.join(afterDir, 'index.json'); +if (fs.existsSync(ia) && fs.existsSync(ib)) { + const strip = (idx) => JSON.stringify(idx, (k, v) => (k === 'bytes' ? undefined : v)); + const same = strip(JSON.parse(fs.readFileSync(ia, 'utf8'))) === strip(JSON.parse(fs.readFileSync(ib, 'utf8'))); + console.log((same ? 'SAME ' : 'DIFF ') + 'index.json (bytes dropped)'); + if (!same) failures++; +} +process.exit(failures ? 1 : 0); diff --git a/src/App.svelte b/src/App.svelte index 70a4490e..996e2c81 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -85,6 +85,7 @@ import { startMusicToolbox } from './lib/musicToolbox' import { startHelperLayer, helpersInPlay } from '$lib/helperLayer' import { startCloudPlugin } from '$lib/cloudPlugin' import { startShaderGraphs } from '$lib/shaderGraph' + import { startMaterialSharing } from '$lib/materialSharing' import { startShaderSync } from '$lib/shaderSync' import { startHudSync } from '$lib/hudSync' import { startHudImages } from '$lib/hudImages' @@ -218,6 +219,10 @@ import { startMusicToolbox } from './lib/musicToolbox' startHelperLayer() // shader graphs: the compile/target wiring + the replication and history seams startShaderGraphs() + // D2: re-unifies shared materials by id whenever the scene changes โ€” every carrier + // (GLTF, a peer's per-object messages, undo) rebuilds materials, and the id survives + // where the instance does not + startMaterialSharing() startShaderSync() startHudSync() startHudImages() @@ -346,6 +351,7 @@ import { startMusicToolbox } from './lib/musicToolbox' import('./lib/scenePhysics'), import('./lib/playInteract'), import('./lib/moveSmoothing'), + import('./lib/knock'), import('./lib/playSettings'), import('./lib/colliderSpec'), import('./lib/colliderHelpers'), @@ -449,6 +455,10 @@ import { startMusicToolbox } from './lib/musicToolbox' import('./lib/inviteLinks'), import('./lib/helperLayer'), import('./lib/explorerClipboard'), + import('./lib/lookPresence'), + import('./lib/postGraphs'), + import('./lib/postGraphPresets'), + import('./lib/materialSharing'), import('./lib/diagnostics'), import('./lib/wireValidate'), import('./lib/wireErrors'), @@ -456,8 +466,8 @@ import { startMusicToolbox } from './lib/musicToolbox' import('./lib/sceneBudget'), import('./lib/overloadGuard'), import('./lib/qualityGovernor') - ]).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, 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, diagnosticsLib, wireValidateLib, wireErrorsLib, safeStorageLib, sceneBudgetLib, overloadGuardLib, qualityGovernorLib]) => { - 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, 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, diagnostics: diagnosticsLib, wireValidate: wireValidateLib, wireErrors: wireErrorsLib, safeStorage: safeStorageLib, sceneBudget: sceneBudgetLib, overloadGuard: overloadGuardLib, qualityGovernor: qualityGovernorLib } + ]).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]) => { + 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 } }) } }) diff --git a/src/components/Outline.svelte b/src/components/Outline.svelte index d60fda87..760800b0 100644 --- a/src/components/Outline.svelte +++ b/src/components/Outline.svelte @@ -8,6 +8,10 @@ // the scene's. Exactly how HudLayer resolves an attached HUD โ€” a look on a camera IS // a post document keyed by that camera's uuid, so there is no new concept here. import { cameraPreview } from '$lib/cameraPreview'; + // P2: while WATCHING a peer, the chain resolves from THEIR look state (camera, view + // mode, local post switch, Set Look overrides) instead of ours โ€” presence, never data. + import { specatorMode } from '../stores/appStore.js'; + import { peerLooks, lookOf } from '$lib/lookPresence'; import { scenePost, postStacks, @@ -22,6 +26,10 @@ // side-effecting import: registers the built-in effect kinds. It also owns the // postprocessing/n8ao imports, which is what keeps scenePost.js a pure leaf. import { compilePostStack, disposePostStack } from '$lib/postEffects'; + // P4: side-effecting too โ€” registers the `graph` kind, so a post-domain shader graph + // is just another entry in the stack above. This component owns the one thing that + // module cannot: the NormalPass, added ON DEMAND below when a graph reads normals. + import '$lib/postGraphs'; import { registerOutlineLayer } from '$lib/editOverlays'; import { faceEditObject, meshEditOutline } from '$lib/faceEdit'; import { editingObject } from '$lib/meshEdit'; @@ -33,6 +41,7 @@ BlendFunction, EffectComposer, EffectPass, + NormalPass, OutlineEffect, RenderPass } from 'postprocessing'; @@ -117,6 +126,9 @@ let stackSkipped: any[] = []; /** L4: does the built stack map the frame itself? (environment reads this) */ let stackTonemaps = false; + /** P4: the ONE normal pass, built only while something in the stack reads normals โ€” + * a second scene render per frame is not a cost to pay for a chain that never asks */ + let normalPass: any = null; // "would the compiled chain differ?" โ€” a param scrub that changes nothing must // not thrash the composer, and the effect below re-runs on every store write let stackSignature = ''; @@ -143,6 +155,11 @@ function rebuildStack(entries: any[]) { for (const pass of stackPasses) (composer as any).removePass(pass); disposePostStack(stackPasses, stackInstances); + if (normalPass) { + (composer as any).removePass(normalPass); + normalPass.dispose?.(); + normalPass = null; + } // `size.current` / `camera.current` are PLAIN property reads on threlte's // CurrentWritable, so they register no dependency โ€” deliberate: a resize or a // camera swap must not rebuild the whole chain, they have their own effects. @@ -162,7 +179,24 @@ // index 1.. = after RenderPass, BEFORE the two outline passes. postprocessing's // addPass(pass, index) re-assigns renderToScreen to whatever ends up last, so // the outlines keep presenting. - stackPasses.forEach((pass, offset) => (composer as any).addPass(pass, 1 + offset)); + // P4 โ€” THE NORMAL PASS, on demand. A post graph that reads Scene normal (edge + // detect is the shipped case) needs a buffer nothing else in this app renders, and + // it costs a second pass over the scene โ€” so it is built only when an effect + // actually asks, and ONE of them serves every effect that does. It goes in FIRST, + // right after the beauty render, because a pass can only read a buffer something + // earlier in the chain has filled. + const wantNormals = stackInstances.filter((instance: any) => instance.object?.tpNeedsNormals); + let offsetBase = 1; + if (wantNormals.length) { + normalPass = new NormalPass(scene, camera.current); + (composer as any).addPass(normalPass, 1); + offsetBase = 2; + for (const instance of wantNormals) { + const slot = instance.object.uniforms?.get?.('normalBuffer'); + if (slot) slot.value = normalPass.texture; + } + } + stackPasses.forEach((pass, offset) => (composer as any).addPass(pass, offsetBase + offset)); applyLocalPrefs(); // L4 โ€” TONE MAPPING, where the SCOPING is the whole point. // @@ -207,6 +241,8 @@ // setMainCamera does over `pass.mainCamera`. Generic, so a future effect that // needs the camera is correct for free. for (const instance of stackInstances) instance.def?.retarget?.(instance.object, active); + // the normal pass renders the scene itself, so it needs the camera swap too + if (normalPass) normalPass.mainCamera = active; }); $effect(() => { @@ -243,6 +279,8 @@ // belt-and-braces for unknown engines: post also skips the first composer frames // (the boot-compile window is where the breakage bites hardest) let postWarm = $state(false); + /** P2: the peer whose look state the chain was last resolved from ('' = our own) */ + let adoptedFrom = ''; let warmupFrames = 0; let postGateToasted = false; @@ -251,7 +289,14 @@ // mode, the local kill switch, the capability gate and the warm-up). // `postWarm` flipping after 10 frames is one extra rebuild, once. $effect(() => { - const throughCamera = $cameraPreview?.uuid ?? null; + // P2: the WATCHED peer's row, when there is one. `specatorMode` holds a peer id + // while watching; an absent row (an older build) falls through to our own state, + // and so does leaving the watch โ€” nothing of theirs is ever written into ours. + void $peerLooks; + const watching = typeof $specatorMode === 'string' ? $specatorMode : ''; + const adopted = watching ? lookOf(watching) : null; + adoptedFrom = adopted ? watching : ''; + const throughCamera = adopted ? adopted.camera : ($cameraPreview?.uuid ?? null); // resolvedDoc reads the stores with get(), which registers NO svelte dependency โ€” // so BOTH have to be touched here or this effect stops re-running when a document // changes (measured: setting a camera to No files replaced rendered nothing new). @@ -259,13 +304,18 @@ void $lookOverride; // 26-D: the quality governor's post steps โ€” AO first (the personal chip reads as plain // shaded, an authored AO entry is dropped), then the whole stack. LOCAL overrides: the - // authored document is never touched, so a peer's look is unchanged + // authored document is never touched, so a peer's look is unchanged. + // P2 + 26-D: while WATCHING a peer the authored documents and the mode come from THEIR + // look (`adopted`), but the governor still applies on top โ€” it is this machine giving + // up post to keep its frames, and that must not be undone by watching somebody. const reduced = $qualityOverrides; + const overrides = adopted ? adopted.look : undefined; + const adoptedMode = adopted ? adopted.mode : $viewMode; const entries = effectivePostStack({ - stack: resolvedDoc(POST_SCENE_KEY), - cameraStack: /** @type {any} */ (throughCamera ? resolvedDoc(throughCamera) : null), - mode: reduced.aoOff && $viewMode === 'shaded-ao' ? 'shaded' : $viewMode, - localEnabled: $postEnabledLocal && !reduced.postOff, + stack: resolvedDoc(POST_SCENE_KEY, overrides), + cameraStack: /** @type {any} */ (throughCamera ? resolvedDoc(throughCamera, overrides) : null), + mode: reduced.aoOff && adoptedMode === 'shaded-ao' ? 'shaded' : adoptedMode, + localEnabled: (adopted ? adopted.overrides.post !== false : $postEnabledLocal) && !reduced.postOff, postOk, postWarm }).filter((entry) => !(reduced.aoOff && entry.kind === 'ao')); @@ -374,6 +424,11 @@ // empty -> still nothing to compose. Measured as a stack that could never // compile a pass in play mode. if (!postWarm && ++warmupFrames > 10) postWarm = true; + // P4: the per-frame write a kind may declare โ€” the shared shader clock, which + // must reach a live uniform WITHOUT a chain rebuild (a rebuild per frame is + // what the parent plan names as the reason a param-driving node needs a seam + // like this one first). + for (const instance of stackInstances) instance.def?.tick?.(instance.object, delta); if (renderer.xr.isPresenting || nothingToCompose) renderer.render(scene, camera.current); else { composer.render(delta); @@ -486,6 +541,17 @@ outlinedSelected: outlineEffectSelected?.selection.size ?? 0, outlinedLocked: outlineEffectLocked?.selection.size ?? 0, stackPasses: stackPasses.length, + // P4: the normal pass is not one of `stackPasses` (it is the chain's, not an + // entry's), so the suite needs it named to prove it is added ON DEMAND + normals: !!normalPass, + graphs: stackInstances + .filter((instance: any) => instance.object?.tpGraphKey) + .map((instance: any) => ({ + key: instance.object.tpGraphKey, + normals: !!instance.object.tpNeedsNormals, + clock: !!instance.object.tpUsesClock, + depth: !!(instance.object.getAttributes?.() & 1) + })), plan: stackPlan, skipped: stackSkipped.map((entry: any) => entry.kind), kinds: stackInstances.map((instance: any) => instance.kind), @@ -496,6 +562,8 @@ ((composer as any).passes ?? []).at(-1) === outlinePassSelected, postWarm, postOk, + // P2: whose look state the chain came from ('' = this viewer's own) + adoptedFrom, // L4: what the renderer was TOLD about tone mapping and what it actually // holds - double grading is invisible in the stack itself stackTonemaps, diff --git a/src/components/Scene.svelte b/src/components/Scene.svelte index f47c9396..8a36b46d 100644 --- a/src/components/Scene.svelte +++ b/src/components/Scene.svelte @@ -29,7 +29,8 @@ import { holdBody, releaseBody } from '$lib/physics'; import { sculptObject, enterSculpt, beginStroke, strokeMove, endStroke as sculptEndStroke, showCursorAt, hideCursor } from '$lib/terrainSculpt'; import { sceneHits } from '$lib/scenePick'; - import { startPlayInteract, tickPlayInteract, stopPlayInteract } from '$lib/playInteract'; + import { startPlayInteract, tickPlayInteract, stopPlayInteract, carriedUuid } from '$lib/playInteract'; + import { startKnock, tickKnock, stopKnock } from '$lib/knock'; import { tickMoveSmoothing } from '$lib/moveSmoothing'; import { moduleClickHandlers, moduleInteractiveGroups, fireClickMiss } from '$lib/moduleSDK'; import { updateSpatialAudio } from '$lib/voiceChat'; @@ -50,7 +51,7 @@ // the annotation is TS syntax โ€” a JSDoc @type cast is ignored here (the documented trap). let knifeFrom: number[] | null = null; import { peerScenes } from '$lib/peerScenes'; - import { initVRControls, updateVRControls, raycastMenu, raycastPanel, raycastPalette, raycastProps, raycastPrefabs, raycastKeyboard, raycastChat, raycastEdit, raycastSnap, raycastSettings, raycastApprove, placePrefabGhost, vrFaceTrigger, vrVertexTrigger, vrVertexGrabStart, vrVertexGrabEnd, beginStretchSliderDrag, endStretchSliderDrag, executeVRMenuAction, resetWorldRig, onInputSourcesChange, worldToContentPose, boxSelectStart, boxSelectEnd, boxSelectActive, applyVRFrameRate, shouldSendHands, onHandPinchStart, onHandPinchEnd, pinchMenuToggledAt, firePingIfArmed, vrModuleTriggerStart, vrModuleTriggerEnd, vrModuleSelectSwallowed } 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, hapticPulse } from '$lib/vrControls'; import { vrKeyboardTarget } from '$lib/vrKeyboard'; import { measureMode, measureClick } from '$lib/measure'; import { pinsGroup, openAnnotation, showNotePins } from '$lib/annotationsHandler'; @@ -278,6 +279,9 @@ // 21-B B3: play-mode grab/carry. The ray is NDC (0,0) every frame, so it // belongs in the frame loop rather than on a pointer event. tickPlayInteract(delta, camera.current); + // 24-A A1: the knock probes (hands in VR, the camera on desktop) against every + // dynamic body โ€” inert unless the scene's knock block is on and a sim runs + tickKnock(performance.now(), camera.current); // 21-B: ease between a remote peer's ~10 Hz physics poses (no-op unless a // remote peer is simulating and something is mid-ease) tickMoveSmoothing(); @@ -1289,6 +1293,11 @@ // 21-B B3: play mode's own input path. Registered HERE, below every `let` // its closure reads (runModuleClickHandlers among them) โ€” the TDZ rule. startPlayInteract({ moduleHitTest: runModuleClickHandlers }); + // 24-A A1: the knock's feeds. The VR hand poses come from vrControls through + // 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 }); xrControllers.forEach((controller) => { controller.addEventListener('select', onXRSelect); @@ -1301,6 +1310,7 @@ return () => { offEditResume(); // #20 P5 stopPlayInteract(); // 21-B B3 (releases any carried body with zero velocity) + stopKnock(); // 24-A A1 element.removeEventListener('pointerdown', onPointerDown); element.removeEventListener('contextmenu', onContextMenu); element.removeEventListener('webglcontextlost', onContextLost); diff --git a/src/components/editors/Nodes.svelte b/src/components/editors/Nodes.svelte index c7ec646a..9ddf8a1d 100644 --- a/src/components/editors/Nodes.svelte +++ b/src/components/editors/Nodes.svelte @@ -8,6 +8,7 @@ Controls, MiniMap, MarkerType, + SelectionMode, useSvelteFlow, type Node, type Edge, @@ -58,10 +59,12 @@ import GamepadNode from './nodes/GamepadNode.svelte'; import PlayAnimNode from './nodes/PlayAnimNode.svelte'; import AnimStateNode from './nodes/AnimStateNode.svelte'; + import OnHitNode from './nodes/OnHitNode.svelte'; import UnknownNode from './nodes/UnknownNode.svelte'; import { flowNodes as flowNodesStore, flowEdges as flowEdgesStore, customNodeDefs, nodeDesignerOpen, flowGraphs, activeGraphId, SCENE_GRAPH, setActiveGraph } from '../../stores/flowStore'; import { createObjectGraph, requestDeleteObjectGraph } from '$lib/flowGraphs'; import { deselectObject } from '$lib/objectActions'; + import { flowMouseBindings } from '$lib/flowPrefs'; import { objectsGroup, selectedObject, selectedObjects } from '../../stores/sceneStore'; import { serializeNode, serializeEdge, deleteFlowNodes, deleteFlowEdges, setNodeData } from '$lib/nodesHandler'; import ThemedSelect from '../ui/ThemedSelect.svelte'; @@ -158,6 +161,9 @@ setuniform: EffectNode, onclick: OnClickNode, onimpact: AnimationNode, + // 24-A A2: its own card โ€” the pulse dot PLUS speed/byMe value rows (the MoveInput + // shape: several source handles need labelled rows, not one right-edge dot) + onhit: OnHitNode, onenter: OnClickNode, // CL-C: same pulse card, sensor copy onexit: OnClickNode, collider: ColliderNode, // CL-C @@ -377,6 +383,38 @@ const bgVariant = $derived(bgPattern === 'lines' ? BG_LINES : BG_DOTS); const selectedNode = $derived((nodes as any[]).find((n) => n.selected) ?? null); + // 114 (v1.13): MOUSE BINDINGS. Classic (the default, byte-identical to every + // version before it): left-drag pans. Select-first: left-drag draws a selection + // rectangle, dragging any selected node moves the set, Shift+click toggles + // membership, and the middle/right button pans. xyflow 1.6: `panOnDrag` takes the + // button list, `selectionOnDrag` the rectangle, `multiSelectionKey` the modifier. + const selectFirst = $derived($flowMouseBindings === 'select'); + // Select-first re-emits a STATIONARY right click as the pane menu: once the right + // button pans, xyflow's Pane preventDefaults EVERY contextmenu and forwards none + // (its system layer would re-emit a press that did not travel, but the svelte + // wrapper never passes that callback through), so the wrapper below tracks the + // gesture itself. A right DRAG is a pan and opens nothing. + // + // The decision is made on POINTERUP, not on the contextmenu event: Chromium fires + // `contextmenu` on the PRESS, so at that moment the gesture has travelled zero + // pixels whether it turns out to be a click or a 200px pan โ€” measured, and the + // first version opened the menu on every right drag because of it. The native menu + // is suppressed either way, by xyflow's own preventDefault. + let rightDown: { x: number; y: number } | null = null; + const onWrapPointerDown = (event: PointerEvent) => { + const target = event.target as HTMLElement | null; + const onBarePane = + !!target?.closest('.svelte-flow__pane') && !target.closest('.svelte-flow__node, .svelte-flow__edge'); + rightDown = event.button === 2 && onBarePane ? { x: event.clientX, y: event.clientY } : null; + }; + const onWrapPointerUp = (event: PointerEvent) => { + if (!selectFirst || event.button !== 2 || !rightDown) return; // Classic: xyflow's Pane opens it + const travelled = Math.hypot(event.clientX - rightDown.x, event.clientY - rightDown.y); + rightDown = null; + if (travelled > 4) return; // that gesture was a pan + onPaneContextMenu({ event }); + }; + // H1 (flow v2): the editor scope follows the viewport selection โ€” a selected // object shows ITS graph (or the create-flow empty state), deselecting returns // to the scene graph. "Has a selection" MUST be read from the selectedObjects @@ -710,6 +748,8 @@
@@ -796,6 +836,10 @@ {onbeforeconnect} {ondelete} {isValidConnection} + panOnDrag={selectFirst ? [1, 2] : true} + selectionOnDrag={selectFirst} + selectionMode={SelectionMode.Partial} + multiSelectionKey={selectFirst ? 'Shift' : undefined} defaultEdgeOptions={{ type: edgeStyle, markerEnd: { type: MarkerType.ArrowClosed, width: 16, height: 16 } }} deleteKey={['Backspace', 'Delete']} fitView diff --git a/src/components/editors/ShaderEditor.svelte b/src/components/editors/ShaderEditor.svelte index abee349a..f364ba64 100644 --- a/src/components/editors/ShaderEditor.svelte +++ b/src/components/editors/ShaderEditor.svelte @@ -38,7 +38,21 @@ shaderRefusalReason } from '$lib/shaderGraph'; import { beginShaderGesture, endShaderGesture } from '$lib/shaderSync'; - import { shaderNodeDefs, shaderNodeDef, SURFACE_NODE } from '$lib/shaderCatalog'; + import { shaderNodeDefs, shaderNodeDef, SURFACE_NODE, POST_OUTPUT_NODE } from '$lib/shaderCatalog'; + // P4: the POST domain. The editor is one surface for two domains โ€” same cards, same + // palette, same pane menu โ€” because a post graph IS a shader graph; what differs is + // which nodes mean anything, which terminal node the graph ends at, and (since a post + // graph belongs to no object) that the post half needs a scope control while the + // surface half's scope is still the selection. + import { + shaderDomain, + activePostGraph, + postGraphKeys, + postGraphName, + createPostGraph, + deletePostGraph, + postPresets + } from '$lib/postGraphs'; import { setDockOccupant, dockHeight, @@ -64,24 +78,67 @@ import { safeStorage } from '$lib/safeStorage'; const nodeTypes = Object.fromEntries(shaderNodeDefs().map((def) => [def.key, ShaderNode])); - const catalog = shaderNodeDefs().filter((def) => def.key !== SURFACE_NODE); - const groups = [...new Set(catalog.map((def) => def.group))]; + const allDefs = shaderNodeDefs(); + /** + * The palette for ONE domain. + * + * `stages` absent means every stage, so the arithmetic and channel nodes are in both + * lists; a node declaring its stages is offered only where it means something. Showing + * the others anyway would be the worst outcome: the compiler refuses them BY NAME, so + * the user would place a card, wire it, and be told it belongs somewhere else โ€” the + * palette is the right place to say so, before the wire. + * @param {string} which + */ + const catalogFor = (which) => + allDefs.filter( + (def) => + def.key !== SURFACE_NODE && + def.key !== POST_OUTPUT_NODE && + (!def.stages || def.stages.includes(which === 'post' ? 'post' : 'fragment')) + ); + const catalog = $derived(catalogFor($shaderDomain)); + const groups = $derived([...new Set(catalog.map((def) => def.group))]); const { screenToFlowPosition } = useSvelteFlow(); const LS = typeof localStorage !== 'undefined' ? localStorage : null; - // ---- scope: purely selection-driven ---------------------------------------- + // ---- scope: selection-driven in SURFACE, picked in POST --------------------- const selectedUuid = $derived($selectedObjects?.length === 1 ? $selectedObjects[0] : null); - const scope = $derived(selectedUuid ?? SCENE_GRAPH_KEY); - const doc = $derived($shaderGraphs[scope] ?? null); - const errors = $derived($shaderErrors[scope] ?? []); + const isPost = $derived($shaderDomain === 'post'); + /** every post graph, re-derived off the store so a new one appears at once */ + /** @param {any} _poke */ + const graphsOf = (_poke) => postGraphKeys(); + const postGraphs = $derived(graphsOf($shaderGraphs)); + /** the post scope: the one asked for, else the first that exists, else none */ + const postScope = $derived( + $activePostGraph && $shaderGraphs[$activePostGraph] + ? $activePostGraph + : (postGraphs[0]?.key ?? '') + ); + const scope = $derived(isPost ? postScope : (selectedUuid ?? SCENE_GRAPH_KEY)); + /** the navigator's list, split by domain (see its markup below) */ + const treeDocuments = $derived( + Object.fromEntries( + Object.entries($shaderGraphs).filter(([key]) => key.startsWith('post:') === isPost) + ) + ); + const doc = $derived(scope ? ($shaderGraphs[scope] ?? null) : null); + const errors = $derived(scope ? ($shaderErrors[scope] ?? []) : []); const ownerName = $derived( - scope === SCENE_GRAPH_KEY - ? 'The scene' - : $objectsGroup?.getObjectByProperty('uuid', scope)?.name || 'This object' + isPost + ? scope + ? postGraphName(scope) + : 'No post effect' + : scope === SCENE_GRAPH_KEY + ? 'The scene' + : $objectsGroup?.getObjectByProperty('uuid', scope)?.name || 'This object' ); const scopeLabel = $derived( - scope === SCENE_GRAPH_KEY ? 'Scene default material' : ownerName + ' โ€” own material' + isPost + ? ownerName + ' โ€” post effect' + : scope === SCENE_GRAPH_KEY + ? 'Scene default material' + : ownerName + ' โ€” own material' ); // ---- graph settings (LOCAL prefs, the node editor's set) ------------------- @@ -164,6 +221,12 @@ // ---- actions --------------------------------------------------------------- function createGraph() { + if (isPost) { + // in the post half "create" MINTS a document (there is no object to attach one + // to), and the new one becomes the scope so you are looking at what you made + activePostGraph.set(createPostGraph({})); + return; + } if (scope !== SCENE_GRAPH_KEY) { const object = $objectsGroup?.getObjectByProperty('uuid', scope); if (object && !shaderTargetSupported(object)) { @@ -189,6 +252,11 @@ } function removeGraph() { + if (isPost) { + if (scope) deletePostGraph(scope); + activePostGraph.set(null); + return; + } if (scope !== SCENE_GRAPH_KEY) { const object = $objectsGroup?.getObjectByProperty('uuid', scope); if (object) detachFrom(object); @@ -198,7 +266,7 @@ /** @param {string} key @param {{x:number,y:number}} [at] */ function addNode(key, at) { - if (!doc) return; + if (!doc || !scope) return; const id = key + '_' + Math.random().toString(36).slice(2, 7); setShaderGraphFor(scope, { nodes: [...doc.nodes, { id, type: key, position: at ?? { x: 140, y: 120 }, data: {} }] @@ -506,6 +574,42 @@ + +{#snippet domainSwitch()} +
+ + +
+ {#if isPost && postGraphs.length} + + + {/if} +{/snippet} + {#snippet actions()} {#if doc}
{/if} @@ -593,16 +700,39 @@

- {ownerName} has no shader yet + {#if isPost} + No post effect to edit yet + {:else} + {ownerName} has no shader yet + {/if}

-

- {scope === SCENE_GRAPH_KEY - ? 'A scene shader drives every object that has no shader of its own' - : 'Deselect to edit the scene-wide shader instead'} -

+ {#if isPost} + +
+ {#each postPresets() as preset (preset.key)} + + {/each} +
+

+ A post effect runs over the finished frame โ€” add it to a look in Configure + Scene โ–ธ Post-processing +

+ {:else} +

+ {scope === SCENE_GRAPH_KEY + ? 'A scene shader drives every object that has no shader of its own' + : 'Deselect to edit the scene-wide shader instead'} +

+ {/if}
{/if} @@ -751,6 +881,11 @@ {doc.nodes.length} nodes ยท {doc.edges.length} wires ยท {doc.backend}

Replicates to peers ยท saved with the scene

+ {:else if isPost} +

+ Create a post effect, then add it to a look in Configure Scene โ–ธ + Post-processing. +

{:else}

Select nothing for the scene shader, or one object for its own. @@ -782,6 +917,7 @@ >

+ {@render domainSwitch()} {scopeLabel}
- {#each defs.filter((d) => d.group === group) as def (def.key)} + {#each defs.filter((/** @type {any} */ d) => d.group === group) as def (def.key)}
- -
+ +
+

+ Three layers, all of them scene data that everyone sees: effects over the + finished frame (below), a default material every object without its own + inherits, and a material on one object. Only the right to switch a layer off + is local โ€” that is View โ–ธ Overrides, above. +

+ +

+ {shaderSummaryOf($shaderGraphs, $objectsGroup)} +

+
+

Knock

+ setScenePhysics({ knock: { enabled: e.currentTarget.checked } })} + > + Hands and players knock dynamic objects + + {#if $sceneKnock.enabled} + setScenePhysics({ knock: { gain: v } })} + /> + setScenePhysics({ knock: { maxSpeed: v } })} + /> + setScenePhysics({ knock: { radius: v } })} + /> + setScenePhysics({ knock: { spin: v } })} + /> + {/if} +

+ An open VR hand, or walking into an object on desktop, sends it off at the speed it + was hit. Grip still grabs. Shared, and it needs a running simulation. +

+

Play mode

Pointer @@ -3311,6 +3450,30 @@
{/if} + + {#if sharedWith.length} +
+

+ This material is shared with {sharedWith.length} + other object{sharedWith.length === 1 ? '' : 's'} โ€” editing it here changes + {sharedWith.length === 1 ? 'that one' : 'them'} too, for everyone. +

+
+ +
+
+ {/if} + {#if !allShaderDriven} } */ const GROUP_LABEL = { ao: 'Ambient occlusion', grading: 'Colour grading', stylize: 'Stylize', + graph: 'Shader graph', camera: 'Camera FX', aa: 'Anti-aliasing', test: 'Test', @@ -107,17 +121,64 @@ .filter((group) => byGroup[group]?.length) .map((group) => ({ label: GROUP_LABEL[group] ?? group, - children: byGroup[group] - .slice() - .sort((a, b) => a.label.localeCompare(b.label)) - .map((def) => ({ label: def.label, action: () => add(def.kind) })) + // P4: the `graph` kind's leaf would add an entry pointing at NOTHING, which is + // a row you then have to go and fix. Its submenu offers the shipped presets and + // the graphs this scene already has instead, so every way in lands on something + // that renders. + children: + group === 'graph' + ? [ + ...postPresets().map((preset) => ({ + label: 'New: ' + preset.label, + title: preset.hint, + action: () => addGraph({ preset: preset.key }) + })), + { label: 'New: empty graph', action: () => addGraph({}) }, + ...graphsList.map((entry) => ({ + label: entry.name, + action: () => addExistingGraph(entry.key) + })) + ] + : byGroup[group] + .slice() + .sort((a, b) => a.label.localeCompare(b.label)) + .map((def) => ({ label: def.label, action: () => add(def.kind) })) })); }); + /** the post graphs that exist, re-derived off the store so a new one shows at once */ + /** @param {any} _poke */ + const graphsOf = (_poke) => postGraphKeys(); + const graphsList = $derived(graphsOf($shaderGraphs)); + + /** @param {{preset?: string}} opts */ + function addGraph(opts) { + menu = null; + const made = addPostGraphToLook({ ...opts, docKey }); + openId = made.id; + } + + /** @param {string} key */ + function addExistingGraph(key) { + menu = null; + const id = addPostEffect('graph', undefined, docKey); + setPostGraphEntry(id, key, docKey); + openId = id; + } + /** @param {any} entry */ function labelOf(entry) { + // a graph entry is named by its DOCUMENT: three rows all reading "Shader graph" + // would be indistinguishable, and the document's name is the thing the author chose + if (entry.kind === 'graph') + return entry.params?.graph ? postGraphName(entry.params.graph) : 'Shader graph (none picked)'; return postEffectDef(entry.kind)?.label ?? entry.kind; } + + /** compile errors for a graph entry, shown on the row that runs it. @param {any} entry */ + /** @param {any} entry @param {any} _poke */ + const graphErrorsOf = (entry, _poke) => + entry.kind === 'graph' && entry.params?.graph ? ($shaderErrors[entry.params.graph] ?? []) : []; /** an entry whose kind this build does not know โ€” kept, never rendered */ /** @param {any} entry */ function isUnknown(entry) { @@ -360,6 +421,28 @@ onchange={(v) => setPostEffectParams(entry.id, { [param.key]: v }, docKey)} /> + {:else if param.type === 'graph'} +
+ {param.label} + ({ value: g.key, name: g.name }))} + value={entry.params[param.key] ?? ''} + placeholder="Pick a graphโ€ฆ" + onchange={(v) => setPostEffectParams(entry.id, { [param.key]: String(v ?? '') }, docKey)} + /> + +
+ {#each graphErrorsOf(entry, $shaderErrors) as message, i (i)} +

{message}

+ {/each} {:else if param.type === 'asset'}
{param.label} diff --git a/src/components/menu/Settings.svelte b/src/components/menu/Settings.svelte index 6d4d8aea..496ad0be 100644 --- a/src/components/menu/Settings.svelte +++ b/src/components/menu/Settings.svelte @@ -8,6 +8,7 @@ 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'; import { trackpadMode, allowBrowserZoom, reversePan, panEnabled, pinchZoomEnabled, lastWheelEvents } from '$lib/trackpadNav'; import { lightHelperLength } from '$lib/lightHelpers'; + import { flowMouseBindings, FLOW_MOUSE_BINDINGS } from '$lib/flowPrefs'; import { helpersInPlay } from '$lib/helperLayer'; import { gamepadPrefs, setGamepadPrefs, DEADZONE_RANGE, SENSITIVITY_RANGE } from '$lib/gamepadPrefs'; import { drawerSlot, cloudPluginInfo } from '$lib/cloudHooks'; @@ -19,6 +20,7 @@ const appVersionString = versionString(); import { vrFaceCap, VR_FACE_CAP } from '$lib/faceEdit'; import { doubleClickAction, DOUBLE_CLICK_ACTIONS } from '$lib/selectionPrefs'; + import { shareDuplicatedMaterials } from '$lib/materialSharing'; import { lengthUnit, angleUnit, LENGTH_UNIT_KEYS } from '$lib/units'; import { vrVertexCap, VR_VERTEX_CAP } from '$lib/meshEdit'; import { syncedAnimations } from '../../stores/flowStore'; @@ -983,6 +985,13 @@ A scene can bind the pad itself with the Gamepad Button and Gamepad Axis nodes in the node editor (Input group) โ€” button presses replicate like a key press, while a stick value stays local to the player holding it. Module bindings are listed under Shortcuts + + + + + + Classic (the default): a left drag on the node editor's canvas pans and Shift+drag draws a selection box. Select-first: a left drag selects, dragging any selected node moves the whole selection, Shift+click adds to or removes from it, and the middle or right button pans โ€” a right click that does not move still opens the menu + {#snippet header()}Scene{/snippet} @@ -1135,6 +1144,16 @@ with a frozen snapshot of the compiled material and nothing to edit. An object inheriting the scene default keeps inheriting it either way + + + + Off (the default), a duplicate gets its own copy of the material, so editing one + leaves the other alone. On, the copy and the original share ONE material and an + edit to either changes both โ€” for everyone in the session. Geometry is always + copied either way. Use the Material section's Unlink to give one + object its own material back + + diff --git a/src/components/menu/Toasts.svelte b/src/components/menu/Toasts.svelte index f82dad58..b673e0a1 100644 --- a/src/components/menu/Toasts.svelte +++ b/src/components/menu/Toasts.svelte @@ -18,6 +18,9 @@ // so arming a panel that is not mounted arms nothing import { armExplorerSceneSave, explorerClose } from '../../stores/appStore' import { peers, loading, loadingcount, pendingApprovals, waitingForApproval, userdata, toastStore, fixLight, showSidebar, specatorMode, restorePanels, appNotice, connectDrawerOpen, connectDrawerTab, toastsInDrawerOnly, showInfoToast, dismissToastById } from '../../stores/appStore' + // P2: the watch banner says when the watched peer's look CANNOT be adopted (the + // P1 rule: a scoped feature must say on its own surface when it takes no effect) + import { peerLooks, watchLookNote } from '$lib/lookPresence' import { restoreAvailable, restoreSnapshot, dismissRestore } from '$lib/autosave' import { ingestGate, resolveIngestGate } from '$lib/commandsHandler.svelte' import { ingestVerdict, profileFor } from '$lib/sceneBudget' @@ -48,6 +51,12 @@ * The avatar lookup is GUARDED now: by the time this runs the peer may have * travelled or left, and the inline version dereferenced it unconditionally. */ + // `watchLookNote` reads the map with get(); `$peerLooks` is the dependency (the + // get()-registers-nothing rule) โ€” passed as the unused argument the codebase uses + // for exactly this so svelte-check does not flag a comma expression + const lookNote = $derived(typeof $specatorMode === 'string' ? noteFor($specatorMode, $peerLooks) : ''); + function noteFor(peerId: string, _dep: unknown) { return watchLookNote(peerId); } + function exitSpectate() { if (!$specatorMode) return; const dolly = $globalScene?.getObjectByName('dolly'); @@ -467,7 +476,7 @@ $effect(() => {

- Watching {$specatorMode} + Watching {$specatorMode}{#if lookNote} ยท {lookNote}{/if}