diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c5ba88d..e01361e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,81 @@ per release, newest first. HTML comments like this one are stripped before rendering, so maintainer notes stay out of the user-facing window. --> +## 1.18.0 — Menus, levels and smooth frames in a headset 🥽 + +### ⏸️ Every game has a real menu + +- ⏸️ **Pause any game.** Escape (or the Menu button in the corner) opens the same menu in every + game: **Resume · Restart · Levels · Settings · How to play · Main menu**, plus Back to editor + on a desktop. In a headset it is on the game board, and the left **X** button opens it. +- ⚙️ **Settings per game**, remembered on this device: music on/off and volume, sound effects + on/off and volume, controller vibration, turning (snap, smooth or off, and the snap angle), a + comfort vignette, Quality (Auto, Low, Medium, High) and **Show FPS** — a small frame counter + on the screen, or on your wrist and top strip in VR. Games add their own rows (Stars Room: + "Point to move stars", "Make stars with a clap"; Untangle: Globe or 2D board). +- 🗺️ **Levels in the menu.** A game that has levels shows them in the menu, on a desktop and in + the headset (Towers' 12, Untangle's 30). + +### 🥽 Pointing and moving in VR + +- 🎯 **See where you point.** The controller laser is easier to see, ends in a solid dot, and the + button under it lights up — on every game board and module menu. +- 🕹️ **The radial menu follows the thumbstick**: the sector you push towards lights up, and the + trigger or a stick click picks it. +- 🪟 **Menus are never hidden** behind floors, pedestals or walls. +- 🌍 **Games can let you move the world.** Untangle and the Jam Room let your grips move, turn and + scale the whole scene during the game, as in Edit (a grip on something you can hold still holds it). +- 🚀 **Teleport that stays inside.** Dungeon Realms, the Stars Room and the Jam Room let you + teleport — onto floors only, inside the play area, never through a wall. The arc turns red where + you cannot land. The Jam Room also lets you fly. + +### 🎮 The games + +- 🏗️ **Towers is a real game now**: twelve levels with planks, wedges, barrels, arches, balls and a + heavy base, 1-3 stars a level and unlocks saved on your device. You can only grab what is close + to you, so build steps and jump (Space, or **A** in VR); wind, a wobbling plate, an outline to + fill and a star to deliver on top. +- 🌌 **Stars Room**: clap your hands in VR to make a new star (with a sparkle and a sound) — it + flies and counts like the others; a setting to move stars only by touching them. +- 🪢 **Untangle**: in VR you start in front of the board, not inside it; Globe or 2D board and the + level picker are in the headset menu; switching board keeps the level you have open, and one + progress counts across both; the level bar sits at your waist, drawn over the scene. +- 🔫 **Waves**: the robots no longer stop and walk back to their portals when one of them is + killed; lighter robots (a far version of each, smaller textures). +- 🏰 **Dungeon Realms** draws only the torches near you, with fewer lights in a headset. +- ⚽ **Football** drops the glass's costly see-through pass, four of its lights and both shadow + maps; a goal's confetti follows the quality setting. + +### 🩹 Found on the preview + +- 🔤 **Crisp text in VR menus again.** After a heavy moment the headset could start the next VR + session at a lower resolution, which blurred every menu's text. A headset now always renders at + full resolution; the speed-ups below are what keep it smooth. +- ⬆️ **Games update the modules they need.** Opening a game that was made for a newer version of + one of its modules than the one installed on your device now offers to update it (Waves on a + headset could still be the 1.17 version, whose robots all walked back to their portals when you + hit one). + +### ⚡ Smoother frames + +- ⚡ **Fixed the hitches**: the object list's module section re-read every module object every + frame — that one cost Waves almost all of its frame time and gave Dungeon Realms a hitch every + second. Four smaller per-frame costs are gone too. +- 🔭 **Automatic level of detail**: big models get simpler versions for the distance, built once + and cached (Settings: "Simplify distant models"). +- 🥽 **Auto quality in the headset**: a VR session starts with shadows off and steps quality down + (and back up) by the headset's own frame rate; a game's Quality setting overrides it. + +### 🧰 For authors + +- `play.locomotion` gains `worldGrab`, and `teleport` is bounded by `play.bounds` (else the scene's + content); `play.reach` limits grab distance. Configure Scene ▸ Physics ▸ Play mode. +- New nodes: **On Clap**, **Point Grab**, **Game Setting**; Spawn takes a place; a spawned copy + answers to its template's events. +- Module SDK: `api.game.levels`, `addSetting`/`setting`, `setHelp`, `onRestart`, `api.quality`, + `api.lod`, `api.vrPanel`, `api.locomotion`. `node scripts/perf-games.cjs` measures the seven games. +- 📦 Dependencies: three 0.186, Vite 8.3, Svelte 5.57.1, Playwright 1.63 and ten more minor updates. + ## 1.17.0 — Games that look finished 🎨 ### 🖐️ Dragging, clicking and selecting that do what you meant diff --git a/CLAUDE.md b/CLAUDE.md index c3d92adf..ae245e52 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2303,6 +2303,70 @@ loadable play content. Everything a user does must be visible to connected peers (`scripts/level-templates.cjs` via author-templates' `kit` type, `seed: false`); a scene load now ends a running simulation first. Suites pack-refs, pack-undo, level-templates (`LIVE=1` runs it against a build's own feed + pack CDN). +- **ROADMAP 31 — TOWERS BECOMES A GAME** (31-towers; T1/T2 of the Quest feedback, "after the basic + level nothing happens, just like hooray"). Two core rules any game can use, and a core MODULE. + · `playReach.js` (pure leaf, vitest): `play.reach` (metres, OMITTED when absent) limits Interact / + Play grabs to that distance from the player's BODY — the segment feet..eye, never the eye (a crate + at your feet is 1.7 m from the eye). Desktop Play: `playInteractState.mode = 'toofar'` (the + crosshair says "Too far — get closer"), the press carries nothing, a carry is held inside the + reach (`carryLimit`); the editor's Interact cursor carry has no body and is unaffected. VR: + `gripTargetOf` refuses an out-of-reach body (`lastGripRefusalDebug`) and the grip buzzes `fail`. + Configure Scene ▸ Physics ▸ Play mode ▸ Limit grab reach. + · JUMP IN VR: the Character Controller node's `jumpHeight` jumps on right A in Interact walking + (`vrJumpHeight()`; A stays push-to-talk otherwise, `pttByA` so a switch mid-hold cannot strand the + mic). Desktop walk mode always jumped on Space. The capsule stands on dynamic pieces (a query + shape: it never shoves them). + · `src/modules/towers/` — a CORE module (the pong precedent: it may reach core internals through + PRIMED DYNAMIC imports; `api.spawn` still does not exist), dormant unless the scene holds the + `Towers game` marker. `levels.js` is the pure leaf (12 levels, stars = win + par pieces + par + time, unlock = a star on the level before, the rack layout, the tower measure, the judge, the + gust schedule, the outline cells). `module.js`: ONE AUTHORITY (the physics initiator, else the + lowest peer id) deals pieces as TRANSIENT duplicates of templates parked on a vault under the + floor, judges ~10x/s, pushes gusts and writes the `tw*` game variables; every peer derives the + rest (the HUD words through the `towersinfo` value node wired into HUD Text's FORMAT, the + moments, saving stars via `api.storage`). HUD presses carry no presser, so every peer watches the + hudbutton stamps (first sight = history, `age` > 2.5 s = a joiner's history) and only the + authority acts; unlocks read the AUTHORITY's progress. `towerswobble` is a module EFFECT so the + plate is a kinematic body — a module effect's `base` is `{pos, rot: [x,y,z], scale}` ARRAYS, not + THREE objects (reading `base.rotation` throws every frame into noteFrameFailure, silently). The + shell's `api.game.levels`/`setHelp`/`onRestart` are feature-detected. The template def is the + arena + templates + HUD + a small graph; `block` (Wedge/Arch/Corner/Stairs) and `thumb.dress` + (card-only objects) are new author-kit fields. Suites play-reach-jump, towers-levels, game-towers. +- **ROADMAP 31 — THE SECOND QUEST ROUND** (1.18.0; acceptance test: cloud + `plans/core/lanes/31/user-feedback-2026-10-01.md`, items U1…A2). Contracts K1-K4: + · K1 `teleportRules.js` (pure leaf, vitest) + vrControls `teleportVerdict(from, to, normalY)`: + in Interact a teleport lands only on a WALKABLE surface (normal y ≥ 0.7), inside `play.bounds` + ({min, max}; a publisher's in its OWN frame — `boundsOwner`) else the content box shrunk 0.3 m, + on a dungeon raster floor cell, and with no collider/mesh in the 1.1 m line (a DYNAMIC body or a + transient copy never refuses — the Stars Room's floating stars). Red arc = refused, release does + nothing. `play.locomotion.worldGrab` gives Interact/Play the Edit world gestures when a grip + starts on no grabbable (`locomotionPolicy(...).worldGestures`). Probe: `api.locomotion`. + · K2 `vrPanelOverlay.js` (leaf): every VR panel joins the TRANSPARENT list at PANEL_ORDER 5000 + behind ONE sentinel whose onBeforeRender CLEARS DEPTH — panels draw over the scene yet still + depth-test among themselves; the beam/reticle take BEAM_ORDER 5010 while they end on a panel; + `userData.vrOverlay = false` opts out; `api.vrPanel(group)` for module menus. The laser is + normal-blended with a solid hit dot and the board FILLS its hovered button; module content ends + the beam (vrGameInput resolves meshes under module-world-root). The radial menu lights the + sector under the stick and the trigger picks it. + · K3 `gameShell.js` (state + actions) + `gameSettings.js` (leaf: per-game rows persisted via + safeStorage `tp:game::shell`) + `gameShellWire.js` (buses, haptics, turning, quality) + + `shellPanelDraw.js` (the VR pages) + `fpsMeter.js` + `comfortVignette.js`: ONE pause menu in + every game — desktop Escape / corner Menu (`#game-shell-menu`, `[data-shell-item]`, + `[data-shell-setting]`, `#game-fps-counter`), VR board footer + LEFT X. LOCAL, replicates + nothing, never pauses the shared world. **Escape in a game opens the menu now; leaving Play from + a game is the menu's Back to editor** (suites that pressed Escape to leave a game moved). + `qualityGovernor.applyGameQuality` pins High 0 / Medium 3 / Low 7 and OUTRANKS the headset's + entry floor (integrate: no floor while pinned; back to Auto in a session re-takes it). + · K4 `lod.js` + `lodCore.js`: auto LOD for loaded meshes ≥ 3000 tris as a RENDER-TIME geometry + swap (scene.onBeforeRender/onAfterRender) — the tree and every serializer only ever see the + source; meshoptimizer levels built once per asset in the decimation worker; `api.lod`. The + governor now runs in XR off the session's own rAF (`xrThresholds(hz)`, entry floor level 1 = + shadows off, next-entry framebuffer scale); `api.quality {level, max, labels, vr, onChange}`. + `scripts/perf-games.cjs` = the Performance-protocol table for the seven games (`--vr`, `--only`, + PERF_SCENES_DIR/REF, PERF_MODULES_DIR; run under `e2e-slot --exclusive`). + · Stars Room: `clapGesture.js` (pure) + `clap.js` (On Clap node; a spawned copy answers to its + template — `transientObjects.spawnedFromOf`), `pointGrab.js` (Point Grab node gates the VR + grip RAY and the desktop carry; touch still holds), Game Setting node. ## Replication golden rules @@ -2398,6 +2462,26 @@ loadable play content. Everything a user does must be visible to connected peers ## Hard-won gotchas (do not rediscover) +- **A HEADSET'S EYE BUFFER IS NEVER LOWERED** (`XR_FRAMEBUFFER_SCALE = 1`, 31-integrate). 31-perf handed three a + 0.85..0.5 framebuffer scale for the NEXT session after any session that reached the governor's resolution steps, + and every panel's text went soft ("ALL text in VR menus became blurry") while the panel textures were unchanged. + Suite vr-panel-sharpness measures the board's text edge energy at the scale the next entry gets. +- **AN INSTALLED USER MODULE NEVER UPDATES ITSELF.** A Quest that installed Waves 2.1.0 for the 1.17 preview kept it on + the 1.18 preview, and its 1.17 bug read as a 1.18 regression. A scene asking for a NEWER version than installed now + gets an Update offer in the load prompt (`classifyRequirements().outdated`, suite module-update-on-load). When a + device report contradicts the union's tests, ask which MODULE VERSION the device ran before bisecting. +- **A VITE STARTED INSIDE `e2e-slot` INHERITS FDS 8/9 AND HOLDS BOTH MACHINE-WIDE E2E LOCKS** + (every lane's suites blocked ~2 h on 2026-10-01). Start a dev server from a plain shell with + `8>&- 9>&-`, never from inside an e2e-slot command. +- **A LEGACY-MODE TEMPLATE CALL `fn(x)` IS UNTRACKED BEYOND ITS ARGUMENTS** — VRMenu's + `sectorColor(s.entry)` read `$vrHovered` inside the helper, so no sector ever repainted (stick + OR ray). Pass the store value as a parameter. +- **A `$state` (deep) PROXY OVER THREE OBJECTS IS A PER-FRAME COST**: ModuleContent.svelte held + module groups in deep `$state`, so every frame walked them (`deep_read`) — 88% of Waves' CPU, the + 1 Hz Dungeon Realms hitch. `$state.raw` for anything holding scene objects. +- **`a ?? b + '/'` IS `a ?? (b + '/')`** — the integrate's MODULES_REPO env override in helpers.cjs + lost its trailing slash and every suite "had no zips". Parenthesize a `??` whose result is + concatenated. - **HELPER_LAYER MUST NEVER BE 1 OR 2: three's WebXRManager gives the LEFT eye layer 1 and the RIGHT eye layer 2.** Helpers on layer 1 drew into ONE eye in a headset — the Quest report "in the middle of the world I see the light source helper, and only with one eye" (Towers, Stars Room, @@ -5006,6 +5090,18 @@ override for e2e — never share 5173 (the user's main-checkout server). locked (replicate the INDEX per-item opt-in; ONE mesh with scenes as tags; scene-is-primary renaming), and the vocabulary settled: **session = the mesh, room = who is in a scene, PocketBase rooms stay DISCOVERY** — that naming blocks R4. +- Status (2026-10-01): **1.18.0 PREVIEW — ROADMAP 31 "the second Quest round", integrated on `feat/1.18` + (lane `31-integrate`), HELD AT THE RELEASE GATE** (https://preview-1-18.theprototype.pages.dev, core a896b27, modules + dev 3c9bf77, scenes `preview-1-18` 35addc4, packs a39280a). Merged core #253 vr-core (K1 K2) · #251 game-shell (K3) · + #249 perf (K4) · #252 towers · #250 stars-jam (unions in playSettings/scenePhysics/vrControls/gameKit/playInteract; + the governor pin-vs-XR-floor composed by hand, perf-governor §6c); modules #28 untangle, #27 waves, #26 fb-dungeon + + integrate fixes (waves def asks 2.2.0; untangle 2.3.1 fits its VR HUD line; flights leave a game through the menu; + door-keypad compares the final pose). THE TRAP OF THE ROUND: K3 made Escape in a game open the pause menu, so every + suite that pressed Escape to leave Play before a late joiner dialled left the host in Play — `h.leavePlay` (core and + modules helpers). Gates: svelte-check 333/47, vitest 378, build green, every lane's held suites green on the union, + modules flights + modes audit (63/0 + 64/0) green, preview proof 211/0 + the core VR/shell suites against the preview + build. Perf before/after: `lanes-30/after-31/31-integrate/perf/before-after.md` (Waves p50 700 -> 16.7 ms, Football + 336 -> 123 calls, Dungeon p99 117 -> 17 ms). OWED on a Quest: every feel/fps line in the handover. - Status (2026-09-24): **1.17.0 round 2 + 3 — THE QUEST ROUND AND THE MESHY ASSETS, integrated on `feat/1.17` (lane `30b-integrate`)**, held at the user's release gate with a new preview (https://preview-1-17.theprototype.pages.dev). Core merged #244 vr-modes, #245 vr-play, #246 @@ -6574,3 +6670,14 @@ color?})`; click handlers get `{source: 'click' | 'sweep', mode}` and `registerClickHandler(fn, {sweep: false})` opts out of the VR sweep. Feature-detect every one (`api.x?.()`). A scene's play block may say `play.locomotion {teleport?, fly?}` and `play.spawn {position, yaw, vrOnly?}`. + +**R31 additions (1.18.0, the second Quest round):** `api.game.levels({list:[{id,label,locked?,stars?}], +current, onPick(id)})` (re-callable; the shell draws the picker on desktop AND the VR board), +`api.game.addSetting({id, label, type:'toggle'|'choice'|'range', options?, min?, max?, default, +onChange?})` + `api.game.setting(id)` (rows in the per-game Settings page, persisted per game id), +`api.game.setHelp(text)` (How to play), `api.game.onRestart(fn)` (after the shell's reset); +`api.quality {level (getter, 0 best), max, labels, vr, onChange(fn) -> off}` (cut effects at a +higher level); `api.lod(object, {ratios, distances, minTriangles})` -> `{meshes, ready, remove}`; +`api.vrPanel(group)` (a module's VR menu drawn over the scene like core's panels); +`api.locomotion = {boundedTeleport, worldGrab}` (the K1 probe). A scene's play block may say +`play.locomotion.worldGrab`, `play.bounds {min, max}` and `play.reach` (metres). Feature-detect all. diff --git a/MODULES.md b/MODULES.md index dd2c3444..3c527c4d 100644 --- a/MODULES.md +++ b/MODULES.md @@ -481,6 +481,61 @@ if (api.storage) { Feature-detect it; on an older app write the SAME key yourself (`localStorage['tp:mod::'] = JSON.stringify(value)`) so progress carries over. +### Performance: quality level, LOD, budgets (1.18, roadmap 31) + +```js +// The adaptive quality level on THIS device: 0 = best ... api.quality.max (every step taken). +// A headset session starts at 1 (shadows off) in auto mode; a game's Quality setting can pin it. +if (api.quality) { + const apply = (level) => { + particles.maxCount = level >= 3 ? 40 : 200; // cut YOUR effects, not shared state + torchLights.forEach((l, i) => (l.visible = i < (level >= 1 ? 2 : 6))); + }; + apply(api.quality.level); + const off = api.quality.onChange((level, { labels, vr }) => apply(level)); // released on disable +} + +// Levels of detail for your own dense geometry (auto LOD already covers dense meshes in the +// shared scene and under the module world root). Built ONCE per asset in a worker +// (meshoptimizer), drawn by distance through a render-time swap: the mesh keeps its full +// geometry for picking, physics and your code. Distances are in the mesh's world RADII. +if (api.lod) { + const lod = api.lod(enemyFigure, { ratios: [0.5, 0.2], distances: [6, 18] }); + lod.ready.then((n) => console.log(n, "meshes have levels")); // lod.remove() undoes it +} +mesh.userData.lod = false; // keep one mesh out of auto LOD +``` + +Both are LOCAL (a fact about this machine) — never let them change replicated state, or two +peers on different hardware disagree about the game. Skinned meshes and morph targets get no +levels (the simplifier cannot carry weights): pre-decimate those offline. + +**The Quest budget** (roadmap 31, the planner's target for a scene in Interact/Play on a Quest 3): +**<= 150 draw calls, <= 300k triangles visible, <= 2 real-time lights with shadows off, no +per-frame allocations in the hot path.** Measure your game with the probe, the same way every time: + +```bash +# dev server running; run it under the exclusive slot (frame times are the point) +APP_URL=https://theprototype.app:5173/ ~/.local/bin/e2e-slot --exclusive -- \ + node scripts/perf-games.cjs --label mine --only waves [--vr] [--profile] +``` + +It prints draw calls + triangles per frame (every render pass summed), geometries, textures and +an MB estimate, lights (+ shadow casters), meshes / instanced / unculled, the LOD and quality +state, and p50/p95/p99 frame ms at CPU throttle x4; `--profile` adds CPU self-time, the nearest +app frame calling the heaviest functions, and allocation per function (garbage included). +The things 1.18's round found, in the order they cost: + +- **Never keep a THREE object inside Svelte `$state`** (or pass one through an action parameter + that is a state proxy): Svelte deep-reads a proxy, which walks the whole scene graph through + `parent` — typed arrays included. Use `$state.raw`. This one line was 88% of a game's frame. +- **No whole-scene lookups per frame** — `scene.getObjectByName` is a traversal; find once, keep + the reference, re-find when it leaves the scene. +- **No allocation per frame**: reuse vectors/arrays; a `[a, b].map(...)` in a frame task is + garbage 60-90 times a second, and GC pauses are the stutter you feel in a headset. +- **Lights and shadow casters are draw calls**: each shadow-casting light draws every caster + again; transmission (glass) renders the scene an extra time per camera, per eye. + ### Game feel: sound, music, haptics, effects, banners (1.17, roadmap 30b) Everything here is LOCAL to the device it runs on — broadcast your own op @@ -533,6 +588,53 @@ register(api) { checkpoint and nobody moves until then. A module's spawn overrides the scene's `play.spawn`. `api.respawnPlayer()` sends the player back to it now. +### Moving around in a game: locomotion, bounds, VR panels (1.18, roadmap 31) + +In Interact and Play a VR player WALKS (collisions, gravity, a 0.3 m step, snap turn) +and nothing else unless the scene's play block — or your module's `userData.play` on +its scene-root group, field by field — allows more: + +```js +// feature-detect: an older core has no api.locomotion and would ignore the bounds +if (api.locomotion?.boundedTeleport) { + group.userData.play = { + locomotion: { teleport: true, worldGrab: false, fly: false }, + bounds: { min: [-12, -0.5, -12], max: [12, 0.4, 12] }, // in THIS group's local frame + colliders: [{ min: [2, 0, -6], max: [2.3, 3, 6] }] // optional: solid boxes, same frame + }; +} +``` + +- **`locomotion.teleport: true`** — the right stick's arc teleport, BOUNDED: the arc stops + at the first surface it meets; the landing must face up (normal y ≥ 0.7) or be the floor + plane, must lie inside `bounds`, and the straight line 1.1 m above your feet and the + landing must not cross a collider box, a mesh, or (with a dungeon raster published) a wall + cell. A refused landing draws the arc RED and releasing the stick does nothing; a valid one + is green and puts your FEET on it. Without `bounds` the play area is the scene's content + box pulled in 0.3 m. Edit mode's teleport is unchanged (it lands anywhere). +- **`locomotion.worldGrab: true`** — the grips move, rotate and SCALE the world the way they + do in Edit (two grips scale/rotate, the right grip alone pans) whenever a grip does NOT + start on something the player may hold (a grip on a dynamic body under + `interaction: 'grab'` still takes the body). For board games and instruments: Untangle, + the Jam Room. +- **`locomotion.fly: true`** — the left stick flies along the controller's aim, no gravity. +- **`bounds: {min: [x, y, z], max: [x, y, z]}`** — a sibling of `locomotion`. The scene's + play block gives it in scene coordinates; a module's `userData.play.bounds` is read in + that group's local frame. Keep `max.y` just above the floor if box tops and ledges must + not be landing spots. +- `vrControls.teleportVerdict(from, to, normalY?)` (on the debug hook + `window.__stores.vrControls`) answers `{ok, reason}` — `ok` `steep` `outside` `off-floor` + `wall-cell` `blocked` — for your e2e; `from`/`to` are world FEET points `[x, y, z]`. + `vrControls.teleportPreview()` reads the live arc (`{engaged, bounded, valid, reason, target}`). + +**Your own VR menu is a panel.** A board, a level bar or buttons you draw in THREE for the +headset should be registered with **`api.vrPanel?.(group)`** (returns the undo, also run +when the module is disabled): it is then drawn OVER the scene — a floor, a base or a wall +between the player and it can never hide its buttons — and the controller laser ends on it +with its dot, even through whatever stands in front. Hit testing is unchanged. Core's own +panels (the radial menu, the game board, the wrist card) already work this way, and the +laser ends on your `registerInteractiveGroup` content too. + **In VR, your game's HUD is in the player's hands.** A screen with `input: 'menu'`, a control on it, or bound to the `menu` / `paused` / `over` game state is drawn on a board ~1.2 m in front of the player that follows their head lazily; its buttons @@ -557,6 +659,84 @@ itself) or `'sweep'` (a later entry while held). A control that must not be swep api.registerClickHandler(pickDot, { sweep: false }); // presses still reach it; sweeps do not ``` +### The game shell: pause menu, levels, per-game settings (1.18, roadmap 31 K3) + +Every game gets ONE pause menu from core — **Resume · Restart · Levels · Settings · How to +play · Main menu** — on the desktop (Escape in Play, or the corner Menu button) and in VR +(the left controller's **X**, or Menu on the game board / wrist card). You do not draw it; +you feed it. A scene counts as a game when it has a state-bound HUD screen, a spawn, or a +module publishing `userData.play` — or when your module registers levels. All of it is +feature-detected (`api.game.levels?.(…)`), LOCAL, and torn down with your module. + +**Levels** — the picker on the desktop and on the VR board. Re-call it whenever a level +unlocks or earns stars; `onPick` never hears a locked level. + +```js +const refresh = () => + api.game.levels?.({ + list: LEVELS.map((l, i) => ({ id: l.id, label: 'Level ' + (i + 1), locked: i > unlocked, stars: best[l.id] ?? 0 })), + current: currentLevel.id, + onPick: (id) => loadLevel(id) + }); +refresh(); +onLevelWon(() => { unlocked++; refresh(); }); +``` + +**Your own settings rows** — shown under the core rows, persisted per game on this device +(`tp:game::`). `type` is `'toggle'`, `'choice'` (`options`, optional `optionLabels`) +or `'range'` (`min`, `max`, `step`). A core id is refused. + +```js +api.game.addSetting?.({ + id: 'board', label: 'Board', type: 'choice', + options: ['globe', '2d'], optionLabels: ['Globe', '2D board'], default: 'globe', + onChange: (v) => setBoard(v) +}); +setBoard(api.game.setting?.('board') ?? 'globe'); // the stored choice at load +api.game.setSetting?.('board', '2d'); // your in-game button writes the same row +api.game.onSettingsChange?.((values) => console.log(values.board, values.sfx)); +``` + +**The core rows every game has** — `music`, `musicVolume` (0..100), `sfx`, `sfxVolume`, +`haptics`, `showFps`, `turning` (`default` | `snap` | `smooth` | `off`), `turnAngle` +(`default` | `15` | `30` | `45` | `90`), `vignette`, `quality` (`auto` | `low` | `medium` | +`high`). Core obeys them itself: the per-game volumes land on the audio BUSES (so +`api.playSound`, `api.music`, flow sound nodes and the scene's track all follow), haptics, +VR turning, the comfort vignette and the quality governor read them live. A module that +plays audio through its OWN WebAudio graph should read them: + +```js +const gain = ctx.createGain(); +const apply = () => (gain.gain.value = api.game.setting?.('sfx') === false ? 0 : (api.game.setting?.('sfxVolume') ?? 100) / 100); +apply(); +api.game.onSettingsChange?.(apply); +``` + +**How to play** — your words first, then the device's controls (keyboard or controllers). +Without it the menu shows the game's Games-tab description. + +```js +api.game.setHelp?.([ + 'Drag the dots until no two lines cross.', + 'Finish a level to unlock the next — stars for fewer moves.' +]); +``` + +**Restart** — the menu resets the game shell (host / alone; others carry on as it is), +respawns the player and runs your hook: + +```js +api.game.onRestart?.(() => { resetBoard(); spawnWave(1); }); +``` + +**Your own Menu button** (a module HUD, a VR board of yours): + +```js +api.game.openMenu?.(); // only while playing a game; false otherwise +api.game.closeMenu?.(); +if (api.game.menuOpen?.()) pauseMyTimers(); +``` + ### Physics (P-A) All mutations are INITIATOR-ONLY — the peer that started the simulation steps @@ -747,6 +927,24 @@ data (replicated like any node edit); the slide is a pure function of `(data, time)` running on each peer — no motion messages at all. The `registerClickHandler` consumes the click so pressing doesn't select the button. +## Walkthrough: the towers module (a game's rules as a core module) + +`src/modules/towers/` runs the **Towers** template's twelve levels. It shows how a game with +real rules splits: the scene (the template) is the arena, the piece templates, the HUD and a +small graph; the module is the rules. Worth copying: + +- **The rules as a pure leaf** (`levels.js`) — the level table, the star rule, the unlocks, + the verdict stepper — tested with vitest, no browser. +- **One authority** decides anything shared (the physics initiator, else the lowest peer id): + it deals the pieces, judges the round and writes the result into game variables + (`api.game.setVar`); every peer derives its HUD words from those through a value node wired + into HUD Text's format (`api.registerValueNode`). +- **HUD buttons without a presser**: a press is a replicated stamp on a `hudbutton` node, so + every peer watches `api.flow.triggerStamp` and only the authority acts (first sight = history). +- **Progress on this device** with `api.storage` (stars, unlocks — never replicated). +- **The shell's level picker, help and restart** (`api.game.levels`, `setHelp`, `onRestart`), + feature-detected so the module also runs on an app without them. + ## Manager, dev mode & gallery (17-A2/A3) User modules install, update, disable and remove **live** — `deactivateModule` diff --git a/package-lock.json b/package-lock.json index 1b09f8b6..4767a75a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,43 +11,43 @@ "dependencies": { "@codemirror/lang-javascript": "^6.2.5", "@dimforge/rapier3d-compat": "^0.19.3", - "@lucide/svelte": "^1.35.0", + "@lucide/svelte": "^1.48.0", "@shaderfrog/core": "^4.0.2", "@shaderfrog/glsl-parser": "^7.0.1", - "@threlte/core": "^8.6.0", - "@threlte/extras": "^9.21.1", + "@threlte/core": "^8.6.1", + "@threlte/extras": "^9.22.0", "@threlte/xr": "^1.6.1", - "@xyflow/svelte": "^1.6.5", + "@xyflow/svelte": "^1.7.0", "codemirror": "^6.0.2", "fflate": "^0.8.3", - "meshoptimizer": "^1.1.1", + "meshoptimizer": "^1.3.0", "n8ao": "^2.0.1", "peerjs": "^1.5.5", - "postprocessing": "^6.39.3", + "postprocessing": "^6.39.5", "svelte-hamburgers": "^5.0.0", - "three": "^0.185.0", - "three-mesh-bvh": "^0.9.1" + "three": "^0.186.1", + "three-mesh-bvh": "^0.9.15" }, "devDependencies": { "@sveltejs/adapter-static": "^3.0.10", "@sveltejs/kit": "^2.70.3", - "@sveltejs/vite-plugin-svelte": "^7.2.0", + "@sveltejs/vite-plugin-svelte": "^7.3.1", "@tailwindcss/vite": "^4.3.3", "flowbite": "^3.1.2", "flowbite-svelte": "^1.33.1", "gh-pages": "^6.3.0", "peer": "^1.0.2", - "playwright": "^1.62.0", - "prettier": "^3.9.6", + "playwright": "^1.63.0", + "prettier": "^3.9.9", "prettier-plugin-svelte": "^4.1.1", "prettier-plugin-tailwindcss": "^0.8.1", - "svelte": "^5.57.0", + "svelte": "^5.57.1", "svelte-awesome-color-picker": "^4.1.3", "svelte-check": "^4.7.6", "tailwindcss": "^4.3.3", "typescript": "^5.9.3", - "vite": "^8.2.2", - "vitest": "^5.0.0" + "vite": "^8.3.1", + "vitest": "^5.0.2" }, "engines": { "node": ">=24" @@ -264,9 +264,9 @@ } }, "node_modules/@lucide/svelte": { - "version": "1.35.0", - "resolved": "https://registry.npmjs.org/@lucide/svelte/-/svelte-1.35.0.tgz", - "integrity": "sha512-1I5WeiEFc21HOLv/GR11vuN3MqN/k/mhCEMW7nEtwSAGOcZ0C5ZgI1Y2Rvs/egfKTUiOTw+R9Ys9BJ+GwAKSWw==", + "version": "1.48.0", + "resolved": "https://registry.npmjs.org/@lucide/svelte/-/svelte-1.48.0.tgz", + "integrity": "sha512-bLimVDjehxu3fpu9XEUpqEkBk2sJAHAT2sVcLHhlM70EdNsXuj9ZnYENWv5xgw3+05h4i5TirAxTiTgnZZf8gw==", "license": "ISC", "peerDependencies": { "svelte": "^5" @@ -326,13 +326,13 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.147.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.147.0.tgz", - "integrity": "sha512-IJ3s6ltHLp45S0bh7phkX+gJO7A1Wuz2EaqpAhb8WjqDwbzMiWKHhyyT42tskaWjEYXtHtVCPpnBJVT9+dcRLg==", + "version": "0.151.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.151.0.tgz", + "integrity": "sha512-J1yXrIlNDZVzE3ada310xeAw7nH8yCAyLPuUIsjKatFPmfn5bS1oW+cM+QsGOtVWd5nhSpbwZWx/rue+r5Z+PA==", "dev": true, "license": "MIT", "funding": { - "url": "https://github.com/sponsors/Boshen" + "url": "https://github.com/sponsors/oxc-project" } }, "node_modules/@polka/url": { @@ -354,9 +354,9 @@ } }, "node_modules/@rolldown/binding-android-arm-eabi": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.6.tgz", - "integrity": "sha512-b+jTcARdTiFLI6jB4a5XjTm0RWd6KcRfQj/I2356fxUZemiho9zQLxo0RtCuMDAyKcLo6cEltkgbQp6d1+sjjQ==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.11.tgz", + "integrity": "sha512-A5kXfGKvKWWZE0TtPrfsvT+q4Y5d1QG8gGUzpYjGydM+fARM9MuX90PrXYXe0XbsDVgyxxNzHo6giCj90bsFNw==", "cpu": [ "arm" ], @@ -371,9 +371,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.6.tgz", - "integrity": "sha512-lkWU8ZJaRk9q3CIEY1Tc7vIFALp3Xw5NfGJo2hQg5oIqNgxWi1zI+IiDEK3r70BF5Dzol1tcXsnzsRc8NLhG+Q==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.11.tgz", + "integrity": "sha512-z6cTycz+iJ4PVkuL4HHW4DfTfoeU/2nqYYuSOrTmH7yHK5Y0LCOnA03V4ZNxavyVaU1oOqUgIg2klN/s+USGOA==", "cpu": [ "arm64" ], @@ -388,9 +388,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.6.tgz", - "integrity": "sha512-dgR56NYnvAszm7Ob1B2/Vn0e8bUQYZH2UjVaMMtMVOCKFSfjhfLmuA/9+O+F+ajUdG6B/bSssrKW6JJYASa8jA==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.11.tgz", + "integrity": "sha512-jShvqNtP6vDC6/A5JOAzbVV+DkgHqhl/ScVCJEbt+TUY6QYz7YnXcrg3sLtFBniro0f/Ld50ZwCWA6f7KYD1nQ==", "cpu": [ "arm64" ], @@ -405,9 +405,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.6.tgz", - "integrity": "sha512-vpVxFvUCFioJqug7OTvqptkc4yb8UX0AwfDmJpaR/0sWz+BUmqSVAf7c8JkUgnN8YLspb4a/N6NhTyMAmdyQ7Q==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.11.tgz", + "integrity": "sha512-f2i2xiNWq1Z1l2++q2fuhZRdLAT3aqxD6vRNm1RAxpUoBcdqNB3C0s1Bt+K+PbEx2F5F4gQp6hqKkphCY/xF9w==", "cpu": [ "x64" ], @@ -422,9 +422,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.6.tgz", - "integrity": "sha512-h1wG6Y6K3JlRswxsI64qQJqBAy4vrLuHgRbc8CZMGSWTOFRY6ghMApM1NKzB2I0n5xV1fjkE18SuVl2QpLeNpA==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.11.tgz", + "integrity": "sha512-4Ir5FSOKIAMr4r0kExpt1s3bMgzJU3rA45AYOHtQpls0oNeqcYBKrWMlckrYH4KCfGLfkfn1tN1dmZPMVsdXow==", "cpu": [ "x64" ], @@ -439,9 +439,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.6.tgz", - "integrity": "sha512-tbCiqub0q2MVWJKgF5PoAlNWCtQydiOYSLIkd8sByqK/6MMYLJRcSXSYodqYtd0O+Fw7QaVmKKlS4oL94YRZ0w==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.11.tgz", + "integrity": "sha512-/gnRDM+39BROzAN/k1OZjDPnDMcZxB/0EUxKjONO5yVkNEvlsoMDrxGNKgZi/ttFriS2gwlDNzB65pvNbFOXIQ==", "cpu": [ "arm" ], @@ -456,9 +456,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.6.tgz", - "integrity": "sha512-oxK9+baEBPhZG5HB4URY+uU04zJWeZlH6Tb9rB5DK4DF9XR1uXNLXt5Q5ZsugTKayNCNLhkcwz/ye74hRI98dg==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.11.tgz", + "integrity": "sha512-PFaK8HwvAHbaKbBcDNQihjMKYvFnA5hiENx/l5tphTDz1E0WFp32l0A7aq7lyUwGsRw/xSrNIy/gIK4thrSCrw==", "cpu": [ "arm64" ], @@ -476,9 +476,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.6.tgz", - "integrity": "sha512-muWCk27FVBEZtv0MsK8gnfSmgczA8KQ0uRVJbTABKhkRfQc38aUrcb7fhi3BNiyseFmgcRsoMfQsSNJ+DbZdSw==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.11.tgz", + "integrity": "sha512-AskzJUIKRLPxkruR1wLKewGbOw+EYfU/9lOrBFj4AFrEA8hPpKFnODWNu2WLaNs0QNkEb9QIJufmVZZIL/bJlg==", "cpu": [ "arm64" ], @@ -496,9 +496,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.6.tgz", - "integrity": "sha512-eWDoSfU7Co2qj3vgB3Dt4lj1mG6CoWbcJQkRMP3XJplyCMtuaq3LHvPFjS9QIPvMGWVadJC04Xiy0IdcVPtnwQ==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.11.tgz", + "integrity": "sha512-qlUGAheh2yh8afH7QBgx0PrRHN85hKnNd78x8MeMhXivuevgd8vgf6/CstOzmNKY/lLTHvNTrPy98cLnAugzJw==", "cpu": [ "ppc64" ], @@ -516,9 +516,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.6.tgz", - "integrity": "sha512-2bWNjRSIayvupRKxXUY2tWG9fYdoUlTqWywHRvE8Eq3GvuQ+f2HeIkve697fIt+IQs/PV8yFsdWuhp1aJ1PdnA==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.11.tgz", + "integrity": "sha512-secpEad+0vCbSfn8upFySkDskv+bGPk3THSDS9Y89yc4rb4kzqHp8Dmyd9BkQW4SnhNXBZCl/6CrO//hZahNJQ==", "cpu": [ "s390x" ], @@ -536,9 +536,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.6.tgz", - "integrity": "sha512-KekI0gS0wLxe1UBSQSjenBVwou/JkcQPDzBPICGZjxUv9k3RteHDPBQaiOicZUFKRIH2wKEimGwVpnJsbPzu7w==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.11.tgz", + "integrity": "sha512-mOVBT3dPpkWm8XBWPmU4bf+U6dYDLeMo/9ojUmis4N0L5uu10qra5vOyngZ7/PSdoE4G9KvRt4bloRxNjLas7A==", "cpu": [ "x64" ], @@ -556,9 +556,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.6.tgz", - "integrity": "sha512-TvtPnfVr+HtyGiDmPK4VWmlNm7QhNNAcK5Q9A7aOXsI8545yCyaoMaicXrFZ72JzeYjaUVk7yT243zT0jzjFKQ==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.11.tgz", + "integrity": "sha512-Is78i9A8Ui4SqcxUwFJ9uMmjDn58IbVTjFWYdQestFEgeuEmHMLGNriXnVJKkwG2YiZjw8cP0zCTyDMdDGtOOg==", "cpu": [ "x64" ], @@ -576,9 +576,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.6.tgz", - "integrity": "sha512-iOo0VEay2XFhaCcH0sps5XIimkSuOnNaZrf6+ZkoSOQBJPKNU48RkmJv0/lSpipexu5P+ouFgafe5IGr/DiQfg==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.11.tgz", + "integrity": "sha512-dUCXneZ87INUMyQ0D+C0HrEBNUPNXHaPmU5GTjyKTJEiussw9Kaj5Ln8UztPe4epV/ffvgNBEadksdYhmW6xJA==", "cpu": [ "arm64" ], @@ -593,9 +593,9 @@ } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.6.tgz", - "integrity": "sha512-y5NTmmasMS455JlOCO4ZM9krIchv3Mvm1crL1iUPGOPgEzSkves9n0SdC5Sjz6+qWDFhd8/JpfWMH8NSWNHe+A==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.11.tgz", + "integrity": "sha512-jByxb6qfd+bH1xUd0qnfFnb17i9sWBPY2tOavJ0l3tdr3OTu+Kvtm8cd/JV5nFt657b1VqGltxg9olOEfofXWw==", "cpu": [ "arm64" ], @@ -610,9 +610,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.6.tgz", - "integrity": "sha512-np8iZSLfXlAD4kWhiyq/u0Yt8oZDtRQ8lGhQaCXo2rl37KNjeU0GjJuwr4P3oeZ++ROfofsKNBqR5LTO8aXyWQ==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.11.tgz", + "integrity": "sha512-/PzKqzAJ03i19oy2ItPvyvaVjOjBCNnfaJs8yvUdGBKmiESgnrJSQ2awd81QzFbbnAmu7YO9ZnJrDCb9VSJPRA==", "cpu": [ "x64" ], @@ -733,9 +733,9 @@ } }, "node_modules/@sveltejs/acorn-typescript": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.11.tgz", - "integrity": "sha512-LFuZUkjJ9iF7JZye/aG5XM0SFcQ5VyL0oVX4WJ9dc0Va3R3s0OauX1BESVCb+YN/ol8TAfqGDDAQsTG627Y5kw==", + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.13.tgz", + "integrity": "sha512-wgKggnhZVL9Bfx1OaKKTrYY9BFRk6C8UAkQNUcIv1+llzYrIqy+RZm5HPKzn0NpEBvTVhTqB4kQyllZywsRBRQ==", "license": "MIT", "peerDependencies": { "acorn": "^8.9.0" @@ -804,9 +804,9 @@ } }, "node_modules/@sveltejs/vite-plugin-svelte": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-7.3.0.tgz", - "integrity": "sha512-QbRoJyD92e9R0ufeQIWRHrCC0ObcqSv/aBDdrQMoU+sypav3cDx5wytdQ6GLdXjEMO6xjrXGzfkUygng8JMv0A==", + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-7.3.1.tgz", + "integrity": "sha512-ZPsLN8B1e/En+Ak5s4V7srFDT532oS0qieLsQwu63NGKsS+iAjoO2Js1BochlHlglcU+Pt7WAO3C5Ee+4f6gVA==", "dev": true, "license": "MIT", "dependencies": { @@ -1187,19 +1187,19 @@ } }, "node_modules/@threlte/core": { - "version": "8.6.0", - "resolved": "https://registry.npmjs.org/@threlte/core/-/core-8.6.0.tgz", - "integrity": "sha512-WzXjUAy6FW4T2M6cXgrP4WP0UlagU2C20t3h/0QJmCGALgWMct/yAG2xR8FIH3UAi4MGWQ5HkpQ8eQrp0NEHSg==", + "version": "8.6.1", + "resolved": "https://registry.npmjs.org/@threlte/core/-/core-8.6.1.tgz", + "integrity": "sha512-8wge5mfgXS5pPxiZLpk007Rw5VpDCOlpc9kdNpAOgXW4gjuBqDhnkUPGsdbzikddqF+61S7T6v6O+j6mtZhB2g==", "license": "MIT", "peerDependencies": { "svelte": ">=5", - "three": ">=0.160" + "three": ">=0.172" } }, "node_modules/@threlte/extras": { - "version": "9.21.1", - "resolved": "https://registry.npmjs.org/@threlte/extras/-/extras-9.21.1.tgz", - "integrity": "sha512-vSX9uXFqDVlA/xT5EHQN3YwWZvvcva6RKHQs6K39qa7w2nO+WV8TJvIR++SFpv3PjjED/F/mthj+KmkIkuL5Pw==", + "version": "9.22.0", + "resolved": "https://registry.npmjs.org/@threlte/extras/-/extras-9.22.0.tgz", + "integrity": "sha512-1bJlcFMpY16RKMgILBAki6pPm4r2FDHx/KGmJFlFj4CxAF31VkciBjXx3nAeqiMl2Brevsr9wfaccZ2dxirzrg==", "license": "MIT", "dependencies": { "@threejs-kit/instanced-sprite-mesh": "^2.5.1", @@ -1211,7 +1211,7 @@ }, "peerDependencies": { "svelte": ">=5", - "three": ">=0.160" + "three": ">=0.172" } }, "node_modules/@threlte/xr": { @@ -1295,9 +1295,9 @@ } }, "node_modules/@types/d3-selection": { - "version": "3.0.11", - "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", - "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "version": "3.0.12", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.12.tgz", + "integrity": "sha512-Qe/KWYhEiIIxGs7HrAAjMfShxKldx19SJtr5zu53f3afPsdZNz7HHtdTLXo/kqeiWNXVycI24kSnfzBYkTzpgw==", "license": "MIT" }, "node_modules/@types/d3-transition": { @@ -1465,6 +1465,13 @@ "license": "Apache-2.0", "peer": true }, + "node_modules/@types/three/node_modules/meshoptimizer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.1.1.tgz", + "integrity": "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==", + "license": "MIT", + "peer": true + }, "node_modules/@types/webxr": { "version": "0.5.24", "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", @@ -1483,14 +1490,14 @@ } }, "node_modules/@vitest/mocker": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.0.tgz", - "integrity": "sha512-66PGTMIiVJP3t4a5yxU9qPtf7MdTBs8jmToMvy+HVflB3Yy13WJZTtPePdvU+wjRV02SKK5doLbSA6o9pwOmiA==", + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.2.tgz", + "integrity": "sha512-Z5FS00Q1SJHkB35xATsmWGdQ5WA1/0MV3CDjqyv7GavHv1OfOj145MNfHOlHk7QLes21dKFDHr8EO2zvL+9WGA==", "dev": true, "license": "MIT", "dependencies": { "@jridgewell/trace-mapping": "0.3.31", - "@vitest/spy": "5.0.0", + "@vitest/spy": "5.0.2", "estree-walker": "^3.0.3", "magic-string": "^1.2.3" }, @@ -1521,9 +1528,9 @@ } }, "node_modules/@vitest/mocker/node_modules/magic-string": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.3.1.tgz", - "integrity": "sha512-rm91zr2Ou+XueDTohjQQjdQEcYM6zVi8KVUCG8Ec3vHwUEKrhSdCNyfuIywkA6hcCAteIn0ZOtAHA6eGpiX+Pg==", + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.2.tgz", + "integrity": "sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==", "dev": true, "license": "MIT", "dependencies": { @@ -1531,9 +1538,9 @@ } }, "node_modules/@vitest/spy": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.0.tgz", - "integrity": "sha512-uy+luWBAPw9XfthoHi5AkfHUnuPYEESjl0p/r+meoBnU8bxg5GDQ3Ey8MjcJ6sqahkL4PFyrvfMJJBw7LbU06g==", + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.2.tgz", + "integrity": "sha512-Ijc7T1nT9efNb5LxvjaBrEqw3f/QwUv5EE0nKqZxgqsaV/FxAAZ8baGylA8X/Z2oS4Lp+K74Jr6dTJsDKxJDeg==", "dev": true, "license": "MIT", "funding": { @@ -1541,22 +1548,22 @@ } }, "node_modules/@xyflow/svelte": { - "version": "1.6.5", - "resolved": "https://registry.npmjs.org/@xyflow/svelte/-/svelte-1.6.5.tgz", - "integrity": "sha512-bSPLuFlaa5mVWNg4FIZEt0vY2x+8eImnq57p4G6TlfkFPVBVVOyiBynhn4IN76oVOm7wWyFLHaCloV+4biLYhw==", + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@xyflow/svelte/-/svelte-1.7.0.tgz", + "integrity": "sha512-Q80coEUlaf/x20dMtTZYd5VinJrkqr8H5sS2zp7rFS6RmHF8JB5akV6Qy5mvIb23tWfHPS3hMovbbzN6u/4ppw==", "license": "MIT", "dependencies": { "@svelte-put/shortcut": "^4.1.0", - "@xyflow/system": "0.0.81" + "@xyflow/system": "0.0.83" }, "peerDependencies": { "svelte": "^5.25.0" } }, "node_modules/@xyflow/system": { - "version": "0.0.81", - "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.81.tgz", - "integrity": "sha512-hfbafW4i7uLq7ILok8QWFFm4KMFw22lbZNJHKfHOMSOOoCk5e5m8yfr84UV9NaJajmogWaLVnp2XFU9JQejlqg==", + "version": "0.0.83", + "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.83.tgz", + "integrity": "sha512-a//eDjez3WZF47aaPTfVUDD0OINOq+WVwBZCRTqG+m0a5euX8EwlJ4jMY1Wxz0Ocj4xOeekJ70V22Pq0ll8RJw==", "license": "MIT", "dependencies": { "@types/d3-drag": "^3.0.7", @@ -2133,9 +2140,9 @@ } }, "node_modules/devalue": { - "version": "5.9.0", - "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.9.0.tgz", - "integrity": "sha512-RWrqdArjvPbsATEhOPUo6Wndc/iWnkWKlhIrdlF3zMMYo/c3CVtoaVAyLtWxz5h8nSlkHzxnzV2uLydPXmtF+A==", + "version": "5.9.4", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.9.4.tgz", + "integrity": "sha512-sPAT4pztbu6586/hrhOnMKS17IJrvg12mXiSPSS3W5qDeN2RGgvZ0diZCm31dBbnevfVmujNO3IM2wrS4Y2Rhg==", "license": "MIT" }, "node_modules/diet-sprite": { @@ -2297,9 +2304,9 @@ "license": "MIT" }, "node_modules/esrap": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.3.0.tgz", - "integrity": "sha512-GQ/7RN8uOtEfNpzZzBMTzW9JBcX42oaSVtPzdF+6cEL8pqIL094iUpr9jzYGn4O4P/1S60dJ6izyT8F4LYARng==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.4.0.tgz", + "integrity": "sha512-CQPcneEHw/czj32uwQC52nYnax8H4SXt25FCM+LIwGZ49cMIxpOj3bP8wmzcI+Z0HIy5KkClaPhRLfCySzwHPA==", "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" @@ -2676,21 +2683,6 @@ "node": ">=14.14" } }, - "node_modules/fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -3384,9 +3376,9 @@ } }, "node_modules/meshoptimizer": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.1.1.tgz", - "integrity": "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.3.0.tgz", + "integrity": "sha512-2oZqMAcEEiCtXWUjIGtZ71oPx1B0ZxYGTqIKsXB1wzWFhleNsCFxQuUJzgCV706bRcwPWtMGf1odmQvAcqKkIw==", "license": "MIT" }, "node_modules/methods": { @@ -3803,28 +3795,25 @@ } }, "node_modules/playwright": { - "version": "1.62.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", - "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", + "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.62.1" + "playwright-core": "1.63.0" }, "bin": { "playwright": "cli.js" }, "engines": { "node": ">=20" - }, - "optionalDependencies": { - "fsevents": "2.3.2" } }, "node_modules/playwright-core": { - "version": "1.62.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", - "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", + "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", "dev": true, "license": "Apache-2.0", "bin": { @@ -3835,9 +3824,9 @@ } }, "node_modules/postcss": { - "version": "8.5.26", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", - "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", "dev": true, "funding": [ { @@ -3855,7 +3844,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.17", + "nanoid": "^3.3.18", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -3864,18 +3853,18 @@ } }, "node_modules/postprocessing": { - "version": "6.39.4", - "resolved": "https://registry.npmjs.org/postprocessing/-/postprocessing-6.39.4.tgz", - "integrity": "sha512-oAS/PjAbc/xT3OzjUrGsorJ4J064XwhVD2t0OwKLP/E8QwDMUJ0oOv6ZI1SYMtLchv4g0ySEx+JcLy92+48vlA==", + "version": "6.39.5", + "resolved": "https://registry.npmjs.org/postprocessing/-/postprocessing-6.39.5.tgz", + "integrity": "sha512-YmH+t74J2X1lwYjKzK/tgs9C9kqWWPB9JwZiDF5mEf4BdCEAmEFbO8lXBA7NM0/j4g+x6FgD/Tt5/kT7ogbEDA==", "license": "Zlib", "peerDependencies": { - "three": ">= 0.168.0 < 0.186.0" + "three": ">= 0.168.0 < 0.187.0" } }, "node_modules/prettier": { - "version": "3.9.6", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", - "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", + "version": "3.9.9", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.9.tgz", + "integrity": "sha512-Z/CJHIkdujO/OtN7nXUii0Rf3VT5SRuhjBA82Xvu2XhBUgX3nhP67T0LHceBdQLex7OOFGTox+Q5Yg8Jk2Qivg==", "dev": true, "license": "MIT", "bin": { @@ -4126,13 +4115,13 @@ } }, "node_modules/rolldown": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.6.tgz", - "integrity": "sha512-vMM4q3aixf46GiF1Kok8jDPFsEpXgFWGjUHXNkNHNm+Y2adXAG2dbX91jkti3i0ZRsOlcmbuzAz1poObSHCmUA==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.11.tgz", + "integrity": "sha512-qpSwIyz0jHQq5qXBTNxFmE6664rJ7O+4TvPFOiOaBSrz8IOHc1koKKSqTM2H6u1UG1+TveuC6vaDHKXFOvb1Kw==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.147.0", + "@oxc-project/types": "=0.151.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -4142,21 +4131,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm-eabi": "1.2.6", - "@rolldown/binding-android-arm64": "1.2.6", - "@rolldown/binding-darwin-arm64": "1.2.6", - "@rolldown/binding-darwin-x64": "1.2.6", - "@rolldown/binding-freebsd-x64": "1.2.6", - "@rolldown/binding-linux-arm-gnueabihf": "1.2.6", - "@rolldown/binding-linux-arm64-gnu": "1.2.6", - "@rolldown/binding-linux-arm64-musl": "1.2.6", - "@rolldown/binding-linux-ppc64-gnu": "1.2.6", - "@rolldown/binding-linux-s390x-gnu": "1.2.6", - "@rolldown/binding-linux-x64-gnu": "1.2.6", - "@rolldown/binding-linux-x64-musl": "1.2.6", - "@rolldown/binding-openharmony-arm64": "1.2.6", - "@rolldown/binding-win32-arm64-msvc": "1.2.6", - "@rolldown/binding-win32-x64-msvc": "1.2.6" + "@rolldown/binding-android-arm-eabi": "1.2.11", + "@rolldown/binding-android-arm64": "1.2.11", + "@rolldown/binding-darwin-arm64": "1.2.11", + "@rolldown/binding-darwin-x64": "1.2.11", + "@rolldown/binding-freebsd-x64": "1.2.11", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.11", + "@rolldown/binding-linux-arm64-gnu": "1.2.11", + "@rolldown/binding-linux-arm64-musl": "1.2.11", + "@rolldown/binding-linux-ppc64-gnu": "1.2.11", + "@rolldown/binding-linux-s390x-gnu": "1.2.11", + "@rolldown/binding-linux-x64-gnu": "1.2.11", + "@rolldown/binding-linux-x64-musl": "1.2.11", + "@rolldown/binding-openharmony-arm64": "1.2.11", + "@rolldown/binding-win32-arm64-msvc": "1.2.11", + "@rolldown/binding-win32-x64-msvc": "1.2.11" } }, "node_modules/run-parallel": { @@ -4378,13 +4367,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/siginfo": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", - "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", - "dev": true, - "license": "ISC" - }, "node_modules/sirv": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", @@ -4420,13 +4402,6 @@ "node": ">=0.10.0" } }, - "node_modules/stackback": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", - "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", - "dev": true, - "license": "MIT" - }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -4505,22 +4480,22 @@ } }, "node_modules/svelte": { - "version": "5.57.0", - "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.57.0.tgz", - "integrity": "sha512-NdbDn7fl4be1ViUG0oq/lvG6OZy3oENolV2ONjiqqsfVoeAfzaQAKUcEX3MrQod/Bebv1PgwET9rfXhgn9s4Kg==", + "version": "5.57.1", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.57.1.tgz", + "integrity": "sha512-Uqj49lWKB+iSSnneuwiYYJ7MZgkB+eXr0LXBhv4uDuAkXqnWmq65Sxflfvp0Lc6MdKjMUxGaeOKWJqz5SNiVIA==", "license": "MIT", "dependencies": { - "@jridgewell/remapping": "^2.3.4", - "@jridgewell/sourcemap-codec": "^1.5.0", - "@sveltejs/acorn-typescript": "^1.0.10", - "@types/estree": "^1.0.5", - "acorn": "^8.12.1", + "@jridgewell/remapping": "^2.3.5", + "@jridgewell/sourcemap-codec": "^1.6.0", + "@sveltejs/acorn-typescript": "^1.0.13", + "@types/estree": "^1.0.9", + "acorn": "^8.18.0", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", - "devalue": "^5.8.1", + "devalue": "^5.9.2", "esm-env": "^1.2.1", - "esrap": "^2.2.12", + "esrap": "^2.3.6", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", @@ -4647,9 +4622,9 @@ } }, "node_modules/three": { - "version": "0.185.1", - "resolved": "https://registry.npmjs.org/three/-/three-0.185.1.tgz", - "integrity": "sha512-5aojFCXKwnjBRZvUnt3WFfEcvUJgkN5LlijRFN95hMy8WVkG4I0QNcJE+OuWvuJ0bOdStrbfXn0pkd6/QyiAlg==", + "version": "0.186.1", + "resolved": "https://registry.npmjs.org/three/-/three-0.186.1.tgz", + "integrity": "sha512-blFeqb49wRCSGUGj7gtpfnSGHy2lwDk94RhUmS1c/hTby70kvChbWpkJ4Pm1390LqzzvTmzgXKHPEafJwCb8jA==", "license": "MIT" }, "node_modules/three-instanced-uniforms-mesh": { @@ -4665,9 +4640,9 @@ } }, "node_modules/three-mesh-bvh": { - "version": "0.9.14", - "resolved": "https://registry.npmjs.org/three-mesh-bvh/-/three-mesh-bvh-0.9.14.tgz", - "integrity": "sha512-xJzfUXnLYjgw5tRKGxjfJW3Pcx0IbwNskvpdwRryhK07SYrKrYB4W8zAaPxu784gK7G4lCBgG+vfG3YeYn95kg==", + "version": "0.9.15", + "resolved": "https://registry.npmjs.org/three-mesh-bvh/-/three-mesh-bvh-0.9.15.tgz", + "integrity": "sha512-9xdEDkCzFoT8VPFH9DznwFSdbaIEjMvM106Iqq7TJurrIheUKDcdW4prfJTCxryih4XS/UxWs0H3K4MLMRFyOQ==", "license": "MIT", "peerDependencies": { "three": ">= 0.159.0" @@ -4893,16 +4868,16 @@ } }, "node_modules/vite": { - "version": "8.2.2", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz", - "integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==", + "version": "8.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.1.tgz", + "integrity": "sha512-/bvH9E9tmCXRGp2uXY3WbOldqpTwFkbha/8ANaEQ6VkxhH60KyqLwgZq6lG2y+4uT55x9+9eUHMpQ7uGnOCKjA==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.33.0", - "picomatch": "^4.0.5", - "postcss": "^8.5.26", - "rolldown": "~1.2.4", + "picomatch": "^4.0.7", + "postcss": "^8.5.28", + "rolldown": "~1.2.9", "tinyglobby": "^0.2.17" }, "bin": { @@ -4919,7 +4894,7 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.4.0 || ^0.5.0", + "@vitejs/devtools": "^0.7.1", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", @@ -5279,14 +5254,14 @@ } }, "node_modules/vitest": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.0.tgz", - "integrity": "sha512-gpsMNoRhMjMktVxPtstOH4/PJuPyovVaMDr4oDilXaGH1EcqM2OE96SoHT2VIQ6fTGtTjqmHDrEu2X9RQiXf8Q==", + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.2.tgz", + "integrity": "sha512-7MQrx9pDv5aHiUcovIb/70Ys3tgtkUVgCtledvKdCmEO+/1Dicq5ZqoSxOW034m03oqC+oHOKui2dM6qtMLoJg==", "dev": true, "license": "MIT", "dependencies": { "@types/chai": "^5.2.2", - "@vitest/mocker": "5.0.0", + "@vitest/mocker": "5.0.2", "chai": "^6.2.2", "es-module-lexer": "^2.3.2", "expect-type": "^1.4.0", @@ -5294,10 +5269,10 @@ "obug": "^2.1.4", "picomatch": "^4.0.7", "std-env": "^4.2.0", - "tinybench": "6.1.4", - "tinyexec": "1.3.0", + "tinybench": "^6.1.4", + "tinyexec": "^1.3.0", "tinyglobby": "^0.2.17", - "why-is-node-running": "^2.3.0" + "why-is-node-running": "^3.2.1" }, "bin": { "vitest": "vitest.mjs" @@ -5312,12 +5287,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "5.0.0", - "@vitest/browser-preview": "5.0.0", + "@vitest/browser-playwright": "5.0.2", + "@vitest/browser-preview": "5.0.2", "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", - "@vitest/coverage-istanbul": "5.0.0", - "@vitest/coverage-v8": "5.0.0", - "@vitest/ui": "5.0.0", + "@vitest/coverage-istanbul": "5.0.2", + "@vitest/coverage-v8": "5.0.2", + "@vitest/ui": "5.0.2", "happy-dom": "*", "jsdom": "*", "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" @@ -5407,20 +5382,16 @@ } }, "node_modules/why-is-node-running": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", - "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.2.tgz", + "integrity": "sha512-NKUzAelcoCXhXL4dJzKIwXeR8iEVqsA0Lq6Vnd0UXvgaKbzVo4ZTHROF2Jidrv+SgxOQ03fMinnNhzZATxOD3A==", "dev": true, "license": "MIT", - "dependencies": { - "siginfo": "^2.0.0", - "stackback": "0.0.2" - }, "bin": { "why-is-node-running": "cli.js" }, "engines": { - "node": ">=8" + "node": ">=20.11" } }, "node_modules/wrap-ansi": { diff --git a/package.json b/package.json index 6723bced..9f0b386f 100644 --- a/package.json +++ b/package.json @@ -25,43 +25,43 @@ "devDependencies": { "@sveltejs/adapter-static": "^3.0.10", "@sveltejs/kit": "^2.70.3", - "@sveltejs/vite-plugin-svelte": "^7.2.0", + "@sveltejs/vite-plugin-svelte": "^7.3.1", "@tailwindcss/vite": "^4.3.3", "flowbite": "^3.1.2", "flowbite-svelte": "^1.33.1", "gh-pages": "^6.3.0", "peer": "^1.0.2", - "playwright": "^1.62.0", - "prettier": "^3.9.6", + "playwright": "^1.63.0", + "prettier": "^3.9.9", "prettier-plugin-svelte": "^4.1.1", "prettier-plugin-tailwindcss": "^0.8.1", - "svelte": "^5.57.0", + "svelte": "^5.57.1", "svelte-awesome-color-picker": "^4.1.3", "svelte-check": "^4.7.6", "tailwindcss": "^4.3.3", "typescript": "^5.9.3", - "vite": "^8.2.2", - "vitest": "^5.0.0" + "vite": "^8.3.1", + "vitest": "^5.0.2" }, "dependencies": { "@codemirror/lang-javascript": "^6.2.5", "@dimforge/rapier3d-compat": "^0.19.3", - "@lucide/svelte": "^1.35.0", + "@lucide/svelte": "^1.48.0", "@shaderfrog/core": "^4.0.2", "@shaderfrog/glsl-parser": "^7.0.1", - "@threlte/core": "^8.6.0", - "@threlte/extras": "^9.21.1", + "@threlte/core": "^8.6.1", + "@threlte/extras": "^9.22.0", "@threlte/xr": "^1.6.1", - "@xyflow/svelte": "^1.6.5", + "@xyflow/svelte": "^1.7.0", "codemirror": "^6.0.2", "fflate": "^0.8.3", - "meshoptimizer": "^1.1.1", + "meshoptimizer": "^1.3.0", "n8ao": "^2.0.1", "peerjs": "^1.5.5", - "postprocessing": "^6.39.3", + "postprocessing": "^6.39.5", "svelte-hamburgers": "^5.0.0", - "three": "^0.185.0", - "three-mesh-bvh": "^0.9.1" + "three": "^0.186.1", + "three-mesh-bvh": "^0.9.15" }, "engines": { "node": ">=24" diff --git a/scripts/author-templates.cjs b/scripts/author-templates.cjs index 6f6be2cd..bba26b2b 100644 --- a/scripts/author-templates.cjs +++ b/scripts/author-templates.cjs @@ -65,7 +65,8 @@ // music {url | file, sha256, name, volume?} — the scene's background track (+ Explorer) // sounds [{key, url | file, sha256, name}] — one-shot assets for Sound nodes // view {pos, target} — the editor camera the file opens on (also the card's camera) -// thumb {camera?: , sceneGroups?: [''], +// thumb {camera?: , sceneGroups?: [''], dress?: [objects] +// (card-only objects: built for the picture, never written into the file), // toneMapping?: 'agx'|'aces'|'neutral'|'reinhard'|'cineon'|'linear'|'none'} // contest (kind contest) {brief, rules, durationDays, opensAfterDays, judging, credits} // @@ -77,6 +78,10 @@ // plane size [w, h] — faces +Z (rot [-π/2, 0, 0] to lie flat) // ring r (outer), inner (r × 0.5) — a flat annulus facing +Z // icosahedron / dodecahedron r, detail (0) +// block shape 'Wedge' | 'Arch' | 'Corner' (an L) | 'Stairs', args [w, h, d(, steps)] — the +// app's building blocks (`/create Wedge 2 1 2`), bottom-anchored (origin on the floor). +// A hull collider fits a wedge; give an arch or an L a custom compound collider +// (physics.collider 'custom' + colliderVerts/colliderPieces — the Towers def's `compound`) // light kind 'point' (default: color, intensity, distance, decay — no shadow) // | 'spot' (angle π/6, penumbra 0.3, distance, decay, target) | 'directional' // (target; its shadow frustum is FITTED to the built meshes — `fit: false` to @@ -231,247 +236,6 @@ function graphBuilder() { // 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 - // startedAt, which is what clears the perRound latches — `reset:true` would instead - // call resetGame() and force state back to MENU (it is the Back-to-menu action). - 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('bagain', 'hudbutton', 'Play again button', 40, 190, { element: 'again-btn' }); - N('gomenu', 'setgamestate', 'Back to menu', 280, 190, { state: 'menu', outcome: '', reset: true }); - E('bagain', 'gomenu', 'trigger'); - - // ---- pause / restart while playing (P toggles a menu screen) --------------- - N('pkey', 'keypress', 'Press P', 40, 340, { code: 'KeyP', edge: 'down', pulse: 0.3 }); - N('pausetoggle', 'hudscreen', 'Toggle pause 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 pause menu', 280, 490, { screen: 'pause', action: 'hide' }); - E('bresume', 'resumehide', 'trigger'); - // Restart while playing: to bump a FRESH round the state must ENTER 'playing' from - // elsewhere (setGameState is a no-op when already playing). So restart resets to - // menu, then a short Delay re-enters playing — that transition bumps the round and - // clears the latches. The Delay is sourced from the button (whose trigger entry - // persists), never from a Once (whose rearm would delete the entry it re-derives). - 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 pause 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 menu button', 40, 790, { element: 'quit-btn' }); - N('doquit', 'setgamestate', 'Quit to menu', 280, 790, { state: 'menu', outcome: '', reset: true }); - N('quithide', 'hudscreen', 'Close pause on quit', 520, 790, { screen: 'pause', action: 'hide' }); - E('bquit', 'doquit', 'trigger'); - E('bquit', 'quithide', 'trigger'); - // 30: Round over offers Play again beside Menu — the Restart chain above, from `over` - N('breplay', 'hudbutton', 'Play again button', 40, 875, { element: 'replay-btn' }); - E('breplay', 'restartreset', 'trigger'); - E('breplay', 'restartdelay', 'trigger'); - - // ---- height: rung sensors -> perRound latches -> boolean*height -> max -> HUD - for (let i = 1; i <= 4; i++) { - const y = 960 + (i - 1) * 150; - N('enr' + i, 'onenter', 'Reached ' + i + 'm', 40, y, { pulse: 0.3 }); - N('selr' + i, 'objectselector', 'Ring ' + i + 'm', 280, y, { selected: 'Height ring ' + i + 'm' }); - E('enr' + i, 'selr' + i); - N('lat' + i, 'latch', 'Held ' + i + 'm', 520, y, { initial: false, perRound: true }); - E('enr' + i, 'lat' + i, 'set'); - N('mul' + i, 'math', i + 'm value', 760, y, { op: 'mul', a: 0, b: i }); - E('lat' + i, 'mul' + i, 'a'); - } - N('mx12', 'math', 'Max 1-2m', 1000, 1000, { op: 'max', a: 0, b: 0 }); - E('mul1', 'mx12', 'a'); - E('mul2', 'mx12', 'b'); - N('mx34', 'math', 'Max 3-4m', 1000, 1200, { op: 'max', a: 0, b: 0 }); - E('mul3', 'mx34', 'a'); - E('mul4', 'mx34', 'b'); - N('mxall', 'math', 'Best height', 1240, 1100, { op: 'max', a: 0, b: 0 }); - E('mx12', 'mxall', 'a'); - E('mx34', 'mxall', 'b'); - N('hheight', 'hudtext', 'HUD height', 1480, 1030, { element: 'height-read', format: 'Best height: {v} m', decimals: 0, value: 0 }); - E('mxall', 'hheight', 'value'); - // ---- 30: the BEST HEIGHT, saved on THIS device (Store Value / Stored Value) -------- - // Stored on every ring crossing, never on `over`: a perRound latch READS un-set the - // instant the round ends (the Infinity cutoff), so an `over` edge would store 0 — and - // that is also why the old Round-over line read "0 m" every time. `towers-last` is the - // same number for THIS round: `max` too (a crate jittering in a ring sensor re-fires - // the crossing after the round ends, when the height reads 0 — a `set` would wipe it), - // zeroed by its own `set` node when a round starts. Keys are game-specific because a - // Games-tab load leaves the scene unnamed, so every template shares tp:scene:untitled:*. - N('storebest', 'storevalue', 'Save best height', 1720, 1030, { key: 'towers-best', mode: 'max', value: 0 }); - N('storelast', 'storevalue', 'Save this round', 1720, 1180, { key: 'towers-last', mode: 'max', value: 0 }); - E('mxall', 'storebest', 'value'); - E('mxall', 'storelast', 'value'); - for (let i = 1; i <= 4; i++) { - E('enr' + i, 'storebest', 'trigger'); - E('enr' + i, 'storelast', 'trigger'); - } - N('onround', 'ongamestate', 'When a round starts', 1480, 1330, { state: 'playing', edge: 'enter', pulse: 0.3 }); - N('zerolast', 'storevalue', 'New round: zero it', 1720, 1330, { key: 'towers-last', mode: 'set', value: 0 }); - E('onround', 'zerolast', 'trigger'); - N('storedbest', 'storedvalue', 'My best height', 1960, 1030, { key: 'towers-best', output: 'number', fallback: 0 }); - N('hbest', 'hudtext', 'HUD best (menu)', 2200, 980, { element: 'best-read', format: 'Your best tower: {v} m', decimals: 0, value: 0 }); - E('storedbest', 'hbest', 'value'); - N('hbest2', 'hudtext', 'HUD best (over)', 2200, 1100, { element: 'over-best', format: 'Best ever: {v} m', decimals: 0, value: 0 }); - E('storedbest', 'hbest2', 'value'); - N('storedlast', 'storedvalue', 'This round', 1960, 1180, { key: 'towers-last', output: 'number', fallback: 0 }); - N('hfinal', 'hudtext', 'HUD final height', 2200, 1220, { element: 'final-height', format: 'This round: {v} m', decimals: 0, value: 0 }); - E('storedlast', 'hfinal', 'value'); - // ---- 30b: the rings are MILESTONES. The first crossing of a ring in a round (a perRound - // Once — a crate re-entering the sensor must not re-announce) tells every player: a banner, - // a burst AT the ring, a chime from the ring, and a success buzz in VR. The gold ring at - // the top is the big one: confetti, the level-up fanfare, "Top of the tower!". The Game - // Feel nodes are LOCAL on every peer from the replicated stamp, so no message of their own. - // (It replaces 30's particle emitter on the pad: the burst pool costs no emitter slot.) - N('buzzring', 'hapticpulse', 'Buzz: a ring reached', 1720, 1480, { pattern: 'success', hand: 'both' }); - for (let i = 1; i <= 4; i++) { - const y = 960 + (i - 1) * 150; - const top = i === 4; - N('first' + i, 'once', 'First time at ' + i + 'm', 2440, y, { perRound: true, pulse: 0.3 }); - E('enr' + i, 'first' + i, 'trigger'); - N('say' + i, 'announce', top ? 'Say: top!' : 'Say: ring ' + i, 2680, y, top - ? { text: 'Top of the tower!', sub: '4 m — the gold ring', seconds: 2.6, color: '#ffc640', decimals: 0 } - : { text: 'Ring ' + i + ' reached', sub: i + ' m — keep stacking', seconds: 1.8, color: '#9ee6ff', decimals: 0 }); - E('first' + i, 'say' + i, 'trigger'); - N('fx' + i, 'effectburst', 'Burst at ring ' + i, 2920, y, { kind: top ? 'confetti' : 'sparkle', count: top ? 96 : 56, lift: 0, color: '' }); - E('first' + i, 'fx' + i, 'trigger'); - E('selr' + i, 'fx' + i, 'at'); - // an additive sparkle alone is faint against a daylight sky: a small confetti puff - // beside it reads at any exposure (the top ring's burst IS confetti already) - if (!top) { - N('puff' + i, 'effectburst', 'Puff at ring ' + i, 2920, y + 70, { kind: 'confetti', count: 36, lift: 0.1, color: '' }); - E('first' + i, 'puff' + i, 'trigger'); - E('selr' + i, 'puff' + i, 'at'); - } - N('chime' + i, 'gamesound', top ? 'Fanfare at the top' : 'Chime at ring ' + i, 3160, y, { sound: top ? 'levelup' : 'ring' }); - E('first' + i, 'chime' + i, 'trigger'); - E('selr' + i, 'chime' + i, 'at'); - E('first' + i, 'buzzring', 'trigger'); - } - N('topsparkle', 'effectburst', 'Sparkle at the top', 2920, 1560, { kind: 'sparkle', count: 80, lift: 0.4, color: '#ffe08a' }); - E('first4', 'topsparkle', 'trigger'); - E('selr4', 'topsparkle', 'at'); - - // ---- the stars — collectible-module touch pickups (shared team score) ------- - for (let i = 1; i <= 3; i++) { - const y = 1600 + (i - 1) * 150; - N('colstar' + i, 'collectible', 'Star ' + i + ' pickup', 40, y, { - variable: 'stars', scope: 'shared', trigger: 'touch', radius: 1.4, - respawn: 0, hide: 'on', perRound: true, whilePlaying: true - }); - N('selstar' + i, 'objectselector', 'Star ' + i, 280, y, { selected: 'Star ' + i }); - E('colstar' + i, 'selstar' + i); - // 30: every star turns and breathes once a round starts (its authored `Star glow` - // clip: the Turntable + Pulse presets in ONE clip, since a transport plays one clip - // per object). Through a zero-second Delay: Play Animation reads a trigger VALUE and - // On Game State carries only a stamp (the beat-graph finding). - N('glow' + i, 'playanim', 'Star ' + i + ' glow', 1240, y, { clip: 'Star glow', action: 'restart', speed: 1 }); - E('glowpulse', 'glow' + i, 'trigger'); - E('glow' + i, 'selstar' + i); - } - N('glowpulse', 'delay', 'Round start pulse (0 s)', 1000, 1560, { seconds: 0, pulse: 0.3 }); - E('onround', 'glowpulse', 'trigger'); - N('cstars', 'collectiblecount', 'Stars left', 520, 1670, { variable: 'stars', read: 'left' }); - N('hstars', 'hudtext', 'HUD stars', 760, 1670, { element: 'stars-read', format: 'Stars left: {v}', decimals: 0, value: 0 }); - E('cstars', 'hstars', 'value'); - - // ---- win: every star collected, agreed by everyone playing ----------------- - N('starsdone', 'compare', 'All stars?', 520, 1900, { op: 'lte', a: 0, b: 0 }); - E('cstars', 'starsdone', 'a'); - N('allwin', 'allplayers', 'Everyone done', 760, 1900, { pulse: 0.3 }); - E('starsdone', 'allwin', 'condition'); - N('gowin', 'setgamestate', 'Win', 1000, 1900, { state: 'over', outcome: 'All stars collected!', reset: false }); - E('allwin', 'gowin', 'trigger'); - - // ---- the round clock: ends the round on time ------------------------------- - N('clock', 'gametime', 'Time left', 40, 2100, { read: 'remaining', length: 180 }); - N('hclock', 'hudtext', 'HUD clock', 280, 2040, { element: 'clock', format: 'Time: {v}s', decimals: 0, value: 0 }); - E('clock', 'hclock', 'value'); - N('timeup', 'compare', 'Time up?', 280, 2190, { op: 'lte', a: 0, b: 0 }); - E('clock', 'timeup', 'a'); - N('playing', 'gametime', 'Is playing', 40, 2340, { read: 'playing', length: 60 }); - N('timeandplay', 'gate', 'Time up & playing', 520, 2240, { op: 'and', a: false, b: false }); - E('timeup', 'timeandplay', 'a'); - E('playing', 'timeandplay', 'b'); - N('alltime', 'allplayers', 'Everyone time up', 760, 2240, { pulse: 0.3 }); - E('timeandplay', 'alltime', 'condition'); - N('gotime', 'setgamestate', 'Time over', 1000, 2240, { state: 'over', outcome: "Time's up!", reset: false }); - E('alltime', 'gotime', 'trigger'); - - // ---- 30b: the round's sound, and the crates' ------------------------------------------ - // Music: the arcade loop while you are in the game (Interact or Play; never the editor). - N('music', 'gamemusic', 'Arcade music', 40, 2480, { preset: 'arcade', volume: 0.45, while: 'always' }); - // Every menu button clicks. - N('click', 'gamesound', 'Button click', 280, 2480, { sound: 'click' }); - for (const b of ['bstart', 'bagain', 'bresume', 'brestart', 'bquit', 'breplay']) E(b, 'click', 'trigger'); - // A round starts with a whistle and a banner. It ENDS without one: the Round over panel is - // the result (a banner over it read twice), so the end is a cheer or a whistle + confetti. - N('saygo', 'announce', 'Say: build!', 1720, 1640, { text: 'Build!', sub: 'Stack crates on the glowing pad — reach the gold ring', seconds: 2.2, color: '#ffd45e', decimals: 0 }); - E('onround', 'saygo', 'trigger'); - N('whistle', 'gamesound', 'Round start whistle', 1720, 1720, { sound: 'whistle' }); - E('onround', 'whistle', 'trigger'); - N('cheer', 'gamesound', 'Cheer', 1240, 2080, { sound: 'cheer' }); - E('allwin', 'cheer', 'trigger'); - N('confetti', 'effectburst', 'Confetti for everyone', 1240, 2160, { kind: 'confetti', count: 96, lift: 0, color: '' }); - E('allwin', 'confetti', 'trigger'); - N('upwhistle', 'gamesound', 'Final whistle', 1240, 2380, { sound: 'whistle' }); - E('alltime', 'upwhistle', 'trigger'); - // Each crate: a pop when a player lifts it (On Grab) and a knock when it lands on - // something (On Impact past a gentle landing), both placed AT the crate. - TOWERS_CRATES.forEach((name, k) => { - const y = 2560 + k * 110; - N('csel' + k, 'objectselector', name, 280, y, { selected: name }); - N('cgrab' + k, 'ongrab', name + ' grabbed', 40, y, { pulse: 0.3 }); - E('cgrab' + k, 'csel' + k); - N('cpop' + k, 'gamesound', name + ' lift', 520, y, { sound: 'pop' }); - E('cgrab' + k, 'cpop' + k, 'trigger'); - E('csel' + k, 'cpop' + k, 'at'); - N('cland' + k, 'onimpact', name + ' lands', 760, y, { pulse: 0.3, minStrength: 0.8 }); - E('cland' + k, 'csel' + k); - N('cknock' + k, 'gamesound', name + ' knock', 1000, y, { sound: 'hit' }); - E('cland' + k, 'cknock' + k, 'trigger'); - E('csel' + k, 'cknock' + k, 'at'); - }); - - return g.done(); -} -/** the nine crates the sound chains above name (the objects below) */ -const TOWERS_CRATES = ['Cube 1', 'Cube 2', 'Cube 3', 'Cube 4', 'Cube 5', 'Cube 6', 'Plank 1', 'Plank 2', 'Plank 3']; - -const TOWERS_HUD_PANEL = { - bg: 'rgba(14, 20, 32, 0.9)', - radius: 18, - border: '1px solid rgba(255, 212, 94, 0.28)' -}; -const TOWERS_BTN = { size: 17, weight: '600', bg: '#3b7dd8', color: '#ffffff', radius: 10 }; -// 30: the finished look. Crates read as WOOD (a physical material with a sheen and a thin -// clearcoat, chamfered edges), the floor carries a subtle 2 m tile from a shader graph, the -// walls are chamfered with a glowing trim, and the height markers are thin glowing rings -// beside a marked pole instead of stacked translucent squares. -const TOWERS_WOOD = { physical: true, roughness: 0.6, sheen: 0.4, sheenColor: 0xffd7a0, sheenRoughness: 0.55, clearcoat: 0.12, clearcoatRoughness: 0.5 }; -const TOWERS_CRATE = { mode: 'dynamic', mass: 1, friction: 0.8, restitution: 0.03 }; -const TOWERS_PLANK = { mode: 'dynamic', mass: 0.9, friction: 0.8, restitution: 0.03 }; -/** a chamfered wooden crate (bevelSegments 1 keeps the baked geometry small) - * @param {string} name @param {number} color @param {number[]} size @param {number[]} pos @param {any} physics */ -const towersCrate = (name, color, size, pos, physics) => ({ type: 'box', name, color, size, bevel: 0.045, bevelSegments: 1, pos, ...TOWERS_WOOD, physics }); /** the floor tile: a 13 x 13 grid of 2 m tiles with a thin grout line and a faint checker, * MULTIPLYING the authored colour (albedo is `diffuseColor.rgb *= ` in the inject backend) */ const TOWERS_FLOOR_SHADER = { @@ -497,75 +261,162 @@ const TOWERS_FLOOR_SHADER = { { id: 'e-tile-s', source: 'tile', sourceHandle: 'out', target: 's', targetHandle: 'albedo' } ] }; -/** the Turntable + Pulse presets folded into ONE clip (a transport plays one clip per - * object): a turn every 6 s, a breath every 1.5 s — scale and glow together */ -const STAR_GLOW_CLIP = { - active: 'glow', - changedAt: 0, - clips: { - glow: { - name: 'Star glow', - duration: 6, - loop: 'loop', - tracks: [ - { id: 'turn', channel: 'rot.y', keys: [{ t: 0, v: 0 }, { t: 6, v: 6.2832 }] }, - { - id: 'breathe', - channel: 'scale', - keys: [0, 1.5, 3, 4.5, 6].flatMap((t, i, all) => - i < all.length - 1 - ? [{ t, v: 1, ease: [0.42, 0, 0.58, 1] }, { t: t + 0.75, v: 1.18, ease: [0.42, 0, 0.58, 1] }] - : [{ t, v: 1 }] - ) - }, - { - id: 'shine', - channel: 'emissive', - keys: [0, 1.5, 3, 4.5, 6].flatMap((t, i, all) => - i < all.length - 1 ? [{ t, v: 1.6 }, { t: t + 0.75, v: 3.2 }] : [{ t, v: 1.6 }] - ) - } - ] - } + +// ---- B8 → 31-towers: Towers, a real game ---------------------------------------- +// The round was "stack crates to the gold ring, hooray" (the Quest note: "after finishing +// basic level nothing happens"). It is TWELVE LEVELS now, each with its own piece shapes, a +// limited supply dealt onto racks, a goal, par pieces and par time for 1-3 stars, an unlock +// chain and logical rules: grab REACH is limited (play.reach, 1.3 m from your body), so the +// high levels need steps and a JUMP (the Character Controller's jumpHeight, desktop Space / +// VR A); pieces dropped outside the build yard are LOST; later levels add wind, a narrow +// pedestal, a rocking plate, an outline to fill and a star to deliver. +// +// THE SPLIT: this def is the ARENA (every object the levels name — zones, racks, the ledge, +// the perch, the piece TEMPLATES under the floor, the goal/yard/ghost markers), the HUD and a +// small graph (buttons, P pause, click sounds, music, the controller, the wobble effect, and +// the HUD words through the core `towers` module's `towersinfo` value node). The levels, the +// dealing, the judge and the stars live in src/modules/towers (levels.js is the table). +// No module download: the template needs only core. + +/** one wooden look for every piece */ +const TOWERS_WOOD = { physical: true, roughness: 0.6, sheen: 0.4, sheenColor: 0xffd7a0, sheenRoughness: 0.55, clearcoat: 0.12, clearcoatRoughness: 0.5 }; +/** the eight corners of a local box, flat — a custom compound collider piece + * @param {number} x0 @param {number} x1 @param {number} y0 @param {number} y1 @param {number} z0 @param {number} z1 */ +const boxVerts = (x0, x1, y0, y1, z0, z1) => { + const out = []; + for (const x of [x0, x1]) for (const y of [y0, y1]) for (const z of [z0, z1]) out.push(x, y, z); + return out; +}; +/** a custom collider from box pieces @param {number[][]} boxes [x0,x1,y0,y1,z0,z1] each */ +const compound = (boxes) => { + const colliderVerts = []; + const colliderPieces = []; + for (const b of boxes) { + colliderPieces.push([colliderVerts.length, 24]); + colliderVerts.push(...boxVerts(...b)); } + return { collider: 'custom', colliderVerts, colliderPieces }; +}; +const piecePhysics = (mass, extra = {}) => ({ mode: 'dynamic', mass, friction: 0.85, restitution: 0.03, ...extra }); +/** the vault the templates rest on, far under the arena floor */ +const VAULT_TOP = -6.25; +/** the piece TEMPLATES: dynamic (so every copy is), parked on the vault; the module copies + * them as transient pieces. Names are the module's `SHAPES[*].template`. */ +const TOWERS_TEMPLATES = [ + { type: 'box', name: 'Piece cube', color: 0xb57a45, size: [0.6, 0.6, 0.6], bevel: 0.045, bevelSegments: 1, pos: [-6, VAULT_TOP + 0.3, 0], ...TOWERS_WOOD, physics: piecePhysics(1) }, + { type: 'box', name: 'Piece plank', color: 0xd2a86e, size: [1.4, 0.3, 0.6], bevel: 0.04, bevelSegments: 1, pos: [-4.5, VAULT_TOP + 0.15, 0], ...TOWERS_WOOD, physics: piecePhysics(0.9) }, + { type: 'box', name: 'Piece beam', color: 0xc49a60, size: [2.0, 0.25, 0.4], bevel: 0.035, bevelSegments: 1, pos: [-2.4, VAULT_TOP + 0.125, 0], ...TOWERS_WOOD, physics: piecePhysics(1) }, + { type: 'block', shape: 'Wedge', args: [0.8, 0.6, 0.6], name: 'Piece wedge', color: 0x5f9fd8, pos: [-0.6, VAULT_TOP, 0], roughness: 0.55, physics: piecePhysics(0.7, { collider: 'hull' }) }, + { type: 'cylinder', name: 'Piece barrel', color: 0x9a5b2e, r: 0.3, h: 0.7, pos: [0.6, VAULT_TOP + 0.35, 0], ...TOWERS_WOOD, physics: piecePhysics(0.8, { collider: 'cylinder' }) }, + { + type: 'block', shape: 'Arch', args: [1.2, 0.8, 0.5], name: 'Piece arch', color: 0xb8ad98, pos: [2.2, VAULT_TOP, 0], roughness: 0.8, + physics: piecePhysics(1.4, compound([[-0.6, -0.36, 0, 0.8, -0.25, 0.25], [0.36, 0.6, 0, 0.8, -0.25, 0.25], [-0.36, 0.36, 0.72, 0.8, -0.25, 0.25]])) + }, + { + type: 'block', shape: 'Corner', args: [0.9, 0.45, 0.3], name: 'Piece L', color: 0x86b865, pos: [3.8, VAULT_TOP, 0], roughness: 0.6, + physics: piecePhysics(1, compound([[-0.45, 0.45, 0, 0.45, 0.15, 0.45], [-0.45, -0.15, 0, 0.45, -0.45, 0.45]])) + }, + { type: 'sphere', name: 'Piece ball', color: 0xe0604a, r: 0.3, pos: [5.2, VAULT_TOP + 0.3, 0], physical: true, roughness: 0.35, clearcoat: 0.6, physics: piecePhysics(0.8, { collider: 'sphere', friction: 0.6, restitution: 0.15 }) }, + { type: 'box', name: 'Piece base', color: 0x59616e, size: [1.4, 0.35, 1.4], bevel: 0.05, bevelSegments: 1, pos: [6.8, VAULT_TOP + 0.175, 0], physical: true, roughness: 0.7, clearcoat: 0.2, physics: piecePhysics(6, { friction: 1 }) }, + { type: 'dodecahedron', name: 'Piece star', color: 0xffc640, r: 0.26, pos: [8.4, VAULT_TOP + 0.26, 0], emissive: 0xffb830, emissiveIntensity: 1.6, physical: true, roughness: 0.25, metalness: 0.1, clearcoat: 1, flatShading: true, physics: piecePhysics(0.4, { collider: 'hull' }) } +]; + +const TOWERS_HUD_PANEL = { bg: 'rgba(14, 20, 32, 0.9)', radius: 18, border: '1px solid rgba(255, 212, 94, 0.28)' }; +const TOWERS_BTN = { size: 17, weight: '600', bg: '#3b7dd8', color: '#ffffff', radius: 10 }; +const TOWERS_LEVEL_NAMES = ['Stack', 'Planks', 'Climb', 'Wedges', 'Barrels', 'Arches', 'Narrow base', 'Gusts', 'Balls', 'Wobble', 'Outline', 'Summit']; + +function towersGraph() { + const g = graphBuilder(); + const { N, E } = g; + // ---- the buttons: the core `towers` module watches their stamps; the graph adds the click + N('click', 'gamesound', 'Button click', 520, 40, { sound: 'click' }); + const button = (id, element, label, x, y) => { + N(id, 'hudbutton', label, x, y, { element }); + E(id, 'click', 'trigger'); + return id; + }; + for (let i = 1; i <= 12; i++) button('lvl' + i, 'lvl-' + i, 'Level ' + i + ' button', 40, 40 + (i - 1) * 70); + button('bnext', 'next-btn', 'Next level button', 40, 900); + button('bretry', 'retry-btn', 'Retry button', 40, 970); + button('blevels', 'levels-btn', 'Levels button', 40, 1040); + // ---- pause (P): Resume closes it; Restart and Levels are the module's, and close it too + N('pkey', 'keypress', 'Press P', 40, 1140, { code: 'KeyP', edge: 'down', pulse: 0.3 }); + N('pausetoggle', 'hudscreen', 'Toggle pause menu', 280, 1140, { screen: 'pause', action: 'toggle' }); + E('pkey', 'pausetoggle', 'trigger'); + N('pausehide', 'hudscreen', 'Close pause menu', 520, 1240, { screen: 'pause', action: 'hide' }); + button('bresume', 'resume-btn', 'Resume button', 40, 1210); + button('brestart', 'restart-btn', 'Restart level button', 40, 1280); + button('bquit', 'quit-btn', 'Levels (pause) button', 40, 1350); + for (const b of ['bresume', 'brestart', 'bquit']) E(b, 'pausehide', 'trigger'); + // ---- the HUD's words, from the module's Towers info node into HUD Text's FORMAT + const text = (id, read, element, x, y, extra = {}) => { + N(id + 'i', 'towersinfo', 'Towers: ' + read, x, y, { read, ...extra }); + N(id + 't', 'hudtext', 'HUD ' + element, x + 240, y, { element, format: '', decimals: 0, value: 0 }); + E(id + 'i', id + 't', 'format'); + }; + text('mline', 'menuLine', 'menu-line', 800, 40); + for (let i = 1; i <= 12; i++) text('ls' + i, 'levelStars', 'lvl-' + i + '-stars', 800, 110 + (i - 1) * 70, { level: i }); + text('title', 'title', 'tw-title', 1300, 40); + text('goal', 'goalText', 'tw-goal', 1300, 110); + text('pieces', 'pieces', 'tw-pieces', 1300, 180); + text('clock', 'clock', 'tw-clock', 1300, 250); + text('hold', 'hold', 'tw-hold', 1300, 320); + text('rule', 'rule', 'tw-rule', 1300, 390); + text('result', 'result', 'tw-result', 1300, 480); + text('rstars', 'resultStars', 'tw-stars', 1300, 550); + text('rline', 'resultLine', 'tw-line', 1300, 620); + text('rbest', 'resultBest', 'tw-best', 1300, 690); + N('progi', 'towersinfo', 'Towers: progress', 1300, 780, { read: 'progress' }); + N('progbar', 'hudbar', 'HUD progress bar', 1540, 780, { element: 'tw-bar', value: 0, min: 0, max: 1, format: '' }); + E('progi', 'progbar', 'value'); + // ---- the world: music in the game, the player's body, the rocking plate + N('music', 'gamemusic', 'Arcade music', 40, 1460, { preset: 'arcade', volume: 0.4, while: 'always' }); + // the Character Controller: WALK with gravity and a 1 m jump (Space on a desktop, A in VR), + // which is what lets a player climb onto pieces to reach a high goal + N('body', 'charcontroller', 'Player: walk + jump', 280, 1460, { mode: 'walk', speed: 0.06, jumpHeight: 1.0, eyeHeight: 1.7, gravity: true }); + N('wobble', 'towerswobble', 'Wobble plate rocks (level 10)', 40, 1560, { amplitude: 0.07, period: 3.4 }); + N('selwobble', 'objectselector', 'Wobble plate', 280, 1560, { selected: 'Wobble plate' }); + E('wobble', 'selwobble'); + return g.done(); +} + +/** a level-select cell: the button and the stars line under it @param {number} i 1..12 */ +const towersLevelCell = (i) => { + const col = (i - 1) % 4; + const row = Math.floor((i - 1) / 4); + const x = -216 + col * 144; + const y = -40 + row * 76; + return [ + { id: 'lvl-' + i, kind: 'button', anchor: 'center', x, y, w: 132, h: 42, z: 1, label: i + ' · ' + TOWERS_LEVEL_NAMES[i - 1], enabled: true, style: { ...TOWERS_BTN, size: 14 } }, + { id: 'lvl-' + i + '-stars', kind: 'text', anchor: 'center', x, y: y + 30, w: 132, h: 18, z: 1, label: '', style: { size: 13, weight: '600', color: '#ffd45e', align: 'center' } } + ]; }; + const TOWERS_DEF = { kind: 'game', slug: 'towers', title: 'Towers', description: - 'Co-op crate stacking: grab the wooden crates, build the tallest tower on the glowing pad, climb to the stars. Your best height is saved. Press P to pause or restart.', + 'Twelve levels of stacking: cubes, planks, wedges, barrels, arches and balls. You can only reach what is close to you, so build steps and jump. Wind, a wobbling plate and a star to deliver. 1-3 stars a level, saved on this device.', license: 'CC0-1.0', author: 'theprototype', - tags: ['physics', 'stacking', 'co-op', 'vr'], - modules: [{ id: 'collectible', version: '1.1.2' }], - installModules: ['collectible'], - // 30: daylight under a real sky — a blue-to-haze gradient, a far fog that softens the - // horizon, and a solid ground disc around the arena (the infinite grid is editor chrome; - // it no longer shows in Play). Exposure above the 0.9 floor. + tags: ['physics', 'stacking', 'puzzle', 'co-op', 'vr'], + modules: [], env: { preset: 'daylight', exposure: 1.05, background: { top: '#4a7fc0', bottom: '#dbe8f2' }, - // 30 integrate: the fog closes in past the arena wall, so the ground beyond the edge - // fades into the sky's own haze instead of ending in a flat olive band fog: { color: '#dbe8f2', near: 16, far: 75 }, ground: { color: '#7b8866', roughness: 0.95 } }, - // ground ON — a solid floor the crates rest on. A crate knocked past the low wall - // falls to the bounds limit and RESPAWNS to its start pose (beforeStates), so the - // supply cannot be lost. Grab interaction, sim starts on Play. physics: { ground: { enabled: true, height: 0, friction: 0.8, restitution: 0 }, bounds: { limit: -20, action: 'respawn' }, material: { friction: 0.7, restitution: 0.05 }, damping: { linear: 0.05, angular: 0.3 }, - // 30b: a SPAWN — Play (and Interact in VR) puts you 4.5 m in front of the pad, facing - // it, with a podium on each hand (yaw 0 faces -Z) - play: { interaction: 'grab', grounded: false, simOnPlay: true, spawn: { position: [0, 0, 4.5], yaw: 0 } } + // 31-towers: REACH 1.3 m from your body — the puzzle's first rule + play: { interaction: 'grab', grounded: false, simOnPlay: true, spawn: { position: [0, 0, 5.4], yaw: 0 }, reach: 1.3 } }, - // the shell floor (fork 11): ao -> AgX -> bloom -> smaa, bloom a touch higher so the - // rings, the trim and the pad read as light post: { enabled: true, effects: [ @@ -576,11 +427,28 @@ const TOWERS_DEF = { ], changedAt: 0 }, - view: { pos: [7.5, 6.5, 12.5], target: [0, 1.4, 0] }, - thumb: { camera: 'Card camera' }, + view: { pos: [9, 7.5, 13.5], target: [0, 1.4, -1] }, + thumb: { + camera: 'Card camera', + // a round in progress: a half-built tower on the pad, the supply on the racks + dress: [ + { ...TOWERS_TEMPLATES[8], name: 'Card base', pos: [0, 0.375, 0], physics: undefined }, + { ...TOWERS_TEMPLATES[0], name: 'Card cube 1', pos: [-0.35, 0.86, 0.1], physics: undefined }, + { ...TOWERS_TEMPLATES[0], name: 'Card cube 2', pos: [0.35, 0.86, -0.1], rot: [0, 0.3, 0], physics: undefined }, + { ...TOWERS_TEMPLATES[1], name: 'Card plank', pos: [0, 1.32, 0], rot: [0, 0.4, 0], physics: undefined }, + { ...TOWERS_TEMPLATES[3], name: 'Card wedge', pos: [0, 1.47, 0], rot: [0, 0.4, 0], physics: undefined }, + { ...TOWERS_TEMPLATES[4], name: 'Card barrel', pos: [0.9, 0.35, 1.9], rot: [0, 0, Math.PI / 2], physics: undefined }, + { ...TOWERS_TEMPLATES[0], name: 'Card rack 1', pos: [4.4, 1.12, 0.9], physics: undefined }, + { ...TOWERS_TEMPLATES[0], name: 'Card rack 2', pos: [4.4, 1.12, 1.7], physics: undefined }, + { ...TOWERS_TEMPLATES[5], name: 'Card rack 3', pos: [4.4, 0.82, 2.8], physics: undefined }, + { ...TOWERS_TEMPLATES[1], name: 'Card rack 4', pos: [-4.4, 0.97, 1.0], physics: undefined }, + { ...TOWERS_TEMPLATES[6], name: 'Card rack 5', pos: [-4.4, 0.82, 2.4], physics: undefined }, + { ...TOWERS_TEMPLATES[0], name: 'Card ledge', pos: [0.6, 3.32, -3.0], physics: undefined }, + { ...TOWERS_TEMPLATES[9], name: 'Card star', pos: [2.4, 3.68, -2.2], physics: undefined } + ] + }, graphs: { scene: towersGraph() }, shaders: { 'Arena floor': TOWERS_FLOOR_SHADER }, - animations: { 'Star 1': STAR_GLOW_CLIP, 'Star 2': STAR_GLOW_CLIP, 'Star 3': STAR_GLOW_CLIP }, hud: { scene: { active: '', @@ -588,19 +456,17 @@ const TOWERS_DEF = { screens: [ { id: 'menu', - name: 'Menu', + name: 'Levels', showWhile: 'menu', input: 'menu', elements: [ - { id: 'menu-panel', kind: 'panel', anchor: 'center', x: 0, y: 0, w: 500, h: 460, z: 0, label: '', style: TOWERS_HUD_PANEL }, - { id: 'title', kind: 'text', anchor: 'center', x: 0, y: -176, w: 420, h: 54, z: 1, label: 'TOWERS', style: { size: 42, weight: '700', color: '#ffd45e', align: 'center' } }, - // 30b: HOW TO PLAY, said once, plainly — the goal, the milestones, the clock - { id: 'howto-title', kind: 'text', anchor: 'center', x: 0, y: -132, w: 440, h: 20, z: 1, label: 'HOW TO PLAY', style: { size: 12, weight: '700', color: '#9ee6ff', align: 'center' } }, - { id: 'subtitle', kind: 'text', anchor: 'center', x: 0, y: -94, w: 450, h: 60, z: 1, label: 'Grab crates from the podiums and stack them on the glowing pad. Each ring your tower reaches is a milestone — the gold ring at 4 m is the top. Touch the three floating stars. Three minutes a round.', style: { size: 14, color: '#d8dee9', align: 'center' }, wrap: true }, - { id: 'best-read', kind: 'text', anchor: 'center', x: 0, y: -44, w: 420, h: 24, z: 1, label: 'Your best tower: 0 m', style: { size: 15, weight: '600', color: '#9ee6ff', align: 'center' } }, - { id: 'start-btn', kind: 'button', anchor: 'center', x: 0, y: 14, w: 240, h: 50, z: 1, label: 'Start round', enabled: true, style: TOWERS_BTN }, - { id: 'menu-hint', kind: 'text', anchor: 'center', x: 0, y: 88, w: 460, h: 36, z: 1, label: 'Desktop: hold click to grab · wheel pushes/pulls · Q/E fly · P pause', style: { size: 12, color: '#8b97a8', align: 'center' }, wrap: true }, - { id: 'menu-hint-vr', kind: 'text', anchor: 'center', x: 0, y: 128, w: 460, h: 36, z: 1, label: 'VR: grip grabs a crate · left stick walks · Y switches to Edit mode', style: { size: 12, color: '#8b97a8', align: 'center' }, wrap: true } + { id: 'menu-panel', kind: 'panel', anchor: 'center', x: 0, y: 20, w: 640, h: 520, z: 0, label: '', style: TOWERS_HUD_PANEL }, + { id: 'title', kind: 'text', anchor: 'center', x: 0, y: -206, w: 520, h: 50, z: 1, label: 'TOWERS', style: { size: 40, weight: '700', color: '#ffd45e', align: 'center' } }, + { id: 'subtitle', kind: 'text', anchor: 'center', x: 0, y: -154, w: 580, h: 44, z: 1, label: 'Carry pieces from the racks to the glowing zone and build up to the gold ring. You can only grab what is close to you, so build steps and jump to reach higher. The tower must hold still for three seconds.', style: { size: 13, color: '#d8dee9', align: 'center' }, wrap: true }, + { id: 'menu-line', kind: 'text', anchor: 'center', x: 0, y: -110, w: 420, h: 22, z: 1, label: '', style: { size: 14, weight: '600', color: '#9ee6ff', align: 'center' } }, + ...Array.from({ length: 12 }, (_, k) => towersLevelCell(k + 1)).flat(), + { id: 'menu-hint', kind: 'text', anchor: 'center', x: 0, y: 214, w: 600, h: 34, z: 1, label: 'Desktop: hold click to grab · wheel pushes/pulls · WASD walk · Space jumps · P pause', style: { size: 12, color: '#8b97a8', align: 'center' }, wrap: true }, + { id: 'menu-hint-vr', kind: 'text', anchor: 'center', x: 0, y: 244, w: 600, h: 34, z: 1, label: 'VR: grip grabs a piece · left stick walks · A jumps · Y switches to Edit', style: { size: 12, color: '#8b97a8', align: 'center' }, wrap: true } ] }, { @@ -609,10 +475,13 @@ const TOWERS_DEF = { showWhile: 'playing', input: 'game', elements: [ - { id: 'height-read', kind: 'text', anchor: 'top-center', x: 0, y: 14, w: 280, h: 30, z: 1, label: '', style: { size: 20, weight: '700', color: '#ffffff', align: 'center' } }, - { id: 'clock', kind: 'text', anchor: 'top-center', x: 0, y: 48, w: 120, h: 22, z: 1, label: '', style: { size: 13, weight: '600', color: '#e5e9f0', align: 'center' } }, - { id: 'stars-read', kind: 'text', anchor: 'top-right', x: 16, y: 14, w: 200, h: 24, z: 1, label: '', style: { size: 15, weight: '600', color: '#ffd45e', align: 'right' } }, - { id: 'play-hint', kind: 'text', anchor: 'bottom-center', x: 0, y: 12, w: 520, h: 20, z: 1, label: 'Stack on the glowing pad — the rings mark your height. Press P to pause.', style: { size: 11, color: '#e5e9f0', align: 'center' } } + { id: 'tw-title', kind: 'text', anchor: 'top-center', x: 0, y: 12, w: 360, h: 28, z: 1, label: '', style: { size: 19, weight: '700', color: '#ffd45e', align: 'center' } }, + { id: 'tw-goal', kind: 'text', anchor: 'top-center', x: 0, y: 42, w: 420, h: 22, z: 1, label: '', style: { size: 14, weight: '600', color: '#ffffff', align: 'center' } }, + { id: 'tw-bar', kind: 'bar', anchor: 'top-center', x: 0, y: 68, w: 300, h: 10, z: 1, label: '', value: 0, min: 0, max: 1, style: { color: '#ffd45e', bg: 'rgba(255,255,255,0.18)', radius: 5 } }, + { id: 'tw-clock', kind: 'text', anchor: 'top-right', x: 16, y: 14, w: 120, h: 26, z: 1, label: '', style: { size: 20, weight: '700', color: '#e5e9f0', align: 'right' } }, + { id: 'tw-pieces', kind: 'text', anchor: 'top-right', x: 16, y: 44, w: 320, h: 20, z: 1, label: '', style: { size: 12, weight: '600', color: '#9ee6ff', align: 'right' } }, + { id: 'tw-hold', kind: 'text', anchor: 'center', x: 0, y: -120, w: 360, h: 40, z: 1, label: '', style: { size: 28, weight: '700', color: '#7dffb0', align: 'center' } }, + { id: 'tw-rule', kind: 'text', anchor: 'bottom-center', x: 0, y: 12, w: 620, h: 20, z: 1, label: '', style: { size: 12, color: '#e5e9f0', align: 'center' } } ] }, { @@ -623,28 +492,32 @@ const TOWERS_DEF = { { id: 'pause-panel', kind: 'panel', anchor: 'center', x: 0, y: 0, w: 380, h: 300, z: 0, label: '', style: TOWERS_HUD_PANEL }, { id: 'pause-title', kind: 'text', anchor: 'center', x: 0, y: -95, w: 340, h: 36, z: 1, label: 'PAUSED', style: { size: 26, weight: '700', color: '#e5e9f0', align: 'center' } }, { id: 'resume-btn', kind: 'button', anchor: 'center', x: 0, y: -30, w: 240, h: 42, z: 1, label: 'Resume', enabled: true, style: { ...TOWERS_BTN, size: 16 } }, - { id: 'restart-btn', kind: 'button', anchor: 'center', x: 0, y: 22, w: 240, h: 42, z: 1, label: 'Restart round', enabled: true, style: { ...TOWERS_BTN, size: 16, bg: '#4c9e6a' } }, - { id: 'quit-btn', kind: 'button', anchor: 'center', x: 0, y: 74, w: 240, h: 42, z: 1, label: 'Quit to menu', enabled: true, style: { size: 15, weight: '500', bg: '#3a4150', color: '#e5e9f0', radius: 10 } } + { id: 'restart-btn', kind: 'button', anchor: 'center', x: 0, y: 22, w: 240, h: 42, z: 1, label: 'Restart level', enabled: true, style: { ...TOWERS_BTN, size: 16, bg: '#4c9e6a' } }, + { id: 'quit-btn', kind: 'button', anchor: 'center', x: 0, y: 74, w: 240, h: 42, z: 1, label: 'Levels', enabled: true, style: { size: 15, weight: '500', bg: '#3a4150', color: '#e5e9f0', radius: 10 } } ] }, { id: 'over', - name: 'Round over', + name: 'Results', showWhile: 'over', input: 'menu', elements: [ - { id: 'over-panel', kind: 'panel', anchor: 'center', x: 0, y: 0, w: 440, h: 320, z: 0, label: '', style: TOWERS_HUD_PANEL }, - { id: 'over-title', kind: 'text', anchor: 'center', x: 0, y: -104, w: 400, h: 40, z: 1, label: 'ROUND OVER', style: { size: 30, weight: '700', color: '#ffd45e', align: 'center' } }, - { id: 'final-height', kind: 'text', anchor: 'center', x: 0, y: -56, w: 400, h: 28, z: 1, label: '', style: { size: 18, weight: '600', color: '#e5e9f0', align: 'center' } }, - { id: 'over-best', kind: 'text', anchor: 'center', x: 0, y: -24, w: 400, h: 22, z: 1, label: '', style: { size: 14, color: '#9ee6ff', align: 'center' } }, - { id: 'replay-btn', kind: 'button', anchor: 'center', x: 0, y: 40, w: 240, h: 46, z: 1, label: 'Play again', enabled: true, style: TOWERS_BTN }, - { id: 'again-btn', kind: 'button', anchor: 'center', x: 0, y: 96, w: 240, h: 42, z: 1, label: 'Menu', enabled: true, style: { size: 15, weight: '500', bg: '#3a4150', color: '#e5e9f0', radius: 10 } } + { id: 'over-panel', kind: 'panel', anchor: 'center', x: 0, y: 0, w: 480, h: 360, z: 0, label: '', style: TOWERS_HUD_PANEL }, + { id: 'tw-result', kind: 'text', anchor: 'center', x: 0, y: -130, w: 440, h: 40, z: 1, label: '', style: { size: 28, weight: '700', color: '#ffd45e', align: 'center' } }, + { id: 'tw-stars', kind: 'text', anchor: 'center', x: 0, y: -84, w: 300, h: 46, z: 1, label: '', style: { size: 38, weight: '700', color: '#ffd45e', align: 'center' } }, + { id: 'tw-line', kind: 'text', anchor: 'center', x: 0, y: -36, w: 440, h: 40, z: 1, label: '', style: { size: 13, color: '#e5e9f0', align: 'center' }, wrap: true }, + { id: 'tw-best', kind: 'text', anchor: 'center', x: 0, y: -4, w: 440, h: 20, z: 1, label: '', style: { size: 12, color: '#9ee6ff', align: 'center' } }, + { id: 'next-btn', kind: 'button', anchor: 'center', x: 0, y: 50, w: 240, h: 46, z: 1, label: 'Next level', enabled: true, style: TOWERS_BTN }, + { id: 'retry-btn', kind: 'button', anchor: 'center', x: -64, y: 110, w: 116, h: 40, z: 1, label: 'Retry', enabled: true, style: { ...TOWERS_BTN, size: 15, bg: '#4c9e6a' } }, + { id: 'levels-btn', kind: 'button', anchor: 'center', x: 64, y: 110, w: 116, h: 40, z: 1, label: 'Levels', enabled: true, style: { size: 15, weight: '500', bg: '#3a4150', color: '#e5e9f0', radius: 10 } } ] } ] } }, objects: [ + // the marker that wakes the core `towers` module (an empty: no body, no draw) + { type: 'empty', name: 'Towers game' }, // the arena — a tiled floor with a low chamfered rim and a glowing trim on top { type: 'box', name: 'Arena floor', color: 0xb4bbc4, size: [26, 0.5, 26], pos: [0, -0.25, 0], roughness: 0.78, physics: { mode: 'static', friction: 0.9 } }, { type: 'box', name: 'Wall north', color: 0x7a8494, size: [26, 1, 0.5], bevel: 0.1, bevelSegments: 1, pos: [0, 0.5, -13], physical: true, roughness: 0.55, clearcoat: 0.3, physics: { mode: 'static' } }, @@ -655,46 +528,38 @@ const TOWERS_DEF = { { type: 'box', name: 'Trim south', color: 0x9fe8ff, size: [25.4, 0.05, 0.12], pos: [0, 1.02, 13], emissive: 0x4fcfff, emissiveIntensity: 2.4, shadow: false }, { type: 'box', name: 'Trim west', color: 0x9fe8ff, size: [0.12, 0.05, 25.4], pos: [-13, 1.02, 0], emissive: 0x4fcfff, emissiveIntensity: 2.4, shadow: false }, { type: 'box', name: 'Trim east', color: 0x9fe8ff, size: [0.12, 0.05, 25.4], pos: [13, 1.02, 0], emissive: 0x4fcfff, emissiveIntensity: 2.4, shadow: false }, - // build pad — glowing blue under a clearcoat, SUNK so its bottom face is not coplanar - // with the floor, a bright rim, and a soft blue light spilling onto the tiles + // ZONE 1 — the build pad (most levels), sunk so its bottom is not coplanar with the floor { type: 'cylinder', name: 'Build pad', color: 0x2f6fbf, r: 1.7, h: 0.24, pos: [0, 0.08, 0], emissive: 0x2f8fff, emissiveIntensity: 1.1, physical: true, roughness: 0.3, clearcoat: 0.8, physics: { mode: 'static', friction: 1 } }, - { type: 'torus', name: 'Pad rim', color: 0xbfefff, r: 1.72, tube: 0.045, pos: [0, 0.2, 0], rot: [-Math.PI / 2, 0, 0], emissive: 0x7fdcff, emissiveIntensity: 3, shadow: false }, + { type: 'torus', name: 'Pad rim', color: 0xbfefff, r: 1.72, tube: 0.045, pos: [0, 0.2, 0], rot: [-Math.PI / 2, 0, 0], emissive: 0x7fdcff, emissiveIntensity: 3, shadow: false, pick: 'through', physics: { mode: 'static', sensor: true } }, { type: 'light', name: 'Pad glow', kind: 'point', color: 0x5fb4ff, intensity: 4, distance: 7, pos: [0, 0.7, 0] }, - // podiums where the crate supply sits, sunk into the floor by the same trick - { type: 'cylinder', name: 'Cube podium', color: 0x5d6879, r: 1.1, h: 0.5, pos: [-5.5, 0.2, 0], physical: true, metalness: 0.1, roughness: 0.4, clearcoat: 0.5, physics: { mode: 'static', friction: 0.9 } }, - { type: 'cylinder', name: 'Plank podium', color: 0x5d6879, r: 1.1, h: 0.5, pos: [5.5, 0.2, 0], physical: true, metalness: 0.1, roughness: 0.4, clearcoat: 0.5, physics: { mode: 'static', friction: 0.9 } }, - // PRE-PLACED wooden crates: a tidy supply that rests until grabbed, then stays put. - // Cubes on the left podium (podium top ~0.45; stack from just above it). - towersCrate('Cube 1', 0xb57a45, [0.6, 0.6, 0.6], [-5.5, 0.85, 0], TOWERS_CRATE), - towersCrate('Cube 2', 0xc28d55, [0.6, 0.6, 0.6], [-5.5, 1.5, 0], TOWERS_CRATE), - towersCrate('Cube 3', 0xa86d3c, [0.6, 0.6, 0.6], [-5.5, 2.15, 0], TOWERS_CRATE), - towersCrate('Cube 4', 0xb57a45, [0.6, 0.6, 0.6], [-5.5, 2.8, 0], TOWERS_CRATE), - // planks on the right podium - towersCrate('Plank 1', 0xd2a86e, [1.4, 0.3, 0.6], [5.5, 0.75, 0], TOWERS_PLANK), - towersCrate('Plank 2', 0xc49a60, [1.4, 0.3, 0.6], [5.5, 1.2, 0], TOWERS_PLANK), - towersCrate('Plank 3', 0xd2a86e, [1.4, 0.3, 0.6], [5.5, 1.65, 0], TOWERS_PLANK), - // a few loose cubes near the pad to start building right away - towersCrate('Cube 5', 0xc28d55, [0.6, 0.6, 0.6], [-2, 0.35, 2], TOWERS_CRATE), - towersCrate('Cube 6', 0xa86d3c, [0.6, 0.6, 0.6], [2, 0.35, 2], TOWERS_CRATE), - // height rings over the pad — thin glowing rings a rising crate trips (the sensor is - // the ring's box, so the hole still counts); select-through, so a click reaches the - // tower inside. The top ring is gold: the one worth a sparkle. - { type: 'torus', name: 'Height ring 1m', color: 0xbff4ff, r: 0.9, tube: 0.035, pos: [0, 1, 0], rot: [-Math.PI / 2, 0, 0], emissive: 0x49d2ff, emissiveIntensity: 2.2, shadow: false, pick: 'through', physics: { mode: 'static', sensor: true, collider: 'box' } }, - { type: 'torus', name: 'Height ring 2m', color: 0xbff4ff, r: 0.9, tube: 0.035, pos: [0, 2, 0], rot: [-Math.PI / 2, 0, 0], emissive: 0x49d2ff, emissiveIntensity: 2.2, shadow: false, pick: 'through', physics: { mode: 'static', sensor: true, collider: 'box' } }, - { type: 'torus', name: 'Height ring 3m', color: 0xbff4ff, r: 0.9, tube: 0.035, pos: [0, 3, 0], rot: [-Math.PI / 2, 0, 0], emissive: 0x49d2ff, emissiveIntensity: 2.2, shadow: false, pick: 'through', physics: { mode: 'static', sensor: true, collider: 'box' } }, - { type: 'torus', name: 'Height ring 4m', color: 0xfff0b8, r: 0.9, tube: 0.04, pos: [0, 4, 0], rot: [-Math.PI / 2, 0, 0], emissive: 0xffc640, emissiveIntensity: 2.6, shadow: false, pick: 'through', physics: { mode: 'static', sensor: true, collider: 'box' } }, - // the measuring pole beside the pad: one bright mark per metre, lined up with a ring - { type: 'cylinder', name: 'Height pole', color: 0xe8edf3, r: 0.04, h: 4.3, pos: [2.2, 2.15, 0], physical: true, metalness: 0.6, roughness: 0.3, shadow: false }, - { type: 'torus', name: 'Pole mark 1m', color: 0xbff4ff, r: 0.1, tube: 0.025, pos: [2.2, 1, 0], rot: [-Math.PI / 2, 0, 0], emissive: 0x49d2ff, emissiveIntensity: 2.4, shadow: false }, - { type: 'torus', name: 'Pole mark 2m', color: 0xbff4ff, r: 0.1, tube: 0.025, pos: [2.2, 2, 0], rot: [-Math.PI / 2, 0, 0], emissive: 0x49d2ff, emissiveIntensity: 2.4, shadow: false }, - { type: 'torus', name: 'Pole mark 3m', color: 0xbff4ff, r: 0.1, tube: 0.025, pos: [2.2, 3, 0], rot: [-Math.PI / 2, 0, 0], emissive: 0x49d2ff, emissiveIntensity: 2.4, shadow: false }, - { type: 'torus', name: 'Pole mark 4m', color: 0xfff0b8, r: 0.1, tube: 0.03, pos: [2.2, 4, 0], rot: [-Math.PI / 2, 0, 0], emissive: 0xffc640, emissiveIntensity: 2.6, shadow: false }, - // the stars — faceted glowing collectibles at climbing heights (touch pickups) - { type: 'dodecahedron', name: 'Star 1', color: 0xffc640, r: 0.24, pos: [-3.5, 2.4, 3.5], emissive: 0xffb830, emissiveIntensity: 1.6, physical: true, roughness: 0.25, metalness: 0.1, clearcoat: 1, flatShading: true }, - { type: 'dodecahedron', name: 'Star 2', color: 0xffc640, r: 0.24, pos: [3.5, 3.2, -3.5], emissive: 0xffb830, emissiveIntensity: 1.6, physical: true, roughness: 0.25, metalness: 0.1, clearcoat: 1, flatShading: true }, - { type: 'dodecahedron', name: 'Star 3', color: 0xffc640, r: 0.24, pos: [0, 4.6, 0], emissive: 0xffb830, emissiveIntensity: 1.6, physical: true, roughness: 0.25, metalness: 0.1, clearcoat: 1, flatShading: true }, - // the card's camera: a 3/4 view over the pad with the podiums either side - { type: 'camera', name: 'Card camera', pos: [8.2, 6, 10.2], lookAt: [0, 1.5, 0], fov: 45 } + // ZONE 2 — the narrow pedestal (level 7), north-west + { type: 'box', name: 'Pedestal', color: 0x6a7382, size: [0.8, 0.8, 0.8], bevel: 0.05, bevelSegments: 1, pos: [-6, 0.4, -4.5], physical: true, roughness: 0.5, clearcoat: 0.4, physics: { mode: 'static', friction: 1 } }, + { type: 'box', name: 'Pedestal cap', color: 0x9fe8ff, size: [0.84, 0.03, 0.84], pos: [-6, 0.815, -4.5], emissive: 0x4fcfff, emissiveIntensity: 1.6, shadow: false, pick: 'through', physics: { mode: 'static', sensor: true } }, + // ZONE 3 — the wobble plate (level 10), north-east: it ROCKS through the module's effect + { type: 'cone', name: 'Wobble pivot', color: 0x59616e, r: 0.5, h: 0.2, pos: [6, 0.1, -4.5], roughness: 0.5, physics: { mode: 'static' } }, + { type: 'box', name: 'Wobble plate', color: 0x3d7f6e, size: [2.2, 0.2, 2.2], bevel: 0.05, bevelSegments: 1, pos: [6, 0.25, -4.5], emissive: 0x2a8f6e, emissiveIntensity: 0.5, physical: true, roughness: 0.4, clearcoat: 0.5, physics: { mode: 'static', friction: 1 } }, + // the SUPPLY: two low racks either side of the spawn (top 0.8), a high ledge north of the + // pad (top 3.0 — out of reach from the floor) and a star perch (top 3.4) + { type: 'box', name: 'Rack west', color: 0x6b5a48, size: [1.6, 0.8, 4], bevel: 0.05, bevelSegments: 1, pos: [-4.4, 0.4, 2.2], ...TOWERS_WOOD, physics: { mode: 'static', friction: 0.9 } }, + { type: 'box', name: 'Rack east', color: 0x6b5a48, size: [1.6, 0.8, 4], bevel: 0.05, bevelSegments: 1, pos: [4.4, 0.4, 2.2], ...TOWERS_WOOD, physics: { mode: 'static', friction: 0.9 } }, + { type: 'box', name: 'High ledge', color: 0x7a8494, size: [3, 0.3, 0.9], bevel: 0.05, bevelSegments: 1, pos: [0, 2.85, -3.0], physical: true, roughness: 0.5, clearcoat: 0.3, physics: { mode: 'static', friction: 0.9 } }, + { type: 'cylinder', name: 'Ledge post west', color: 0x5d6879, r: 0.09, h: 2.7, pos: [-1.35, 1.35, -3.0], physical: true, metalness: 0.3, roughness: 0.4, physics: { mode: 'static' } }, + { type: 'cylinder', name: 'Ledge post east', color: 0x5d6879, r: 0.09, h: 2.7, pos: [1.35, 1.35, -3.0], physical: true, metalness: 0.3, roughness: 0.4, physics: { mode: 'static' } }, + { type: 'box', name: 'Star perch', color: 0x7a8494, size: [0.7, 0.2, 0.7], bevel: 0.04, bevelSegments: 1, pos: [2.4, 3.3, -2.2], physical: true, roughness: 0.5, physics: { mode: 'static', friction: 1 } }, + { type: 'cylinder', name: 'Perch post', color: 0x5d6879, r: 0.06, h: 3.2, pos: [2.4, 1.6, -2.2], physical: true, metalness: 0.3, roughness: 0.4, physics: { mode: 'static' } }, + // the MOVING markers — the module puts them on the level's zone (a sensor each, so none + // is a wall: every top-level object becomes a fixed body at sim start) + { type: 'torus', name: 'Goal ring', color: 0xfff0b8, r: 0.95, tube: 0.045, pos: [0, 1.8, 0], rot: [-Math.PI / 2, 0, 0], emissive: 0xffc640, emissiveIntensity: 2.8, shadow: false, pick: 'through', physics: { mode: 'static', sensor: true } }, + { type: 'ring', name: 'Yard ring', color: 0xbff4ff, r: 3.4, inner: 3.3, pos: [0, 0.02, 0], rot: [-Math.PI / 2, 0, 0], emissive: 0x49d2ff, emissiveIntensity: 1.2, shadow: false, side: 'double', pick: 'through', physics: { mode: 'static', sensor: true } }, + { type: 'box', name: 'Ghost wall', color: 0x9fe8ff, size: [1.8, 1.2, 0.62], pos: [0, -9, 0], emissive: 0x49d2ff, emissiveIntensity: 0.9, opacity: 0.22, shadow: false, pick: 'through', physics: { mode: 'static', sensor: true } }, + // the measuring pole beside the pad: a bright mark per metre + { type: 'cylinder', name: 'Height pole', color: 0xe8edf3, r: 0.04, h: 4.3, pos: [2.2, 2.15, 0.6], physical: true, metalness: 0.6, roughness: 0.3, shadow: false, physics: { mode: 'static' } }, + ...[1, 2, 3, 4].map((m) => ({ type: 'torus', name: 'Pole mark ' + m + 'm', color: 0xbff4ff, r: 0.1, tube: 0.025, pos: [2.2, m, 0.6], rot: [-Math.PI / 2, 0, 0], emissive: 0x49d2ff, emissiveIntensity: 2.4, shadow: false, physics: { mode: 'static', sensor: true } })), + // the piece TEMPLATES, parked on a vault slab far under the floor + { type: 'box', name: 'Template vault', color: 0x333333, size: [18, 0.5, 3], pos: [1, VAULT_TOP - 0.25, 0], shadow: false, physics: { mode: 'static', friction: 1 } }, + ...TOWERS_TEMPLATES, + // the card's camera: a 3/4 view over the pad, the ledge and a rack + { type: 'camera', name: 'Card camera', pos: [6.6, 4.9, 8.2], lookAt: [0, 1.3, -0.9], fov: 45 } ] }; @@ -833,6 +698,42 @@ function starsGraph() { 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' }); + // ---- 31: the player's two settings (the game's Settings — desktop and the VR menu — + // through 31-game-shell's panel; Stars' own P menu carries the same two as toggles) ----- + N('setpoint', 'gamesetting', 'Setting: point to move stars', 3700, 40, { setting: 'stars-point-grab', title: 'Point to move stars', kind: 'toggle', value: true }); + N('setclap', 'gamesetting', 'Setting: make stars with a clap', 3700, 200, { setting: 'stars-clap', title: 'Make stars with a clap', kind: 'toggle', value: true }); + // S2 — OFF: the VR grip ray and the desktop carry take nothing; a hand still knocks + N('pointgrab', 'pointgrab', 'Pointing moves stars', 3940, 40, {}); + E('setpoint', 'pointgrab', 'enabled'); + // S3 — a CLAP (both hands together, held a quarter second) makes a star between them, + // with a sparkle, a portal sound and a buzz in both hands. The pulse replicates with the + // point, so the host's spawner makes ONE star there for everyone; the buzz is `me` only. + N('clap', 'onclap', 'Clap: a new star', 3940, 200, { who: 'anyone', distance: 0.1, hold: 0.25, cooldown: 1, pulse: 0.3 }); + E('setclap', 'clap', 'enabled'); + N('clapme', 'onclap', 'My clap', 3940, 360, { who: 'me', distance: 0.1, hold: 0.25, cooldown: 1, pulse: 0.3 }); + E('setclap', 'clapme', 'enabled'); + // at most 40 clapped stars; the 41st recycles the oldest (the spawner's own rule) + N('clapspawn', 'spawn', 'Spawn a star at the clap', 4180, 200, { x: 0, y: 0, z: 0, count: 1, maxAlive: 40, interval: 0, spread: 0 }); + E('clap', 'clapspawn', 'trigger'); + E('clap', 'clapspawn', 'position', 'point'); + N('clapfx', 'effectburst', 'Clap sparkle', 4180, 280, { kind: 'sparkle', count: 72, lift: 0, color: '#ffe08a' }); + E('clap', 'clapfx', 'trigger'); + E('clap', 'clapfx', 'at', 'point'); + N('clapfx2', 'effectburst', 'Clap sparks', 4180, 340, { kind: 'sparks', count: 36, lift: 0, color: '' }); + E('clap', 'clapfx2', 'trigger'); + E('clap', 'clapfx2', 'at', 'point'); + N('clapsnd', 'gamesound', 'Clap: portal', 4420, 280, { sound: 'portal' }); + E('clap', 'clapsnd', 'trigger'); + E('clap', 'clapsnd', 'at', 'point'); + N('clapbuzz', 'hapticpulse', 'Clap: buzz both hands', 4180, 400, { pattern: 'success', hand: 'both' }); + E('clapme', 'clapbuzz', 'trigger'); + // S4 — a clapped star (and a More-stars copy) is a copy of the TEMPLATE, and a copy answers + // to its template's selector: a knock rings it and counts as one of MY touches, like any + // star. Unplaced sound (the template itself waits under the floor). + N('tplhit', 'onhit', 'A new star hit', 3940, 520, { pulse: 0.3, minSpeed: 0.3, who: 'anyone' }); + N('tplme', 'onhit', 'A new star, my hit', 3940, 600, { pulse: 0.3, minSpeed: 0.3, who: 'me' }); + N('tplsnd', 'gamesound', 'New star: ring', 4180, 520, { sound: 'ring' }); + E('tplhit', 'tplsnd', 'trigger'); 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'); @@ -842,8 +743,12 @@ function starsGraph() { E('bmore', 'spawn', 'trigger'); E('padmore', 'spawn', 'trigger'); E('seltpl', 'spawn', 'source'); + E('seltpl', 'clapspawn', 'source'); // 31 + E('tplhit', 'seltpl'); + E('tplme', 'seltpl'); // ---- 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' }); + E('tplme', 'touch', 'trigger'); // 31: a new star's hit is a touch too 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'); @@ -1019,7 +924,7 @@ const STARS_DEF = { slug: 'stars-room', title: 'Stars Room', description: - 'A zero-gravity glass room full of glowing crystal stars. Knock them with your hands in VR or walk into them. Start a round to light every star against the clock, or just play.', + 'A zero-gravity glass room full of glowing crystal stars. Knock them with your hands in VR or walk into them, clap to make a new one. Start a round to light every star against the clock, or just play.', license: 'CC0-1.0', author: 'theprototype', tags: ['zero-g', 'physics', 'sandbox', 'vr'], @@ -1043,7 +948,14 @@ const STARS_DEF = { damping: { linear: 0.35, angular: 0.2 }, ccd: false, // 30b: a SPAWN just inside the south glass, facing the stars (yaw 0 faces -Z) - play: { interaction: 'grab', grounded: false, simOnPlay: true, spawn: { position: [0, 0, 5.2], yaw: 0 } }, + // 31 (S1): TELEPORT, bounded to the room — `play.bounds` is the glass box's inside (the + // walls' inner faces sit at ±5.75), so the arc can never land you outside the scene + // (31-vr-core's bounded teleport; K1) + play: { + interaction: 'grab', grounded: false, simOnPlay: true, spawn: { position: [0, 0, 5.2], yaw: 0 }, + locomotion: { teleport: true }, + bounds: { min: [-5.55, -0.1, -5.55], max: [5.55, 6.9, 5.55] } + }, knock: { enabled: true, gain: 1, maxSpeed: 10, radius: 0.12, spin: 0.6 } }, post: { @@ -1081,7 +993,7 @@ const STARS_DEF = { { id: 'go-btn', kind: 'button', anchor: 'center', x: 0, y: 12, w: 260, h: 48, z: 1, label: 'Start round', enabled: true, style: { ...STARS_BTN, size: 17 } }, { id: 'free-btn', kind: 'button', anchor: 'center', x: 0, y: 70, w: 260, h: 42, z: 1, label: 'Free play', enabled: true, style: STARS_QUIET }, { id: 'start-hint', kind: 'text', anchor: 'center', x: 0, y: 128, w: 420, h: 22, z: 1, label: 'Light all 24 in two minutes · P: menu', style: { size: 12, color: '#8b97a8', align: 'center' }, wrap: true }, - { id: 'start-hint-vr', kind: 'text', anchor: 'center', x: 0, y: 156, w: 420, h: 22, z: 1, label: 'VR: left stick walks · grip grabs · Y switches to Edit mode', style: { size: 12, color: '#8b97a8', align: 'center' }, wrap: true } + { id: 'start-hint-vr', kind: 'text', anchor: 'center', x: 0, y: 156, w: 440, h: 22, z: 1, label: 'VR: stick walks or teleports · clap for a new star · Y: Edit mode', style: { size: 12, color: '#8b97a8', align: 'center' }, wrap: true } ] }, { @@ -1117,7 +1029,7 @@ const STARS_DEF = { 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: '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. Clap to make a star.', 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' } }, @@ -1858,7 +1770,18 @@ const JAM_DEF = { // cockpit, where the controller tip reaches the piano, the drums, the sampler, the // transport and the mixer; a desktop keeps the overview it always had (a level eye at the // cockpit would see only the piano). - physics: { play: { cursor: 'free', simOnPlay: false, spawn: { position: JAM_SPAWN, yaw: 0, vrOnly: true } } }, + // 31 (J1): the user, on a Quest 3: "In Jam Room I would like to be able to fly around and + // also scale the entire environment with grips and move around same as in edit mode". So the + // game FLIES (no gravity), the grips move/rotate/SCALE the world when they do not close on a + // device (worldGrab), and teleport lands inside the studio (`bounds` = inside the walls, + // the open front included) — all K1 flags, 31-vr-core's to implement. + physics: { + play: { + cursor: 'free', simOnPlay: false, spawn: { position: JAM_SPAWN, yaw: 0, vrOnly: true }, + locomotion: { fly: true, worldGrab: true, teleport: true }, + bounds: { min: [-4.7, -0.1, -6.6], max: [6.9, 3.3, 3.1] } + } + }, // 30b: NO Game Music node, on purpose — the room IS the music (the transport, the drums and // the piano are what you hear), so a procedural `studio` loop would only fight the band. post: { @@ -2374,6 +2297,11 @@ const DEFS = [ // reflection flips handedness, and every primitive here is symmetric about its own // axes, so the reflected shape is the same primitive posed (rx, -ry, -rz); a spline // reflects its points. A ghost's material is faded and it casts no shadow. + // 31-towers: the app's own BUILDING BLOCKS (Wedge / Arch / Corner / Stairs) — the same + // builders `/create Wedge` uses, fetched only when a def asks for one + const blockBuilders = d.objects.some((/** @type {any} */ o) => o.type === 'block') + ? (await import('/src/lib/customGeometries.js')).customGeometryBuilders + : null; /** @param {any} o @param {{mirror?: boolean, prefix?: string, opacity?: number, shadow?: boolean}} [opts] */ const build = (o, opts = {}) => { const mirror = !!opts.mirror; @@ -2473,6 +2401,13 @@ const DEFS = [ else if (o.type === 'icosahedron') geo = new T.IcosahedronGeometry(o.r, o.detail ?? 0); else if (o.type === 'dodecahedron') geo = new T.DodecahedronGeometry(o.r, o.detail ?? 0); else if (o.type === 'cone') geo = new T.ConeGeometry(o.r, o.h, 24); + // 31-towers: a building block, bottom-anchored like `/create Wedge` (origin on the floor) + else if (o.type === 'block') { + const make = blockBuilders?.[o.shape]; + if (!make || !['Wedge', 'Arch', 'Corner', 'Stairs'].includes(o.shape)) + throw new Error('object "' + o.name + '": unknown block "' + o.shape + '" (Wedge | Arch | Corner | Stairs)'); + geo = make(...(o.args ?? [])); + } else throw new Error('object "' + o.name + '": unknown type "' + o.type + '"'); // 30 author-kit: MeshPhysicalMaterial when a def asks for `physical` or uses any // field only it has; MeshToonMaterial for `toon`. Absent all of those it is the @@ -2932,6 +2867,10 @@ const DEFS = [ if (live) clone.add(live.clone(true)); else console.log(' WARN thumb.sceneGroups: no scene-root group named ' + name); } + // 31-towers: CARD-ONLY dressing — objects built for the picture and never saved + // (the scene was exported above). A Towers level deals its pieces at play time, so + // an honest card of the FILE is an empty arena; the card shows a round in progress. + for (const o of d.thumb?.dress ?? []) clone.add(build(o)); // camera markers are chrome, not scenery clone.traverse((/** @type {any} */ n) => { if (n.userData?.camera) n.visible = false; diff --git a/scripts/perf-games.cjs b/scripts/perf-games.cjs new file mode 100644 index 00000000..b4d01ff0 --- /dev/null +++ b/scripts/perf-games.cjs @@ -0,0 +1,466 @@ +#!/usr/bin/env node +// 31-perf P0 — THE PERF PROBE: the roadmap-31 Performance-protocol table for the seven +// Games-tab games, measured the same way every time so a before/after means something. +// +// node scripts/perf-games.cjs [--label baseline-1.17] [--only waves,football] [--vr] +// [--seconds 10] [--throttle 4] [--out ] +// +// Per game, on a FRESH page against a running dev server (APP_URL, default this lane's +// https://theprototype.app:5263/): +// 1 install the game's modules from their REAL zips (the sibling modules checkout, or +// PERF_MODULES_DIR), through the Modules manager's own zip input +// 2 load the game's REAL .tpscene through the Games-tab path (`importSessionZip` + +// `requestLoadSession`, what `sceneTemplates.loadRemoteScene` runs) — read from the +// scenes repo at a git REF (PERF_SCENES_REF, default `preview-1-17`: the games as 1.17 +// ships them) or a directory (PERF_SCENES_DIR) +// 3 press the real Play button, then the game's own HUD Play/Start button when it has one +// (else the shell is set to `playing`), and with `--vr` a fake XR session walks the left +// stick forward the whole time (tests/e2e/fakeXR.cjs — the real per-frame VR path) with +// the post stack + AO off, the nearest desktop analogue of a headset's direct render (a +// fake session cannot present, so it is still ONE eye at 1280x720 — not a Quest number) +// 4 settle 2 s, then CPU-throttle x4 (CDP `Emulation.setCPUThrottlingRate`) for the +// measured window and record: every render() call's draw calls + triangles (summed per +// display frame), every rAF frame's duration, and the JS heap before/after +// 5 read the scene: geometries/textures (renderer.info.memory), a texture-MB estimate +// (unique textures, w*h*4 x 4/3 for mips), lights, shadow-casting lights, shadow-casting +// meshes, meshes / instanced meshes / meshes with frustumCulled off, shader programs +// +// Writes `perf-