diff --git a/scripts/author-templates.cjs b/scripts/author-templates.cjs index 97bcf029..fcf47b31 100644 --- a/scripts/author-templates.cjs +++ b/scripts/author-templates.cjs @@ -46,6 +46,7 @@ // // DEF (the file / the card): // kind* 'template' | 'example' | 'game' | 'contest' — decides the folder + index section +// seed false keeps a template out of the bundled offline seed (30c: kit levels need the pack CDN) // slug* title* description identity + card text; author, license ('CC0-1.0'), tags [] // modules [{id, version}] — the card's module list (games only; must match installModules) // installModules [''] — zips installed from MODULES_REPO before the build (the game shows) @@ -86,6 +87,11 @@ // spline points [{pos, radius}], closed, color // group / empty children [objects] (names resolve inside groups too) // mirror of (a named object/group), opacity (0.15), prefix — reflected across x = 0 +// kit pack*, item* (a pack item NAME, e.g. 'architecture-kit' / 'WallStone'), scale? +// ([x,y,z] or a number) — a PACK PIECE as a reference (30c packRefs.js): written +// as a stub the app refills from PACKS_BASE, so a level of 100 pieces stays small. +// Kept TOP-LEVEL (physics only reads top-level objects); `physics` sets its +// collider (custom compound boxes/wedges for doorways, stairs, trees) // MATERIAL (every mesh type): color, roughness (0.85), metalness (0), emissive + // emissiveIntensity (1), opacity (< 1 → transparent), flatShading, side ('double' | 'back'), // toon (MeshToonMaterial), physical (MeshPhysicalMaterial — also implied by any of: @@ -2014,7 +2020,10 @@ const DEFS = [ MIRROR_DEF, BEAT_DEF, // the def is the module's (see moduleDef); a checkout without it cannot author it - ...MODULE_DEFS.map((id) => moduleDef(id) ?? { slug: id, missingModuleDef: true }) + ...MODULE_DEFS.map((id) => moduleDef(id) ?? { slug: id, missingModuleDef: true }), + // 30c level design: three walkable GENERAL templates built from the kits (seed: false — + // kit references need the pack CDN); the layouts live in level-templates.cjs + ...require('./level-templates.cjs').LEVEL_DEFS ]; (async () => { @@ -2116,6 +2125,28 @@ const DEFS = [ const s = window.__stores; const T = s.THREE; s.commandsHandler.sceneCommand('/clear all'); + // 30c: KIT pieces name a pack item; resolve each to its file path through the pack's + // own item list (default.json) on PACKS_BASE, once per pack, before anything builds + /** @type {Record>} */ + const kitFiles = {}; + /** @param {any[]} list @param {Set} sink */ + const kitPacks = (list, sink) => { + for (const o of list ?? []) { + if (o.type === 'kit') sink.add(o.pack); + if (o.children) kitPacks(o.children, sink); + } + return sink; + }; + for (const pack of kitPacks(d.objects, new Set())) { + const base = String(s.packs.PACKS_BASE).replace(/\/+$/, ''); + const res = await fetch(base + '/' + pack + '/default.json'); + if (!res.ok) throw new Error('kit: pack "' + pack + '" is not served at ' + base + ' (HTTP ' + res.status + ')'); + kitFiles[pack] = {}; + for (const row of await res.json()) { + const file = row?.variants?.['glTF-Binary']; + if (row?.name && file) kitFiles[pack][row.name] = pack + '/' + row.name + '/glTF-Binary/' + file; + } + } /** @type {any} */ let group; s.objectsGroup.subscribe((g) => (group = g))(); @@ -2221,6 +2252,18 @@ const DEFS = [ if (o.type === 'group' || o.type === 'empty') { object = new T.Group(); for (const child of o.children ?? []) object.add(build(child, opts)); + } else if (o.type === 'kit') { + // 30c: a hollow STUB — the app's packRefs watcher refills it from the pack + // (awaited below, before the card renders and the file is written) + const path = kitFiles[o.pack]?.[o.item]; + if (!path) throw new Error('kit "' + o.name + '": pack ' + o.pack + ' has no item "' + o.item + '"'); + object = new T.Group(); + object.userData.packRef = { pack: o.pack, item: o.item, path, kids: [] }; + object.userData.packStub = true; + if (o.scale != null) { + const k = Array.isArray(o.scale) ? o.scale : [o.scale, o.scale, o.scale]; + object.scale.set(k[0], k[1], k[2]); + } } else if (o.type === 'light' && o.kind && o.kind !== 'point') { // 30 author-kit: spot / directional / hemisphere. Spot and directional follow // createLight's convention (cast shadows by default, the V-1 bias pair) and aim @@ -2400,6 +2443,18 @@ const DEFS = [ } group.add(build(o)); } + // 30c: refill every kit stub from its pack BEFORE anything measures the scene (the + // shadow fit below, the card) — and refuse to write a level whose pack is unreachable + if (kitPacks(d.objects, new Set()).size) { + s.objectsGroup.update((v) => v); + await new Promise((r) => setTimeout(r, 200)); + await s.packRefs.packRefsSettled(); + const hollow = []; + group.traverse((/** @type {any} */ n) => { + if (n.userData?.packStub) hollow.push(n.name); + }); + if (hollow.length) throw new Error('kit pieces did not load: ' + hollow.slice(0, 5).join(', ')); + } // 30 author-kit: FIT each shadow-casting directional light's ortho frustum to the // meshes just built — the 8 corners of their world box carried into the light's own // frame (it looks down -Z, and the shadow camera is posed from the light toward its @@ -2731,7 +2786,10 @@ const DEFS = [ if (liveScene?.fog) scene.fog = liveScene.fog.clone(); const envRoot = liveScene?.getObjectByName('environment-root'); if (envRoot) scene.add(envRoot.clone(true)); - const clone = new T.ObjectLoader().parse(group.toJSON()); + // 30c: a level of kit pieces is CLONED, not round-tripped — toJSON writes every + // piece's textures as PNG data URLs (7.9 MB for one wall), which is the very cost + // the kit references exist to avoid. Every other def renders exactly as before. + const clone = kitPacks(d.objects, new Set()).size ? group.clone(true) : new T.ObjectLoader().parse(group.toJSON()); scene.add(clone); // 21-C C6-b: a module's WORLD lives at the scene root (golden rule 5), so a // card rendered from objectsGroup alone shows a dungeon template as a lone @@ -2897,7 +2955,9 @@ const DEFS = [ // with a partial one. if (!ONLY) { fs.mkdirSync(STATIC_OUT, { recursive: true }); - const seedTemplates = defs.filter((d) => d.kind === 'template').map((d) => ({ + // 30c: `seed: false` keeps a template OUT of the offline seed — a level built from kit + // references needs the pack CDN, so bundling it offline would promise an empty world + const seedTemplates = defs.filter((d) => d.kind === 'template' && d.seed !== false).map((d) => ({ ...writeDef(STATIC_OUT, d), scene: `/templates/${d.slug}/scene.tpscene`, thumb: built[d.slug].thumb ? `/templates/${d.slug}/thumb.webp` : '' diff --git a/scripts/level-templates.cjs b/scripts/level-templates.cjs new file mode 100644 index 00000000..c280e7ac --- /dev/null +++ b/scripts/level-templates.cjs @@ -0,0 +1,598 @@ +// 30c level design — three GENERAL-tab templates built from the round-3 kits (the +// Modular Architecture Kit, Nature & Terrain, Props & Interiors), authored by +// author-templates.cjs through its `kit` object type (a pack piece as a REFERENCE — core +// src/lib/packRefs.js — so a level of ~150 pieces is a ~100 KB file whose bytes come from +// the pack CDN). +// +// Every level is a PLAYABLE walk: a Character Controller node in walk mode, the simulation +// starting on Play (the walker only collides while a world exists — charController.js), a +// spawn point (scenePhysics play.spawn), and colliders that match the pieces: walls and +// props keep their measured box, and the pieces a box gets wrong take CUSTOM compound +// shapes — a doorway is two jambs and a lintel, a staircase a wedge, a tree its trunk, a +// footbridge its deck and rails. Grass, flowers, rugs and water are sensors (pass-through). +// +// THE GRID (kit.md): walls stand ON grid lines, centred on them, pivot bottom-centre, 2 m +// long; floor tiles fill the 2 × 2 cells; storeys are 3 m. Every position below is on that +// grid unless it is a prop. +// +// Numbers the colliders use were MEASURED from the shipped GLBs (see the handover's +// probe): the arch opening, the footbridge deck profile, the tree trunks, the mound. + +const A = 'architecture-kit'; +const N = 'nature-kit'; +const P = 'props-kit'; +const PI = Math.PI; +const H = PI / 2; + +// ---- colliders ------------------------------------------------------------------------ + +/** A custom compound collider from convex pieces, each a flat vertex list (object-local, + * the root's pivot). @param {number[][][]} pieces each a list of [x,y,z] */ +function compound(pieces, extra = {}) { + /** @type {number[]} */ + const verts = []; + /** @type {number[][]} */ + const ranges = []; + for (const piece of pieces) { + ranges.push([verts.length, piece.length * 3]); + for (const v of piece) verts.push(...v.map((n) => Math.round(n * 1000) / 1000)); + } + if (verts.length > 1200) throw new Error('collider over the 1200-float cap: ' + verts.length); + return { mode: 'static', collider: 'custom', colliderVerts: verts, colliderPieces: ranges, ...extra }; +} + +/** the 8 corners of an axis-aligned box @param {number[]} min @param {number[]} max */ +function box(min, max) { + const out = []; + for (const x of [min[0], max[0]]) for (const y of [min[1], max[1]]) for (const z of [min[2], max[2]]) out.push([x, y, z]); + return out; +} + +/** a ramp rising toward -Z from `z0` (low, y0) to `z1` (high, y1), `w` wide + * @param {number} w @param {number} z0 @param {number} z1 @param {number} y0 @param {number} y1 */ +function wedge(w, z0, z1, y0, y1) { + const x = w / 2; + return [ + [-x, y0, z0], + [x, y0, z0], + [-x, y0, z1], + [x, y0, z1], + [-x, y1, z1], + [x, y1, z1] + ]; +} + +const COLLIDERS = { + // the Arch: 2 × 3 × 0.5, its opening MEASURED at ±0.408 m and 2.05 m high — two jambs + // and a lintel, so the walker passes through the opening and not the stone. The jambs sit + // 6 cm INSIDE the stone: the 0.82 m opening leaves a 0.3 m capsule 11 cm a side, and a walk + // that grazed a jamb corner slid off it and stopped (measured, 1 run in 2); the eye walks the + // centre line, so nothing visibly clips + arch: compound([box([-1, 0, -0.25], [-0.47, 3, 0.25]), box([0.47, 0, -0.25], [1, 3, 0.25]), box([-1, 2.05, -0.25], [1, 3, 0.25])]), + // a doorway wall: kit.md's exact 1.0 × 2.2 m opening, centred, in a 0.25 m wall + doorway: compound([box([-1, 0, -0.125], [-0.5, 3, 0.125]), box([0.5, 0, -0.125], [1, 3, 0.125]), box([-1, 2.2, -0.125], [1, 3, 0.125])]), + // Stairs (2 × 3 × 4, bottom step at +Z): a wedge 36.9° — under the walker's 50° climb + stairs: compound([wedge(2, 2, -2, 0, 3)]), + /** a trunk: a square column of half-width r about (cx, cz), h tall — the canopy stays + * walk-under @param {number} r @param {number} [cx] @param {number} [cz] @param {number} [h] */ + trunk: (r, cx = 0, cz = 0, h = 3) => compound([box([cx - r, 0, cz - r], [cx + r, h, cz + r])]), + /** a sandstone Ramp (2 × 1 × 2, low edge at +Z) squashed to `rise` metres */ + ramp: (/** @type {number} */ rise) => compound([wedge(2, 1, -1, 0, rise)]), + sensor: { sensor: true } +}; + +// the footbridge, MEASURED (the GLB's own vertices): it is walked along its LOCAL Z (2 m), +// its deck a flat 0.85 m up across |x| < 1.45, and its posts and rails fill 1.45 < |x| < 2 +// up to 2.06 m. The deck has no approach of its own (a 0.85 m step either end), so the level +// kitbashes a squashed sandstone Ramp onto each end. +const BRIDGE_DECK = { deckY: 0.85, halfW: 1.45, railY: 2.06 }; +function bridgeCollider() { + const d = BRIDGE_DECK; + return compound([ + box([-d.halfW, 0, -1], [d.halfW, d.deckY, 1]), + box([-2, 0, -1], [-d.halfW, d.railY, 1]), + box([d.halfW, 0, -1], [2, d.railY, 1]) + ]); +} + +// the grassy mound (8 × 1.2 × 8): its convex hull, from MEASURED rings of (radius, height) +// (its mesh ends in a 0.37 m lip, over the walker's 0.3 m step, so the hull's foot runs out +// to 4.3 m at ground level: a 31° slope where the lip is) +const HILL_RINGS = [ + [0, 1.2], + [1.5, 1.15], + [2, 1.01], + [2.5, 0.85], + [3, 0.68], + [3.5, 0.49] +]; +function hillCollider() { + /** @type {number[][]} */ + const verts = []; + for (const [r, y] of HILL_RINGS) { + if (r === 0) { + verts.push([0, y, 0]); + continue; + } + for (let i = 0; i < 12; i++) verts.push([Math.cos((i / 12) * 2 * PI) * r, y, Math.sin((i / 12) * 2 * PI) * r]); + } + verts.push(...[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11].map((i) => [Math.cos((i / 12) * 2 * PI) * 4.3, 0, Math.sin((i / 12) * 2 * PI) * 4.3])); + return compound([verts]); +} + +// the trunks, MEASURED between 0.3 and 1.2 m up (radius about the trunk's own centre, which +// is off the pivot on the pine, the birch and the dead tree); the oak's reading includes its +// root flare, so it takes the trunk proper +/** @type {Record} */ +const TRUNKS = { + Oak: COLLIDERS.trunk(0.6, -0.05, 0.04), + OakAutumn: COLLIDERS.trunk(0.6, -0.05, 0.04), + Pine: COLLIDERS.trunk(0.4, -0.24, -0.14), + Birch: COLLIDERS.trunk(0.25, 0.01, -0.24), + DeadTree: COLLIDERS.trunk(0.4, 0.36, 0.5) +}; + +// ---- helpers -------------------------------------------------------------------------- + +/** a counter per name prefix, so every object has a unique readable name */ +function namer() { + /** @type {Record} */ + const seen = {}; + return (/** @type {string} */ base) => { + seen[base] = (seen[base] ?? 0) + 1; + return base + ' ' + seen[base]; + }; +} + +/** a deterministic PRNG (mulberry32), so a re-author is byte-stable @param {number} seed */ +function rng(seed) { + let a = seed >>> 0; + return () => { + a = (a + 0x6d2b79f5) >>> 0; + let t = a; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +const r3 = (/** @type {number} */ n) => Math.round(n * 1000) / 1000; + +/** one kit piece @param {string} pack @param {string} item @param {string} name + * @param {number[]} pos @param {number} [yaw] @param {any} [extra] */ +function piece(pack, item, name, pos, yaw = 0, extra = {}) { + return { type: 'kit', pack, item, name, pos: pos.map(r3), ...(yaw ? { rot: [0, r3(yaw), 0] } : {}), ...extra }; +} + +/** the walk: ONE Character Controller node in walk mode — the whole of a level's logic */ +function walkGraph() { + return { + nodes: [ + { + id: 'walk', + type: 'charcontroller', + position: { x: 40, y: 40 }, + data: { label: 'Character Controller', mode: 'walk', speed: 0.08, jumpHeight: 1.1, eyeHeight: 1.7, gravity: true }, + class: 'w-[150px]' + } + ], + edges: [] + }; +} + +/** the shared play physics: ground on, respawn below, grab props, the sim starts on Play so + * the walker has a world to collide with; `spawn` = where desktop play starts */ +function playPhysics(/** @type {number[]} */ spawn, /** @type {number} */ yaw) { + return { + ground: { enabled: true, height: 0, friction: 0.8, restitution: 0 }, + bounds: { limit: -20, action: 'respawn' }, + damping: { linear: 0.1, angular: 0.4 }, + play: { interaction: 'grab', grounded: false, simOnPlay: true, spawn: { pos: spawn, yaw } } + }; +} + +/** the house look: AO, AgX, a soft bloom (flames, lanterns), SMAA */ +function lookPost(bloom = 0.6) { + return { + enabled: true, + effects: [ + { id: 'ao', kind: 'ao', enabled: true, params: {} }, + { id: 'tone', kind: 'tonemapping', enabled: true, params: { mode: 'AGX' } }, + { id: 'bloom', kind: 'bloom', enabled: true, params: { intensity: bloom, luminanceThreshold: 0.85 } }, + { id: 'aa', kind: 'smaa', enabled: true, params: {} } + ], + changedAt: 0 + }; +} + +const DYNAMIC = (/** @type {number} */ mass) => ({ mode: 'dynamic', mass }); + +// ---- 1. Castle Courtyard --------------------------------------------------------------- +// +// A 16 × 20 m walled courtyard: curtain walls on the grid lines x = ±8, z = 6 and z = -14, +// round towers three storeys tall on the two north corners, a rampart terrace (a stone +// platform 3 m up behind the north wall, battlements for a parapet) reached by a staircase +// through an archway, a paved avenue from the gate to the well, a market stall with its +// crates and sacks, and trees on the earth either side. The gate (oak double doors between +// two pillars) is behind you when you spawn; you look north down the avenue. +function castleObjects() { + const n = namer(); + /** @type {any[]} */ + const o = []; + // curtain walls ------------------------------------------------------------------ + for (const x of [-5, -3, -1, 1, 3, 5]) o.push(piece(A, 'WallStone', n('North wall'), [x, 0, -14])); + for (const z of [-11, -9, -7, -5, -3, -1, 1, 3, 5]) { + o.push(piece(A, 'WallStone', n('West wall'), [-8, 0, z], H)); + // the east wall has one DOOR (to a storeroom — the Door leaf is shut) + if (z === -7) { + o.push(piece(A, 'WallStoneDoor', 'Storeroom doorway', [8, 0, z], -H, { physics: COLLIDERS.doorway })); + o.push(piece(A, 'Door', 'Storeroom door', [8, 0, z], -H)); + } else o.push(piece(A, 'WallStone', n('East wall'), [8, 0, z], H)); + } + for (const x of [-7, -5, -3, -1, 3, 5, 7]) o.push(piece(A, 'WallStone', n('South wall'), [x, 0, 6])); + // the gate: oak double doors in a 2 m gap, a pillar either side, a battlement over it + o.push(piece(A, 'Gate', 'Castle gate', [1, 0, 6])); + o.push(piece(A, 'Pillar', n('Gate pillar'), [0, 0, 6])); + o.push(piece(A, 'Pillar', n('Gate pillar'), [2, 0, 6])); + o.push(piece(A, 'CornerPostStone', n('Corner post'), [-8, 0, 6])); + o.push(piece(A, 'CornerPostStone', n('Corner post'), [8, 0, 6])); + // battlements along every wall top (the north ones are the rampart's parapet) + for (const x of [-5, -3, -1, 1, 3, 5]) o.push(piece(A, 'Battlement', n('Battlement'), [x, 3, -14])); + for (const z of [-11, -9, -7, -5, -3, -1, 1, 3, 5]) { + o.push(piece(A, 'Battlement', n('Battlement'), [-8, 3, z], H)); + o.push(piece(A, 'Battlement', n('Battlement'), [8, 3, z], H)); + } + for (const x of [-7, -5, -3, -1, 1, 3, 5, 7]) o.push(piece(A, 'Battlement', n('Battlement'), [x, 3, 6])); + // two round towers, three storeys, on the north corners + for (const x of [-8, 8]) for (const y of [0, 3, 6]) o.push(piece(A, 'Tower', n(x < 0 ? 'West tower' : 'East tower'), [x, y, -14])); + // the rampart: a solid platform 3 m up behind the north wall (three courses of blocks) + for (const x of [-5, -3, -1, 1, 3, 5]) for (const y of [0, 1, 2]) o.push(piece(A, 'Block', n('Rampart block'), [x, y, -13])); + // its railing along the courtyard edge — open at x = -5, where the stairs arrive + for (const x of [-3, -1, 1, 3, 5]) o.push(piece(A, 'Railing', n('Rampart railing'), [x, 3, -12.08])); + // the staircase up to it, entered through an archway + o.push(piece(A, 'Stairs', 'Rampart stairs', [-5, 0, -10], 0, { physics: COLLIDERS.stairs })); + o.push(piece(A, 'Arch', 'Stair archway', [-5, 0, -7], 0, { physics: COLLIDERS.arch })); + // the avenue: flagstones from the gate to the rampart + for (const x of [-3, -1, 1, 3]) for (const z of [5, 3, 1, -1, -3, -5, -7, -9, -11]) o.push(piece(A, 'FloorStone', n('Flagstone'), [x, 0, z])); + // the well in the middle of the avenue + o.push(piece(P, 'Well', 'Well', [0, 0.1, -4])); + // the market on the east side, facing the avenue + o.push(piece(P, 'MarketStall', 'Market stall', [6, 0, -2], -H)); + o.push(piece(P, 'CrateStack', 'Crate stack', [6.4, 0, 1.4], -H)); + o.push(piece(P, 'Crate', n('Crate'), [4.9, 0, 0.9], 0.3, { physics: DYNAMIC(15) })); + o.push(piece(P, 'CrateTeal', n('Crate'), [5.1, 0, -4.8], -0.2, { physics: DYNAMIC(12) })); + o.push(piece(P, 'Barrel', n('Barrel'), [6.9, 0, -4.6], 0, { physics: DYNAMIC(30) })); + o.push(piece(P, 'BarrelSmall', n('Barrel'), [6.3, 0, -5.4], 0, { physics: DYNAMIC(12) })); + o.push(piece(P, 'Sacks', 'Grain sacks', [6.7, 0, 3.5], -2.4)); + o.push(piece(P, 'Lantern', n('Lantern'), [6.4, 1.05, 1.1], 0, { physics: COLLIDERS.sensor })); + o.push(piece(P, 'Signpost', 'Signpost', [3.3, 0, 4.4], -0.5)); + o.push(piece(P, 'Chest', 'Treasure chest', [5, 0, -11], -0.3)); + // wall torches on the inner faces (a wall face is 0.125 off its grid line) + for (const z of [-9, 3]) o.push(piece(P, 'WallTorch', n('Wall torch'), [7.875, 1.5, z], -H, { physics: COLLIDERS.sensor })); + for (const z of [-1, -9]) o.push(piece(P, 'WallTorch', n('Wall torch'), [-7.875, 1.5, z], H, { physics: COLLIDERS.sensor })); + // the west garden: trees, a bench, bushes, flowers on the earth + o.push(piece(N, 'Oak', 'Courtyard oak', [-6, 0, 3], 0.7, { physics: TRUNKS.Oak })); + o.push(piece(N, 'Birch', 'Courtyard birch', [-6.2, 0, -3.5], 2.1, { physics: TRUNKS.Birch })); + o.push(piece(P, 'Bench', 'Garden bench', [-5, 0, 0.2], H)); + for (const [x, z, yaw] of [[-7.1, 0.6, 0.3], [-7.2, -5.6, 1.9], [4.9, 4.9, 0.8]]) o.push(piece(N, 'Bush', n('Bush'), [x, 0, z], yaw)); + for (const [x, z] of [[-4.9, 4.6], [-6.9, -1.4], [4.8, -8.6]]) o.push(piece(N, 'FlowerPatch', n('Flowers'), [x, 0, z], x, { physics: COLLIDERS.sensor })); + const rand = rng(1701); + for (let i = 0; i < 14; i++) { + const west = i % 2 === 0; + const x = west ? -7.3 + rand() * 2.8 : 4.6 + rand() * 2.8; + const z = west ? -6 + rand() * 10.5 : -9 + rand() * 13.5; + o.push(piece(N, i % 3 ? 'GrassTuft' : 'GrassTuftDry', n('Grass'), [x, 0, z], rand() * 6, { physics: COLLIDERS.sensor })); + } + // pines outside the walls, so the skyline is not empty + for (const [x, z, s] of [[-12, -18, 1.1], [-14, -9, 1], [-13, 2, 0.9], [12.5, -19, 1.05], [14, -6, 1.1], [13, 4, 0.95], [-3, -20, 1.2], [4, -21, 1]]) + o.push(piece(N, 'Pine', n('Pine'), [x, 0, z], x * 0.37, { scale: s, physics: TRUNKS.Pine })); + return o; +} + +const CASTLE_DEF = { + kind: 'template', + seed: false, + slug: 'castle-courtyard', + title: 'Castle Courtyard', + description: + 'A walled courtyard built from the kits: towers, a gate, a rampart up a stair through an archway, a market stall and a well. Press Play to walk it.', + author: 'theprototype', + license: 'CC0-1.0', + tags: ['level design', 'kits', 'castle', 'walkable'], + objects: castleObjects(), + // a lit afternoon: a high warm sun from the south-west, a blue sky fading to haze, the + // packed-earth ground, and a far fog so the horizon softens + env: { + preset: 'daylight', + exposure: 1.05, + background: { top: '#4f86c6', bottom: '#dfe8ee' }, + fog: { color: '#dfe8ee', near: 35, far: 130 }, + ground: { color: '#7a6446', roughness: 0.95 }, + sun: { color: '#fff1d6', intensity: 2.6, dir: [-0.55, 0.62, 0.55] }, + hemi: { sky: '#d3e6ff', ground: '#7a6446', intensity: 1.25 } + }, + // just inside the gate, looking up the avenue at the well and the rampart + physics: playPhysics([1, 0.3, 4.6], 0), + post: lookPost(0.5), + graphs: { scene: walkGraph() }, + view: { pos: [9.5, 9, 13], target: [0, 2, -5] }, + thumb: {} +}; + +// ---- 2. Forest Clearing ---------------------------------------------------------------- +// +// A clearing in a ring of trees under a cliff: a stone path runs north from where you stand, +// turns east over a footbridge where a pond narrows, and ends at a small plaster hut built +// from the architecture kit (four walls, a doorway, a window, a slate roof). A mound to the +// west is walkable; logs, a stump, mushrooms, ferns and flowers dress the floor. +function forestObjects() { + const n = namer(); + /** @type {any[]} */ + const o = []; + // the path north, then east over the bridge to the hut + for (const z of [4, 2, 0, -2, -4, -6]) o.push(piece(N, 'PathTile', n('Path'), [0, 0, z], 0)); + // the crossing, west to east: a ramp up, the footbridge (turned so its walk runs along X), + // a ramp down — the Ramp is architecture-kit sandstone squashed to the deck's 0.85 m + o.push(piece(A, 'Ramp', n('Bridge ramp'), [2, 0, -6], -H, { scale: [1, 0.85, 1], physics: COLLIDERS.ramp(1) })); + o.push(piece(N, 'Bridge', 'Footbridge', [4, 0, -6], H, { physics: bridgeCollider() })); + o.push(piece(A, 'Ramp', n('Bridge ramp'), [6, 0, -6], H, { scale: [1, 0.85, 1], physics: COLLIDERS.ramp(1) })); + o.push(piece(N, 'PathTile', n('Path'), [8, 0, -6], 0)); + // the pond: its body to the north-east, a narrow neck running under the bridge + o.push(piece(N, 'PondWater', 'Pond', [4.8, 0.02, -10.8], 0.35, { scale: 1.7, physics: COLLIDERS.sensor })); + o.push(piece(N, 'PondWater', 'Pond neck', [4, 0.025, -6], H, { scale: [1.5, 1, 0.85], physics: COLLIDERS.sensor })); + for (const [x, z, yaw] of [[1.3, -8.4, 0.2], [7.6, -11.6, 1.2], [2.2, -11.9, 2.4], [6.8, -7.9, 0.9]]) o.push(piece(N, 'Reeds', n('Reeds'), [x, 0, z], yaw, { physics: COLLIDERS.sensor })); + for (const [x, z, yaw] of [[1.5, -10.5, 0.4], [7.9, -9.6, 2.2], [4.6, -12.7, 1.1]]) o.push(piece(N, 'FlatRock', n('Pond rock'), [x, 0, z], yaw)); + for (const [x, z] of [[1.9, -7.7], [5.4, -12.6], [7.3, -8.3]]) o.push(piece(N, 'RockSmall', n('Pebble'), [x, 0, z], x)); + // the hut: a 4 × 4 m plaster cottage (kit.md's recipe, moved to x 10..14, z -9..-5), + // its doorway facing the path, a window to the south + const hx = 10; + const hz = -9; + for (const [dx, dz] of [[1, 1], [3, 1], [1, 3], [3, 3]]) o.push(piece(A, 'FloorWood', n('Hut floor'), [hx + dx, 0, hz + dz])); + o.push(piece(A, 'WallPlaster', n('Hut wall'), [hx + 1, 0, hz])); + o.push(piece(A, 'WallPlaster', n('Hut wall'), [hx + 3, 0, hz])); + o.push(piece(A, 'WallPlaster', n('Hut wall'), [hx + 1, 0, hz + 4])); + o.push(piece(A, 'WallPlasterWindow', 'Hut window wall', [hx + 3, 0, hz + 4])); + o.push(piece(A, 'Window', 'Hut window', [hx + 3, 0, hz + 4])); + o.push(piece(A, 'WallPlaster', n('Hut wall'), [hx, 0, hz + 1], H)); + o.push(piece(A, 'WallPlasterDoor', 'Hut doorway', [hx, 0, hz + 3], H, { physics: COLLIDERS.doorway })); + o.push(piece(A, 'WallPlaster', n('Hut wall'), [hx + 4, 0, hz + 1], H)); + o.push(piece(A, 'WallPlaster', n('Hut wall'), [hx + 4, 0, hz + 3], H)); + for (const [dx, dz] of [[0, 0], [4, 0], [0, 4], [4, 4]]) o.push(piece(A, 'CornerPostStone', n('Hut corner'), [hx + dx, 0, hz + dz])); + for (const dx of [1, 3]) { + o.push(piece(A, 'RoofSlope', n('Hut roof'), [hx + dx, 3, hz + 3])); + o.push(piece(A, 'RoofSlope', n('Hut roof'), [hx + dx, 3, hz + 1], PI)); + } + for (const dx of [0, 4]) { + o.push(piece(A, 'WallPlasterGable', n('Hut gable'), [hx + dx, 3, hz + 3], H)); + o.push(piece(A, 'WallPlasterGable', n('Hut gable'), [hx + dx, 3, hz + 1], -H)); + } + o.push(piece(A, 'Chimney', 'Hut chimney', [hx + 3, 3.7, hz + 1])); + // inside the hut + o.push(piece(P, 'Bed', 'Hut bed', [hx + 2.8, 0.1, hz + 1.25], H)); + o.push(piece(P, 'Table', 'Hut table', [hx + 2.9, 0.1, hz + 3], 0, { scale: [0.7, 1, 0.8] })); + o.push(piece(P, 'Chair', 'Hut chair', [hx + 2.9, 0.1, hz + 2.2], PI)); + o.push(piece(P, 'Rug', 'Hut rug', [hx + 1.6, 0.1, hz + 2.1], 0, { scale: 0.8, physics: COLLIDERS.sensor })); + o.push(piece(P, 'Lantern', n('Lantern'), [hx + 2.9, 0.88, hz + 3], 0, { physics: COLLIDERS.sensor })); + o.push({ type: 'light', name: 'Hut lamp', pos: [hx + 2, 2.2, hz + 2], color: 0xffb468, intensity: 6, distance: 8, decay: 2 }); + o.push(piece(P, 'Barrel', n('Barrel'), [hx - 0.8, 0, hz + 1.6], 0, { physics: DYNAMIC(25) })); + o.push(piece(P, 'Crate', n('Crate'), [hx - 0.9, 0, hz + 0.6], 0.4, { physics: DYNAMIC(15) })); + // the cliff behind everything, and the walkable mound to the west + for (const x of [-10, -6, -2, 2, 6, 10]) o.push(piece(N, 'Cliff', n('Cliff'), [x, 0, -19], x > 0 ? PI * 0.02 : 0)); + o.push(piece(N, 'Hill', 'Grassy mound', [-9, 0, -3], 0.4, { physics: hillCollider() })); + // trees round the clearing + const trees = [ + ['Oak', -6, -9, 1.0], ['OakAutumn', -11, 4, 1.05], ['Pine', -14, -12, 1.1], ['Pine', -4, -15, 1.15], ['Birch', -3.5, -10.5, 1], + ['Pine', 11, -15, 1.1], ['Birch', 8.5, 2.8, 1], ['Oak', 15.5, -3, 1.1], ['DeadTree', -12.5, -6, 1], ['Pine', 16, -10, 1.2], + ['Birch', -8, 7.5, 0.95], ['Pine', 6, 10, 1.1], ['OakAutumn', 13, 5.5, 0.9], ['Pine', -15, 3, 1.05], ['Oak', 2, -15.5, 1.05] + ]; + // an outer ring of pines closes the clearing in (and hides the edge of the world) + const ring = rng(99); + for (let i = 0; i < 16; i++) { + const a = (i / 16) * 2 * PI + ring() * 0.25; + const r = 20 + ring() * 5; + trees.push([i % 5 === 2 ? 'Oak' : 'Pine', r3(2 + Math.cos(a) * r), r3(-3 + Math.sin(a) * r), r3(1 + ring() * 0.3)]); + } + for (const [item, x, z, s] of trees) + o.push(piece(N, /** @type {string} */ (item), n(/** @type {string} */ (item)), [/** @type {number} */ (x), 0, /** @type {number} */ (z)], /** @type {number} */ (x) * 0.61, { scale: s, physics: TRUNKS[/** @type {string} */ (item)] })); + // the floor of the clearing + o.push(piece(N, 'Log', 'Fallen log', [-3.4, 0, -1.2], 0.45)); + o.push(piece(N, 'Stump', 'Stump', [-4.2, 0, -4.6], 0.8)); + o.push(piece(N, 'Mushrooms', n('Mushrooms'), [-3.3, 0, -5.3], 1.3, { physics: COLLIDERS.sensor })); + o.push(piece(N, 'Mushrooms', n('Mushrooms'), [-6.8, 0, -7.8], 2.6, { physics: COLLIDERS.sensor })); + o.push(piece(N, 'BoulderCluster', 'Boulders', [-9.5, 0, -13.5], 0.9)); + o.push(piece(N, 'RockLarge', 'Big rock', [9.5, 0, 0.5], 2.2)); + o.push(piece(N, 'RockMedium', n('Rock'), [-1.8, 0, 7.5], 0.5)); + o.push(piece(P, 'Signpost', 'Signpost', [1.6, 0, -3.4], -0.6)); + o.push(piece(N, 'Stump', 'Seat stump', [-1.9, 0, 1.6], 2.0, { scale: 0.7 })); + const rand = rng(4242); + const clear = (/** @type {number} */ x, /** @type {number} */ z) => Math.abs(x) < 1.6 && z > -7.5 && z < 6.5; // the path + for (let i = 0; i < 16; i++) { + const a = rand() * 2 * PI; + const r = 4 + rand() * 9; + const x = r3(Math.cos(a) * r); + const z = r3(Math.sin(a) * r * 0.9 - 3); + if (clear(x, z) || (x > 0.8 && x < 14.5 && z > -13 && z < -3.5) || z < -16) continue; // path, bridge, pond, hut, cliff + const kind = i % 4 === 0 ? 'Fern' : i % 4 === 1 ? 'Bush' : i % 4 === 2 ? 'FlowerPatch' : 'BushAutumn'; + o.push(piece(N, kind, n(kind), [x, 0, z], rand() * 6, kind === 'FlowerPatch' || kind === 'Fern' ? { physics: COLLIDERS.sensor } : {})); + } + for (let i = 0; i < 26; i++) { + const x = r3(-12 + rand() * 26); + const z = r3(-14 + rand() * 22); + if (clear(x, z) || (x > 0.8 && x < 14.5 && z > -13 && z < -3.5) || z < -16) continue; + o.push(piece(N, i % 4 ? 'GrassTuft' : 'Flowers', n('Grass'), [x, 0, z], rand() * 6, { physics: COLLIDERS.sensor })); + } + for (const [x, z, yaw] of [[-2.6, -2.8, 0.2], [2.4, 1.5, 1.4], [-5.5, 2.5, 2.9]]) o.push(piece(N, 'FallenLeaves', n('Leaves'), [x, 0.01, z], yaw, { physics: COLLIDERS.sensor })); + return o; +} + +const FOREST_DEF = { + kind: 'template', + seed: false, + slug: 'forest-clearing', + title: 'Forest Clearing', + description: + 'A clearing under a cliff: a stone path, a footbridge over the neck of a pond and a little plaster hut, ringed by oaks, pines and birches. Press Play to walk it.', + author: 'theprototype', + license: 'CC0-1.0', + tags: ['level design', 'kits', 'nature', 'walkable'], + objects: forestObjects(), + // late morning in the woods: a clear sky, a green ground, a warm sun through the trees + env: { + preset: 'daylight', + exposure: 1.05, + background: { top: '#5d93cf', bottom: '#dfe8e0' }, + // the fog closes in past the tree line, so the flat ground fades into haze instead of + // ending in a band at the horizon (the Towers finding) + fog: { color: '#dfe8e0', near: 18, far: 62 }, + ground: { color: '#5f7d3f', roughness: 1 }, + sun: { color: '#fff3d8', intensity: 2.5, dir: [0.45, 0.72, 0.5] }, + hemi: { sky: '#d9ecff', ground: '#5f7d3f', intensity: 1.3 } + }, + physics: playPhysics([0, 0.35, 5], 0), + post: lookPost(0.5), + graphs: { scene: walkGraph() }, + view: { pos: [-6, 7.5, 12], target: [3, 1, -6] }, + thumb: {} +}; + +// ---- 3. Tavern Interior ---------------------------------------------------------------- +// +// A two-storey room 12 × 10 m: the HALL (x -6..2) rises the full 6 m with a BALCONY along its +// north wall, the KITCHEN (x 2..6) sits behind a partition with a doorway and has a LOFT above +// it that opens onto the balcony. An oak staircase climbs the west wall to the balcony; a bar +// runs under it; tables, benches, barrels and a hearth fill the rooms, and lanterns and wall +// torches light it warm. The front door is shut: you are inside. +function tavernObjects() { + const n = namer(); + /** @type {any[]} */ + const o = []; + const XS = [-5, -3, -1, 1, 3, 5]; + const ZS = [-5, -3, -1, 1, 3]; + // ground floor, ceiling, balcony and loft ------------------------------------------ + for (const x of XS) for (const z of ZS) o.push(piece(A, 'FloorWood', n('Floor'), [x, 0, z])); + for (const x of XS) for (const z of ZS) o.push(piece(A, 'FloorWood', n('Ceiling'), [x, 6, z])); + for (const x of [-5, -3, -1, 1]) o.push(piece(A, 'FloorWood', n('Balcony'), [x, 3, -5])); + for (const x of [3, 5]) for (const z of ZS) o.push(piece(A, 'FloorWood', n('Loft'), [x, 3, z])); + // outer walls, two storeys: sandstone below, plaster above -------------------------- + /** @param {number} y @param {string} solid @param {string} win @param {string} winWall */ + const shell = (y, solid, win, winWall) => { + // a window's shutters are its FRONT (+Z): every wall of the shell is turned so its + // front faces OUT (kit.md: the Window shares its wall's position and rotation) + for (const x of XS) { + // north wall z = -6, turned to face -Z + if (y === 3 && x === -1) { + o.push(piece(A, winWall, n('Window wall'), [x, y, -6], PI)); + o.push(piece(A, 'Window', n('Window'), [x, y, -6], PI)); + } else o.push(piece(A, solid, n('Wall'), [x, y, -6], PI)); + // south wall z = 4 (the front: the door, and windows) + if (y === 0 && x === -3) { + o.push(piece(A, 'WallStoneDoor', 'Front doorway', [x, y, 4])); + o.push(piece(A, 'Door', 'Front door', [x, y, 4])); + } else if ((y === 0 && (x === 1 || x === 5)) || (y === 3 && x === -3)) { + o.push(piece(A, winWall, n('Window wall'), [x, y, 4])); + o.push(piece(A, 'Window', n('Window'), [x, y, 4])); + } else o.push(piece(A, solid, n('Wall'), [x, y, 4])); + } + for (const z of ZS) { + if (y === 0 && z === 1) { + o.push(piece(A, winWall, n('Window wall'), [-6, y, z], -H)); + o.push(piece(A, 'Window', n('Window'), [-6, y, z], -H)); + } else o.push(piece(A, solid, n('Wall'), [-6, y, z], -H)); + if (y === 0 && z === -1) { + o.push(piece(A, winWall, n('Window wall'), [6, y, z], H)); + o.push(piece(A, 'Window', n('Window'), [6, y, z], H)); + } else o.push(piece(A, solid, n('Wall'), [6, y, z], H)); + } + for (const [x, z] of [[-6, -6], [6, -6], [-6, 4], [6, 4]]) o.push(piece(A, 'CornerPostStone', n('Corner post'), [x, y, z])); + }; + shell(0, 'WallStone', 'Window', 'WallStoneWindow'); + shell(3, 'WallPlaster', 'Window', 'WallPlasterWindow'); + // the partition between hall and kitchen (x = 2), a doorway at z = 1 -------------------- + for (const z of ZS) { + if (z === 1) o.push(piece(A, 'WallPlasterDoor', 'Kitchen doorway', [2, 0, z], H, { physics: COLLIDERS.doorway })); + else o.push(piece(A, 'WallPlaster', n('Partition'), [2, 0, z], H)); + } + // the staircase up the west wall to the balcony, rails along the open edges ---------- + o.push(piece(A, 'StairsWood', 'Balcony stairs', [-5, 0.1, -2], 0, { physics: COLLIDERS.stairs })); + for (const x of [-3, -1, 1]) o.push(piece(A, 'Railing', n('Balcony railing'), [x, 3.1, -3.92])); + for (const z of [-3, -1, 1, 3]) o.push(piece(A, 'Railing', n('Loft railing'), [2.08, 3.1, z], H)); + o.push(piece(A, 'Column', n('Column'), [2, 0.1, -4])); + o.push(piece(A, 'Column', n('Column'), [-2, 0.1, -4])); + for (const x of [-3, -1, 1]) o.push(piece(A, 'Beam', n('Balcony beam'), [x, 2.7, -4])); + for (const x of [-5, -3, -1, 1]) o.push(piece(A, 'Beam', n('Ceiling beam'), [x, 5.7, -0.5])); + // the bar, under the balcony: half walls (scaled to counter height) with a trim top -- + for (const x of [-2.2, -0.2]) { + o.push(piece(A, 'WallStoneHalf', n('Bar'), [x, 0.1, -2.6], 0, { scale: [1, 0.72, 1] })); + o.push(piece(A, 'Trim', n('Bar top'), [x, 1.18, -2.6])); + } + o.push(piece(P, 'Bookcase', n('Shelf'), [-1.2, 0.1, -5.5])); + o.push(piece(P, 'Bookcase', n('Shelf'), [0.4, 0.1, -5.5])); + for (const [x, z] of [[-3.4, -5.2], [-3.6, -4.4], [1.4, -5.2]]) o.push(piece(P, 'Barrel', n('Barrel'), [x, 0.1, z], x, { physics: DYNAMIC(30) })); + o.push(piece(P, 'Candles', n('Candles'), [-1.6, 1.38, -2.6], 0, { physics: COLLIDERS.sensor })); + o.push(piece(P, 'Lantern', n('Lantern'), [-0.2, 1.38, -2.6], 0, { physics: COLLIDERS.sensor })); + for (const x of [-2.4, -1.2, 0]) o.push(piece(P, 'Chair', n('Bar stool'), [x, 0.1, -1.7], PI)); + // the hall: two long tables with benches, a rug, a plant -------------------------- + for (const [x, z] of [[-2.6, 1.2], [-0.2, 0.6]]) { + o.push(piece(P, 'Table', n('Table'), [x, 0.1, z], H)); + o.push(piece(P, 'Bench', n('Bench'), [x - 0.75, 0.1, z], H)); + o.push(piece(P, 'Bench', n('Bench'), [x + 0.75, 0.1, z], H)); + o.push(piece(P, 'Candles', n('Candles'), [x, 0.88, z], 0, { physics: COLLIDERS.sensor })); + } + o.push(piece(P, 'Rug', 'Hall rug', [-1.2, 0.1, 1.0], H, { scale: 1.2, physics: COLLIDERS.sensor })); + o.push(piece(P, 'PottedPlant', n('Plant'), [1.4, 0.1, 3.3])); + o.push(piece(P, 'PottedPlant', n('Plant'), [-5.3, 0.1, 3.3])); + o.push(piece(P, 'CrateTeal', n('Crate'), [-4.9, 0.1, 1.9], 0.2, { physics: DYNAMIC(12) })); + // the kitchen: a hearth, a cauldron, a workbench, stores ----------------------------- + o.push(piece(A, 'Chimney', 'Hearth', [5.4, 0.1, -5.4])); + o.push(piece(P, 'Cauldron', 'Cauldron', [4.4, 0.1, -5], 0.4)); + o.push(piece(P, 'Workbench', 'Workbench', [5.45, 0.1, -1.5], -H)); + o.push(piece(P, 'Candles', n('Candles'), [5.45, 1.02, -1.2], 0, { physics: COLLIDERS.sensor })); + o.push(piece(P, 'CrateStack', 'Kitchen crates', [4.6, 0.1, 3.2])); + o.push(piece(P, 'Sacks', 'Kitchen sacks', [5.2, 0.1, 1.4], 1.9)); + o.push(piece(P, 'BarrelSmall', n('Barrel'), [3.1, 0.1, -5.3], 0, { physics: DYNAMIC(10) })); + // the loft: a bed, a chest, a rug, a tapestry, a plant -------------------------------- + o.push(piece(P, 'Bed', 'Loft bed', [5, 3.1, -4.4])); + o.push(piece(P, 'Chest', 'Loft chest', [5.2, 3.1, 2.8], -H)); + o.push(piece(P, 'Rug', 'Loft rug', [4.1, 3.1, -0.5], H, { physics: COLLIDERS.sensor })); + o.push(piece(P, 'Tapestry', n('Tapestry'), [5.875, 3.9, 0.5], -H, { physics: COLLIDERS.sensor })); + o.push(piece(P, 'Tapestry', n('Tapestry'), [-3, 3.9, -5.875], 0, { physics: COLLIDERS.sensor })); + o.push(piece(P, 'PottedPlant', n('Plant'), [1.3, 3.1, -5.3])); + // wall torches and lights ---------------------------------------------------------- + for (const [x, z, yaw] of [[-5.875, 0.7, H], [-5.875, 2.8, H], [1.875, -1.8, -H], [-0.8, 3.875, PI], [5.875, 2, -H]]) + o.push(piece(P, 'WallTorch', n('Wall torch'), [x, 1.55, z], yaw, { physics: COLLIDERS.sensor })); + for (const [x, y, z, i] of [[-3, 2.5, 1.2, 9], [0.2, 2.5, 1, 9], [-1, 2.4, -3.2, 7], [4, 2.4, -2.5, 8], [4, 5.2, -1, 8], [-2, 5.2, -5, 6], [-4.2, 2.3, -1, 5]]) + o.push({ type: 'light', name: n('Lamp'), pos: [x, y, z], color: 0xffb060, intensity: i, distance: 11, decay: 2 }); + return o; +} + +const TAVERN_DEF = { + kind: 'template', + seed: false, + slug: 'tavern-interior', + title: 'Tavern Interior', + description: + 'A two-storey tavern: a hall with a bar under the balcony, a kitchen with a hearth, and a loft up the oak stairs, lit warm by lanterns and torches. Press Play to walk it.', + author: 'theprototype', + license: 'CC0-1.0', + tags: ['level design', 'kits', 'interior', 'walkable'], + objects: tavernObjects(), + // an early evening outside the windows; inside, the warm lamps do the work and a warm + // hemisphere keeps the corners from going black + env: { + preset: 'sunset', + exposure: 1.1, + background: { top: '#3f4a78', bottom: '#f0a766' }, + fog: null, + ground: { color: '#5b5040', roughness: 1 }, + sun: { color: '#ffb06a', intensity: 1.6, dir: [0.7, 0.35, 0.6] }, + hemi: { sky: '#ffd9ae', ground: '#6b5236', intensity: 1.2 } + }, + // by the front door, the bar and the stairs ahead + physics: playPhysics([-1.4, 0.3, 3.3], -0.2), + post: lookPost(0.7), + graphs: { scene: walkGraph() }, + view: { pos: [0.5, 2.1, 3.3], target: [-1.5, 1.6, -3] }, + thumb: {} +}; + +module.exports = { LEVEL_DEFS: [CASTLE_DEF, FOREST_DEF, TAVERN_DEF], COLLIDERS, TRUNKS, BRIDGE_DECK, HILL_RINGS }; diff --git a/src/App.svelte b/src/App.svelte index 40cf9592..7a733e9e 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -65,6 +65,7 @@ import { startMultiTransform } from '$lib/multiTransform' import { startLightParams } from '$lib/lightParams' import { startShadowDefaults } from '$lib/shadowDefaults' + import { startPackRefs } from '$lib/packRefs' import { startViewMode } from '$lib/viewMode' import { startInputRuntime } from '$lib/inputRuntime' // 21-E2.4: right-drag a HUD element in the viewport. Installed here rather than from @@ -196,6 +197,7 @@ import { startMusicToolbox } from './lib/musicToolbox' startMultiTransform() startLightParams() startShadowDefaults() + startPackRefs() startViewMode() startInputRuntime() startHudViewportDrag() @@ -478,9 +480,10 @@ import { startMusicToolbox } from './lib/musicToolbox' import('./lib/importGate'), import('./lib/decimate'), import('./lib/playCursor'), - import('./lib/gameStorage') - ]).then(([sceneStore, appStore, flowStore, meshEdit, vrControls, autosave, voiceChat, audioEngineLib, musicClockLib, audioDevicesLib, audioPatchLib, vrPatchLib, musicToolboxLib, micCaptureLib, annotationsHandler, flowRuntime, history, materialsHandler, objectActions, commandsHandler, moduleSDK, drawModeLib, pathCapture, lockControl, prefabsLib, physics, jointsLib, possessLib, handModelsLib, terrainSculptLib, userModulesLib, environmentLib, sceneMusicLib, animatedImports, fileHandler, fileWindowsLib, sceneBounds, cameraClip, ping, sessionsLib, geometryEdit, lightParams, shadowDefaultsLib, paletteLib, viewModeLib, inputRuntimeLib, shortcutsLib, themesLib, vrRadialMenu, vrPaletteLib, vrWindowPosesLib, vrKeyboardLib, faceEditLib, meshToolParamsLib, avatarModelLib, explorerLib, bottomDock, explorerDrop, assetShare, soundRuntime, dungeonPlay, sceneAssetsLib, THREE, GLTFExporterModule, snappingLib, flowSocketsLib, networkQualityLib, packsLib, customNodesLib, nodesHandlerLib, nodeCatalogLib, objectMenuLib, animationPreviewLib, aiProvidersLib, aiToolsLib, aiAssistantLib, meshProvidersLib, meshJobsLib, flowGraphsLib, objectFlowLib, peerServerLib, cloudHooksLib, cloudPluginLib, connectionStateLib, peerApprovalLib, particleRuntimeLib, particleActionsLib, particlePresetsLib, versionLib, whatsNewLib, confirmDialogLib, scenePhysicsLib, playInteractLib, moveSmoothingLib, knockLib, playSettingsLib, colliderSpecLib, colliderHelpersLib, colliderEditLib, editSessionLib, trackpadNavLib, vrSleeveLib, gridSettingsLib, viewPrefsLib, cameraBookmarksLib, cameraObjectsLib, cameraHelpersLib, onionSkinLib, cameraPreviewLib, addObjectsLib, cameraPipLib, inputDeviceLib, sceneTemplatesLib, bvhPickingLib, multiTransformLib, objectOriginLib, moduleGalleryLib, uvEditorLib, uvUnwrapLib, meshTopologyLib, meshBudgetLib, proportionalLib, proportionalRingLib, scenePickLib, snapEngineLib, meshPivotLib, selectionPrefsLib, editOverlaysLib, objectPermissionsLib, scenePostLib, postEffectsLib, viewportOverridesLib, postprocessingModule, shaderBackendsLib, shaderGraphLib, shaderSyncLib, shaderTexturesLib, shaderCatalogLib, unitsLib, postBackendsLib, workspaceLib, editResumeLib, moduleRequirementsLib, hudDocsLib, hudSyncLib, idbLib, hudKindsLib, hudImagesLib, gameStateLib, gameSyncLib, hudActionsLib, moduleNodeIOLib, moduleToolboxesLib, splineTubeLib, splineToolLib, splineEditLib, terrainCarveLib, flattenActionsLib, hudViewportDragLib, gamepadPrefsLib, charControllerLib, hudRichTextLib, moduleHudKindsLib, hudMinimapLib, hudArrangeLib, gamePresenceLib, levelsLib, peerVarsLib, projectManifestLib, projectFileLib, transientObjectsLib, spawnerLib, triggerSyncLib, saveNameLib, sceneIdentityLib, importDuplicatesLib, peerScenesLib, sharedLibraryLib, transferLedgerLib, explorerViewLib, filePreviewLib, saveAsLib, windowTabsLib, colocationLib, colocationCalibrateLib, colocationPresenceLib, xrAnchorsLib, colocationAnchorsLib, colocationNudgeLib, mountedVolumesLib, storageUsageLib, scenePrivacyLib, touchControlsLib, playModeLib, objectListNavLib, inviteLinksLib, helperLayerLib, explorerClipboardLib, lookPresenceLib, postGraphsLib, postGraphPresetsLib, materialSharingLib, diagnosticsLib, wireValidateLib, wireErrorsLib, safeStorageLib, sceneBudgetLib, overloadGuardLib, qualityGovernorLib, importBudgetLib, importGateLib, decimateLib, playCursorLib, gameStorageLib]) => { - window.__stores = { ...sceneStore, ...appStore, ...flowStore, meshEdit, vrControls, autosave, voiceChat, audioEngine: audioEngineLib, musicClock: musicClockLib, audioDevices: audioDevicesLib, audioPatch: audioPatchLib, vrPatch: vrPatchLib, musicToolbox: musicToolboxLib, micCapture: micCaptureLib, annotationsHandler, flowRuntime, history, materialsHandler, objectActions, commandsHandler, moduleSDK, drawMode: drawModeLib, pathCapture, lockControl, prefabs: prefabsLib, physics, joints: jointsLib, possess: possessLib, handModels: handModelsLib, terrainSculpt: terrainSculptLib, userModules: userModulesLib, environment: environmentLib, sceneMusic: sceneMusicLib, animatedImports, fileHandler, fileWindows: fileWindowsLib, sceneBounds, cameraClip, ping, sessions: sessionsLib, geometryEdit, lightParams, shadowDefaults: shadowDefaultsLib, palette: paletteLib, viewModeCtl: viewModeLib, inputRuntime: inputRuntimeLib, shortcutsRegistry: shortcutsLib, themes: themesLib, vrRadialMenu, vrPalette: vrPaletteLib, vrWindowPoses: vrWindowPosesLib, vrKeyboard: vrKeyboardLib, faceEdit: faceEditLib, meshToolParams: meshToolParamsLib, avatarModel: avatarModelLib, explorer: explorerLib, bottomDock, explorerDrop, assetShare, soundRuntime, dungeonPlay, sceneAssets: sceneAssetsLib, THREE, GLTFExporterModule, snapping: snappingLib, flowSockets: flowSocketsLib, networkQuality: networkQualityLib, packs: packsLib, customNodes: customNodesLib, nodesHandler: nodesHandlerLib, nodeCatalog: nodeCatalogLib, objectMenu: objectMenuLib, animationPreview: animationPreviewLib, aiProviders: aiProvidersLib, aiTools: aiToolsLib, aiAssistant: aiAssistantLib, meshProviders: meshProvidersLib, meshJobs: meshJobsLib, flowGraphsCtl: flowGraphsLib, objectFlow: objectFlowLib, peerServer: peerServerLib, cloudHooks: cloudHooksLib, cloudPlugin: cloudPluginLib, connectionState: connectionStateLib, peerApproval: peerApprovalLib, particleRuntime: particleRuntimeLib, particleActions: particleActionsLib, particlePresets: particlePresetsLib, version: versionLib, whatsNew: whatsNewLib, confirmDialog: confirmDialogLib, scenePhysics: scenePhysicsLib, playInteract: playInteractLib, moveSmoothing: moveSmoothingLib, knock: knockLib, playSettings: playSettingsLib, colliderSpec: colliderSpecLib, colliderHelpers: colliderHelpersLib, colliderEdit: colliderEditLib, editSession: editSessionLib, trackpadNav: trackpadNavLib, vrSleeve: vrSleeveLib, gridSettings: gridSettingsLib, viewPrefs: viewPrefsLib, cameraBookmarks: cameraBookmarksLib, cameraObjects: cameraObjectsLib, cameraHelpers: cameraHelpersLib, onionSkin: onionSkinLib, cameraPreview: cameraPreviewLib, addObjects: addObjectsLib, cameraPip: cameraPipLib, inputDevice: inputDeviceLib, sceneTemplates: sceneTemplatesLib, bvhPicking: bvhPickingLib, multiTransform: multiTransformLib, objectOrigin: objectOriginLib, moduleGallery: moduleGalleryLib, uvEditor: uvEditorLib, uvUnwrap: uvUnwrapLib, meshTopology: meshTopologyLib, meshBudget: meshBudgetLib, proportional: proportionalLib, proportionalRing: proportionalRingLib, scenePick: scenePickLib, snapEngine: snapEngineLib, meshPivot: meshPivotLib, selectionPrefs: selectionPrefsLib, editOverlays: editOverlaysLib, objectPermissions: objectPermissionsLib, scenePost: scenePostLib, postEffects: postEffectsLib, viewportOverrides: viewportOverridesLib, postprocessing: postprocessingModule, shaderBackends: shaderBackendsLib, shaderGraph: shaderGraphLib, shaderSync: shaderSyncLib, shaderTextures: shaderTexturesLib, shaderCatalog: shaderCatalogLib, units: unitsLib, postBackends: postBackendsLib, workspace: workspaceLib, editResume: editResumeLib, moduleRequirements: moduleRequirementsLib, hudDocs: hudDocsLib, hudSync: hudSyncLib, idb: idbLib, hudKinds: hudKindsLib, hudImages: hudImagesLib, gameState: gameStateLib, gameSync: gameSyncLib, hudActions: hudActionsLib, moduleNodeIO: moduleNodeIOLib, moduleToolboxes: moduleToolboxesLib, splineTube: splineTubeLib, splineTool: splineToolLib, splineEdit: splineEditLib, terrainCarve: terrainCarveLib, flattenActions: flattenActionsLib, hudViewportDrag: hudViewportDragLib, gamepadPrefs: gamepadPrefsLib, charController: charControllerLib, hudRichText: hudRichTextLib, moduleHudKinds: moduleHudKindsLib, hudMinimap: hudMinimapLib, hudArrange: hudArrangeLib, gamePresence: gamePresenceLib, levels: levelsLib, peerVars: peerVarsLib, projectManifest: projectManifestLib, projectFile: projectFileLib, transientObjects: transientObjectsLib, spawner: spawnerLib, triggerSync: triggerSyncLib, saveName: saveNameLib, sceneIdentity: sceneIdentityLib, importDuplicates: importDuplicatesLib, peerScenes: peerScenesLib, sharedLibrary: sharedLibraryLib, transferLedger: transferLedgerLib, explorerView: explorerViewLib, filePreview: filePreviewLib, saveAs: saveAsLib, windowTabs: windowTabsLib, colocation: colocationLib, colocationCalibrate: colocationCalibrateLib, colocationPresence: colocationPresenceLib, xrAnchors: xrAnchorsLib, colocationAnchors: colocationAnchorsLib, colocationNudge: colocationNudgeLib, mountedVolumes: mountedVolumesLib, storageUsage: storageUsageLib, scenePrivacy: scenePrivacyLib, touchControls: touchControlsLib, playMode: playModeLib, objectListNav: objectListNavLib, inviteLinks: inviteLinksLib, helperLayer: helperLayerLib, explorerClipboard: explorerClipboardLib, lookPresence: lookPresenceLib, postGraphs: postGraphsLib, postGraphPresets: postGraphPresetsLib, materialSharing: materialSharingLib, diagnostics: diagnosticsLib, wireValidate: wireValidateLib, wireErrors: wireErrorsLib, safeStorage: safeStorageLib, sceneBudget: sceneBudgetLib, overloadGuard: overloadGuardLib, qualityGovernor: qualityGovernorLib, importBudget: importBudgetLib, importGate: importGateLib, decimate: decimateLib, playCursor: playCursorLib, gameStorage: gameStorageLib } + import('./lib/gameStorage'), + import('./lib/packRefs') + ]).then(([sceneStore, appStore, flowStore, meshEdit, vrControls, autosave, voiceChat, audioEngineLib, musicClockLib, audioDevicesLib, audioPatchLib, vrPatchLib, musicToolboxLib, micCaptureLib, annotationsHandler, flowRuntime, history, materialsHandler, objectActions, commandsHandler, moduleSDK, drawModeLib, pathCapture, lockControl, prefabsLib, physics, jointsLib, possessLib, handModelsLib, terrainSculptLib, userModulesLib, environmentLib, sceneMusicLib, animatedImports, fileHandler, fileWindowsLib, sceneBounds, cameraClip, ping, sessionsLib, geometryEdit, lightParams, shadowDefaultsLib, paletteLib, viewModeLib, inputRuntimeLib, shortcutsLib, themesLib, vrRadialMenu, vrPaletteLib, vrWindowPosesLib, vrKeyboardLib, faceEditLib, meshToolParamsLib, avatarModelLib, explorerLib, bottomDock, explorerDrop, assetShare, soundRuntime, dungeonPlay, sceneAssetsLib, THREE, GLTFExporterModule, snappingLib, flowSocketsLib, networkQualityLib, packsLib, customNodesLib, nodesHandlerLib, nodeCatalogLib, objectMenuLib, animationPreviewLib, aiProvidersLib, aiToolsLib, aiAssistantLib, meshProvidersLib, meshJobsLib, flowGraphsLib, objectFlowLib, peerServerLib, cloudHooksLib, cloudPluginLib, connectionStateLib, peerApprovalLib, particleRuntimeLib, particleActionsLib, particlePresetsLib, versionLib, whatsNewLib, confirmDialogLib, scenePhysicsLib, playInteractLib, moveSmoothingLib, knockLib, playSettingsLib, colliderSpecLib, colliderHelpersLib, colliderEditLib, editSessionLib, trackpadNavLib, vrSleeveLib, gridSettingsLib, viewPrefsLib, cameraBookmarksLib, cameraObjectsLib, cameraHelpersLib, onionSkinLib, cameraPreviewLib, addObjectsLib, cameraPipLib, inputDeviceLib, sceneTemplatesLib, bvhPickingLib, multiTransformLib, objectOriginLib, moduleGalleryLib, uvEditorLib, uvUnwrapLib, meshTopologyLib, meshBudgetLib, proportionalLib, proportionalRingLib, scenePickLib, snapEngineLib, meshPivotLib, selectionPrefsLib, editOverlaysLib, objectPermissionsLib, scenePostLib, postEffectsLib, viewportOverridesLib, postprocessingModule, shaderBackendsLib, shaderGraphLib, shaderSyncLib, shaderTexturesLib, shaderCatalogLib, unitsLib, postBackendsLib, workspaceLib, editResumeLib, moduleRequirementsLib, hudDocsLib, hudSyncLib, idbLib, hudKindsLib, hudImagesLib, gameStateLib, gameSyncLib, hudActionsLib, moduleNodeIOLib, moduleToolboxesLib, splineTubeLib, splineToolLib, splineEditLib, terrainCarveLib, flattenActionsLib, hudViewportDragLib, gamepadPrefsLib, charControllerLib, hudRichTextLib, moduleHudKindsLib, hudMinimapLib, hudArrangeLib, gamePresenceLib, levelsLib, peerVarsLib, projectManifestLib, projectFileLib, transientObjectsLib, spawnerLib, triggerSyncLib, saveNameLib, sceneIdentityLib, importDuplicatesLib, peerScenesLib, sharedLibraryLib, transferLedgerLib, explorerViewLib, filePreviewLib, saveAsLib, windowTabsLib, colocationLib, colocationCalibrateLib, colocationPresenceLib, xrAnchorsLib, colocationAnchorsLib, colocationNudgeLib, mountedVolumesLib, storageUsageLib, scenePrivacyLib, touchControlsLib, playModeLib, objectListNavLib, inviteLinksLib, helperLayerLib, explorerClipboardLib, lookPresenceLib, postGraphsLib, postGraphPresetsLib, materialSharingLib, diagnosticsLib, wireValidateLib, wireErrorsLib, safeStorageLib, sceneBudgetLib, overloadGuardLib, qualityGovernorLib, importBudgetLib, importGateLib, decimateLib, playCursorLib, gameStorageLib, packRefsLib]) => { + window.__stores = { ...sceneStore, ...appStore, ...flowStore, meshEdit, vrControls, autosave, voiceChat, audioEngine: audioEngineLib, musicClock: musicClockLib, audioDevices: audioDevicesLib, audioPatch: audioPatchLib, vrPatch: vrPatchLib, musicToolbox: musicToolboxLib, micCapture: micCaptureLib, annotationsHandler, flowRuntime, history, materialsHandler, objectActions, commandsHandler, moduleSDK, drawMode: drawModeLib, pathCapture, lockControl, prefabs: prefabsLib, physics, joints: jointsLib, possess: possessLib, handModels: handModelsLib, terrainSculpt: terrainSculptLib, userModules: userModulesLib, environment: environmentLib, sceneMusic: sceneMusicLib, animatedImports, fileHandler, fileWindows: fileWindowsLib, sceneBounds, cameraClip, ping, sessions: sessionsLib, geometryEdit, lightParams, shadowDefaults: shadowDefaultsLib, palette: paletteLib, viewModeCtl: viewModeLib, inputRuntime: inputRuntimeLib, shortcutsRegistry: shortcutsLib, themes: themesLib, vrRadialMenu, vrPalette: vrPaletteLib, vrWindowPoses: vrWindowPosesLib, vrKeyboard: vrKeyboardLib, faceEdit: faceEditLib, meshToolParams: meshToolParamsLib, avatarModel: avatarModelLib, explorer: explorerLib, bottomDock, explorerDrop, assetShare, soundRuntime, dungeonPlay, sceneAssets: sceneAssetsLib, THREE, GLTFExporterModule, snapping: snappingLib, flowSockets: flowSocketsLib, networkQuality: networkQualityLib, packs: packsLib, customNodes: customNodesLib, nodesHandler: nodesHandlerLib, nodeCatalog: nodeCatalogLib, objectMenu: objectMenuLib, animationPreview: animationPreviewLib, aiProviders: aiProvidersLib, aiTools: aiToolsLib, aiAssistant: aiAssistantLib, meshProviders: meshProvidersLib, meshJobs: meshJobsLib, flowGraphsCtl: flowGraphsLib, objectFlow: objectFlowLib, peerServer: peerServerLib, cloudHooks: cloudHooksLib, cloudPlugin: cloudPluginLib, connectionState: connectionStateLib, peerApproval: peerApprovalLib, particleRuntime: particleRuntimeLib, particleActions: particleActionsLib, particlePresets: particlePresetsLib, version: versionLib, whatsNew: whatsNewLib, confirmDialog: confirmDialogLib, scenePhysics: scenePhysicsLib, playInteract: playInteractLib, moveSmoothing: moveSmoothingLib, knock: knockLib, playSettings: playSettingsLib, colliderSpec: colliderSpecLib, colliderHelpers: colliderHelpersLib, colliderEdit: colliderEditLib, editSession: editSessionLib, trackpadNav: trackpadNavLib, vrSleeve: vrSleeveLib, gridSettings: gridSettingsLib, viewPrefs: viewPrefsLib, cameraBookmarks: cameraBookmarksLib, cameraObjects: cameraObjectsLib, cameraHelpers: cameraHelpersLib, onionSkin: onionSkinLib, cameraPreview: cameraPreviewLib, addObjects: addObjectsLib, cameraPip: cameraPipLib, inputDevice: inputDeviceLib, sceneTemplates: sceneTemplatesLib, bvhPicking: bvhPickingLib, multiTransform: multiTransformLib, objectOrigin: objectOriginLib, moduleGallery: moduleGalleryLib, uvEditor: uvEditorLib, uvUnwrap: uvUnwrapLib, meshTopology: meshTopologyLib, meshBudget: meshBudgetLib, proportional: proportionalLib, proportionalRing: proportionalRingLib, scenePick: scenePickLib, snapEngine: snapEngineLib, meshPivot: meshPivotLib, selectionPrefs: selectionPrefsLib, editOverlays: editOverlaysLib, objectPermissions: objectPermissionsLib, scenePost: scenePostLib, postEffects: postEffectsLib, viewportOverrides: viewportOverridesLib, postprocessing: postprocessingModule, shaderBackends: shaderBackendsLib, shaderGraph: shaderGraphLib, shaderSync: shaderSyncLib, shaderTextures: shaderTexturesLib, shaderCatalog: shaderCatalogLib, units: unitsLib, postBackends: postBackendsLib, workspace: workspaceLib, editResume: editResumeLib, moduleRequirements: moduleRequirementsLib, hudDocs: hudDocsLib, hudSync: hudSyncLib, idb: idbLib, hudKinds: hudKindsLib, hudImages: hudImagesLib, gameState: gameStateLib, gameSync: gameSyncLib, hudActions: hudActionsLib, moduleNodeIO: moduleNodeIOLib, moduleToolboxes: moduleToolboxesLib, splineTube: splineTubeLib, splineTool: splineToolLib, splineEdit: splineEditLib, terrainCarve: terrainCarveLib, flattenActions: flattenActionsLib, hudViewportDrag: hudViewportDragLib, gamepadPrefs: gamepadPrefsLib, charController: charControllerLib, hudRichText: hudRichTextLib, moduleHudKinds: moduleHudKindsLib, hudMinimap: hudMinimapLib, hudArrange: hudArrangeLib, gamePresence: gamePresenceLib, levels: levelsLib, peerVars: peerVarsLib, projectManifest: projectManifestLib, projectFile: projectFileLib, transientObjects: transientObjectsLib, spawner: spawnerLib, triggerSync: triggerSyncLib, saveName: saveNameLib, sceneIdentity: sceneIdentityLib, importDuplicates: importDuplicatesLib, peerScenes: peerScenesLib, sharedLibrary: sharedLibraryLib, transferLedger: transferLedgerLib, explorerView: explorerViewLib, filePreview: filePreviewLib, saveAs: saveAsLib, windowTabs: windowTabsLib, colocation: colocationLib, colocationCalibrate: colocationCalibrateLib, colocationPresence: colocationPresenceLib, xrAnchors: xrAnchorsLib, colocationAnchors: colocationAnchorsLib, colocationNudge: colocationNudgeLib, mountedVolumes: mountedVolumesLib, storageUsage: storageUsageLib, scenePrivacy: scenePrivacyLib, touchControls: touchControlsLib, playMode: playModeLib, objectListNav: objectListNavLib, inviteLinks: inviteLinksLib, helperLayer: helperLayerLib, explorerClipboard: explorerClipboardLib, lookPresence: lookPresenceLib, postGraphs: postGraphsLib, postGraphPresets: postGraphPresetsLib, materialSharing: materialSharingLib, diagnostics: diagnosticsLib, wireValidate: wireValidateLib, wireErrors: wireErrorsLib, safeStorage: safeStorageLib, sceneBudget: sceneBudgetLib, overloadGuard: overloadGuardLib, qualityGovernor: qualityGovernorLib, importBudget: importBudgetLib, importGate: importGateLib, decimate: decimateLib, playCursor: playCursorLib, gameStorage: gameStorageLib, packRefs: packRefsLib } }) } }) diff --git a/src/components/editors/Explorer.svelte b/src/components/editors/Explorer.svelte index 81d76926..1b2b2135 100644 --- a/src/components/editors/Explorer.svelte +++ b/src/components/editors/Explorer.svelte @@ -295,6 +295,7 @@ openPackLoading } from '$lib/packs'; import { importFile, exportObjectsAsGltf, openScenePayload } from '$lib/fileHandler.svelte'; + import { packRefFromUrl } from '$lib/packRefs'; // 21-H2: the Explorer is the prefab HOME (the Library modal is gone), so it owns // their whole CRUD — add, export both ways, update, rename, properties, delete. import { @@ -5700,7 +5701,10 @@ dismiss(); return showToast('Could not fetch the pack item'); } - await importFile(new File([await res.blob()], item.name + '.glb'), item.name, 'glb'); + // 30c: the placed piece carries its pack reference (packRefs.js) + await importFile(new File([await res.blob()], item.name + '.glb'), item.name, 'glb', undefined, undefined, { + packRef: packRefFromUrl(item.glbUrl, { pack: item.packName, item: item.name }) + }); dismiss(); } catch { dismiss(); diff --git a/src/components/menu/Inspector.svelte b/src/components/menu/Inspector.svelte index 247b7987..6bbfd735 100644 --- a/src/components/menu/Inspector.svelte +++ b/src/components/menu/Inspector.svelte @@ -169,6 +169,7 @@ selectedObjects, backgroundColor, globalCamera, + orbitControls, viewMode, showGrid, pokeScene } from '../../stores/sceneStore'; // 16-P3: grid + snapping prefs (LOCAL, like the clip planes) @@ -1404,6 +1405,28 @@ if (!direction.lengthSq()) direction.set(-1, 1, 1).normalize(); flyTo(direction.multiplyScalar(distance).toArray(), [0, 0, 0]); } + /** + * 30c: the play SPAWN from the editor view — the point the view orbits around is where + * the player's feet go, and the camera's heading toward it is the way they face (yaw 0 + * looks down -Z, the fixed start's direction). + */ + function setSpawnFromView() { + /** @type {any} */ + const camera = $globalCamera; + /** @type {any} */ + const controls = $orbitControls; + const target = controls?.target; + if (!camera || !target) return; + const yaw = Math.atan2(-(target.x - camera.position.x), -(target.z - camera.position.z)); + const r2 = (/** @type {number} */ v) => Math.round(v * 100) / 100; + setScenePhysics({ play: { spawn: { pos: [r2(target.x), r2(target.y), r2(target.z)], yaw: Math.round(yaw * 1000) / 1000 } } }); + } + /** @param {any} spawn */ + function spawnText(spawn) { + if (!spawn) return 'Default — (0, 2, 3), facing −Z'; + const deg = Math.round((((spawn.yaw * 180) / Math.PI) % 360 + 360) % 360); + return '(' + spawn.pos.map((/** @type {number} */ v) => v.toFixed(1)).join(', ') + '), facing ' + deg + '°'; + } /** shared look for the small bookmark row buttons */ const bmBtn = 'shrink-0 rounded-sm bg-gray-700 px-1.5 py-0.5 text-xs text-gray-300 hover:bg-gray-600 disabled:opacity-40'; function resetView() { @@ -2605,6 +2628,24 @@ > Start the simulation when play mode opens + +
+ Spawn point + {spawnText($scenePlay.spawn)} +
+
+ + {#if $scenePlay.spawn} + + {/if} +

Shared: everyone entering play mode in this scene gets these.

diff --git a/src/components/play/PointerLockControls.svelte b/src/components/play/PointerLockControls.svelte index faa9a53a..a88e1e91 100644 --- a/src/components/play/PointerLockControls.svelte +++ b/src/components/play/PointerLockControls.svelte @@ -7,6 +7,7 @@ import { userdata, peers } from '../../stores/appStore' import { dungeonData, slideMove, spawnPointFor } from '$lib/dungeonPlay' import { resolvePlaySettings } from '$lib/playSettings' + import { scenePlay } from '$lib/scenePhysics' // 30 P3: free-cursor games never take the pointer — the real cursor aims import { playCursorFree } from '$lib/playCursor' import { inputClaims, getGamepadAxes } from '$lib/inputRuntime' @@ -205,6 +206,15 @@ rig.position.y = 0.8 rig.position.z = spawn.z } + } else if (rig) { + // 30c: the scene's own SPAWN (scenePhysics play.spawn: feet position + yaw). + // Absent = the fixed start, untouched. The rig lives in a group lifted 0.9, so + // an eye 0.8 above that is 1.7 above the feet (the dungeon line above). + const at = get(scenePlay)?.spawn + if (at) { + rig.position.set(at.pos[0], at.pos[1] + 0.8, at.pos[2]) + rig.quaternion.setFromEuler(new Euler(0, at.yaw ?? 0, 0, 'YXZ')) + } } }) } else { diff --git a/src/lib/commandsHandler.svelte.js b/src/lib/commandsHandler.svelte.js index f98f7d4c..a40bac20 100644 --- a/src/lib/commandsHandler.svelte.js +++ b/src/lib/commandsHandler.svelte.js @@ -17,6 +17,7 @@ import { hasAnimatedImport, sendAnimatedImport, setAnimationState, dropAllAnimat import { dropAllAnimations } from '$lib/animationPreview' import { parkAnimatedAtBase } from '$lib/flowRuntime' import { stripEditOverlays } from '$lib/editOverlays' +import { isPristinePackRef, stubElementOf } from '$lib/packRefs' import { runSceneClearHandlers } from '$lib/moduleSDK' import { annotations } from '$lib/annotationsHandler' import { isViewer, warnViewerReadOnly } from '$lib/objectPermissions' @@ -943,6 +944,12 @@ async function applyCreateObject(object, uuid, override, groupuuid, pos, rot, sc */ export function sendObjects(peerId, element, opts = {}) { let groupid; + if (peerId === null && isPristinePackRef(element)) { + // 30c: a placed KIT PIECE reaches every peer as its stub; each refills it from + // the pack (no GLTF of a 1 MB piece per placement — see packRefs.js) + peer.send({ type: 'object', element: stubElementOf(element), groupuuid: element.parent?.uuid, ...(opts.override ? { override: true } : {}) }); + return; + } if (peerId === null) { groupid = element.uuid; peer.send({type: 'group', name: element.name, uuid: element.uuid, groupparent: null, @@ -1039,6 +1046,19 @@ export function sendObject(conn, element, groupuuid, opts = {}) { objects.forEach(element => { // viewer perms: never sync a viewer's local-only objects to a peer if (element.userData && element.userData.__localOnly) return; + if (isPristinePackRef(element)) { + // 30c: a pristine KIT PIECE travels as a stub (one small message) and the + // receiver refills it from its pack — packRefs.js carries the measurement (one + // wall is 7.9 MB as toJSON). ObjectLoader path: the element's matrix is LOCAL, + // and the receiver adds it under `groupuuid`. + conn.send({ + type: 'object', + element: stubElementOf(element), + groupuuid: element.parent?.uuid, + ...heal + }); + return; + } if (hasAnimatedImport(element.uuid)) { // rigs travel as their original file bytes, one message sendAnimatedImport(conn, element, opts); @@ -1171,7 +1191,8 @@ function countObjects(element, sink) { objects = sceneObjects.children; } objects.forEach(element => { - if (element.type == "Group" && !hasAnimatedImport(element.uuid)) { + // 30c: a kit-piece stub is ONE message; its children are refilled, never sent + if (element.type == "Group" && !hasAnimatedImport(element.uuid) && !isPristinePackRef(element)) { countObjects(element, sink); } sink.push(element.uuid) diff --git a/src/lib/explorerDrop.js b/src/lib/explorerDrop.js index 8eba91af..b3de320d 100644 --- a/src/lib/explorerDrop.js +++ b/src/lib/explorerDrop.js @@ -6,6 +6,7 @@ import { peers, showToast, toastStore, stackOnDrop } from '../stores/appStore'; import { explorerItems, itemBlob } from './explorer'; import { prefabs, instantiatePrefab } from './prefabs'; import { importFile } from './fileHandler.svelte'; +import { packRefFromUrl } from './packRefs'; import { setObjectTexture } from './materialsHandler'; import { topLevelObjectOf } from './objectActions'; import { sceneHits, hitWorldNormal } from './scenePick'; @@ -205,7 +206,11 @@ async function placeExplorerPayload(payload, target) { dismiss(); return showToast('Could not fetch the pack item'); } - importFile(new File([await res.blob()], name + '.glb'), name, undefined, target.point ?? undefined); + // 30c: the placed piece carries its pack reference, so a save and the wire write it + // as a small stub instead of the whole model (packRefs.js) + importFile(new File([await res.blob()], name + '.glb'), name, undefined, target.point ?? undefined, undefined, { + packRef: packRefFromUrl(payload.url, { item: name }) + }); dismiss(); } catch { dismiss(); diff --git a/src/lib/fileHandler.svelte.js b/src/lib/fileHandler.svelte.js index a79248e6..2a085ab8 100644 --- a/src/lib/fileHandler.svelte.js +++ b/src/lib/fileHandler.svelte.js @@ -23,6 +23,8 @@ import { createGltfLoader, registerAnimatedImport, recordAnimatedImport, sendAni import { environment } from './environment'; import { parkAnimatedAtBase } from '$lib/flowRuntime'; import { stripEditOverlays } from '$lib/editOverlays'; +import { stampPackRef } from '$lib/packRefs'; +import { hashBytes } from '$lib/explorer'; import { saveFileBase } from '$lib/saveName'; import { peers, fixLight, loadingFile, showToast, showInfoToast, dismissToastById } from '../stores/appStore'; import { safeStorage } from './safeStorage'; @@ -666,7 +668,10 @@ function defaultImportName(extension, name) { * @param {any} file @param {string=} name @param {string=} ext - explicit extension when the blob has no name (Library) * @param {number[]=} position - world drop point (Explorer drag-out, 96) * @param {any[]=} extras - companion files picked/dropped alongside (.mtl + its textures) - * @param {{reduce?: boolean | import('./importBudget').ReductionPlan}} [opts] 26-F: import REDUCED + * @param {{reduce?: boolean | import('./importBudget').ReductionPlan, packRef?: import('./packRefs').PackRef | null}} [opts] + * 26-F: `reduce` imports REDUCED. 30c: `packRef` names the PACK ITEM this file is — the + * placed root then carries the reference (packRefs.js), so a save and the wire write it + * as a small stub. Ignored for an animated or a reduced import (neither IS the file). * @returns {Promise} the placed root's uuid, or null when nothing was placed */ export async function importFile(file, name, ext, position, extras, opts = {}) { @@ -703,6 +708,9 @@ export async function importFile(file, name, ext, position, extras, opts = {}) { return null; } try { + // 30c: stamp the pack reference BEFORE addImported, which is what replicates it + if (opts.packRef && !parsed.animated && typeof file?.arrayBuffer === 'function') + stampPackRef(parsed.root, opts.packRef, await hashBytes(await file.arrayBuffer())); if (parsed.animated) addAnimatedImport(parsed.animated.result, parsed.animated.buffer, label, parsed.animated.kind); else addImported(parsed.root, label, position); for (const note of parsed.notes) showToast(note); diff --git a/src/lib/packRefs.js b/src/lib/packRefs.js new file mode 100644 index 00000000..b252b9b9 --- /dev/null +++ b/src/lib/packRefs.js @@ -0,0 +1,546 @@ +// @ts-ignore - no bundled three type declarations (project-wide) +import * as THREE from 'three'; +// @ts-ignore - three addons ship no declarations here (project-wide) +import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'; +// @ts-ignore +import { DRACOLoader } from 'three/addons/loaders/DRACOLoader.js'; +// @ts-ignore +import { MeshoptDecoder } from 'three/addons/libs/meshopt_decoder.module.js'; +import { writable, get } from 'svelte/store'; +import { objectsGroup, pokeScene } from '../stores/sceneStore'; +import { showToast } from '../stores/appStore'; +import { PACKS_BASE } from './packs'; +import { hashBytes } from './explorer'; + +// 30c — KIT REFERENCES: a pack piece in a scene is a REFERENCE, not a copy. +// +// THE MEASUREMENT this module exists for: one placed `WallStone` (a 0.84 MB GLB from the +// architecture kit) serialized through `buildSessionPayload` as a 7.9 MB toJSON element — +// its three 1024² JPEGs come back as PNG data URLs (3.3 + 2.3 + 1.9 MB) — and 5.9 MB +// zipped. `objects` is one toJSON() PER top-level child, so two copies of one wall share +// nothing, and a castle of ~100 kit pieces would be a ~600 MB file. The wire has the same +// shape (a session load sends each element; a placement sends each mesh as a GLTF). +// +// So a piece placed from a pack carries WHERE IT CAME FROM: +// userData.packRef = {pack, item, path, hash, kids} +// `path` is relative to PACKS_BASE (an absolute URL passes through — the Khronos rows), +// `hash` is the content hash of the bytes it was built from, `kids` the uuids of its +// descendants in traverse order (so every peer rebuilds the same child uuids). A save, a +// session-load broadcast and a late joiner's sync write a PRISTINE piece as a STUB: an +// empty Group with the root's uuid, transform and userData plus `packStub: true`. Every +// peer (and the loader of a file) REFILLS a stub from the pack URL — fetched and parsed +// once per piece, and every copy shares its textures, so twelve walls upload three +// textures instead of thirty-six. +// +// PRISTINE is measured, never assumed: a piece whose descendants differ from the file it +// came from (a mesh edit, a new colour, a dropped texture, a moved child) is written in +// full exactly as before — `fingerprintOf` over the descendants' structure, geometry +// checksums, material parameters and an 8×8 sample of every texture, compared against the +// same fingerprint of the parsed file. Only the ROOT's own transform, name and userData +// are free, and those ride the stub. +// +// AN OLDER BUILD reading a stub gets an empty group where the piece was (nothing throws); +// a peer that cannot reach the pack keeps the stub, says so once, and SAVES it back as a +// stub — a reference is never silently turned into nothing. +// +// A LEAF: three + its loaders, svelte/store, two stores, packs (for PACKS_BASE) and +// explorer (for the one content hash) — both leaves themselves. sessions and +// commandsHandler import this; nothing here reaches back. + +/** + * @typedef {{pack: string, item: string, path: string, hash?: string, kids?: string[]}} PackRef + */ + +/** How many refills are in flight — the author script and the suite wait on it. + * @type {import('svelte/store').Writable} */ +export const packRefsPending = writable(0); + +/** url -> the parsed file ({hash, scene}); a failure is forgotten so a retry can win + * @type {Map>} */ +const templates = new Map(); +/** content hash -> the fingerprint of a pristine copy + * @type {Map} */ +const fingerprints = new Map(); +/** image-bytes key -> the ONE texture every piece painted with those bytes uses + * @type {Map} */ +const sharedTextures = new Map(); +/** root uuid -> its refill, so two scans never fill one root twice + * @type {Map>} */ +const filling = new Map(); +/** urls already reported as unreachable (one toast per piece, not per copy) */ +const reported = new Set(); + +/** @param {any} object @returns {PackRef | null} */ +export function packRefOf(object) { + const ref = object?.userData?.packRef; + return ref && typeof ref.path === 'string' && ref.path ? ref : null; +} + +/** Where a reference's bytes live. @param {PackRef} ref */ +export function packRefUrl(ref) { + if (/^https?:\/\//.test(ref.path)) return ref.path; + return String(PACKS_BASE).replace(/\/+$/, '') + '/' + ref.path.replace(/^\/+/, ''); +} + +/** + * The reference for a pack item fetched from `url`: relative to PACKS_BASE when it lives + * there (so a scene follows the pack to whatever ref the build serves), absolute otherwise. + * @param {string} url @param {{pack?: string, item?: string}} [names] + * @returns {PackRef | null} + */ +export function packRefFromUrl(url, names = {}) { + if (!url || typeof url !== 'string') return null; + const base = String(PACKS_BASE).replace(/\/+$/, '') + '/'; + let path = url; + if (url.startsWith(base)) path = url.slice(base.length); + else if (!/^https?:\/\//.test(url)) return null; + const pack = names.pack || (path === url ? '' : path.split('/')[0]) || 'pack'; + const item = names.item || (path.split('/').pop() ?? '').replace(/\.\w+$/, '') || 'item'; + return { pack, item, path }; +} + +function createLoader() { + const loader = new GLTFLoader(); + const draco = new DRACOLoader(); + draco.setDecoderPath('/draco/'); + loader.setDRACOLoader(draco); + loader.setMeshoptDecoder(MeshoptDecoder); + return loader; +} + +/** + * The bytes of every IMAGE in a GLB, by glTF image index — read from the file's own JSON + * chunk, because the parsed texture no longer knows them. Empty for a .gltf or anything + * unexpected (sharing is an optimisation; nothing depends on it). + * @param {ArrayBuffer} buffer @returns {Uint8Array[]} + */ +function glbImages(buffer) { + try { + const view = new DataView(buffer); + if (view.getUint32(0, true) !== 0x46546c67) return []; + const jsonLength = view.getUint32(12, true); + const json = JSON.parse(new TextDecoder().decode(new Uint8Array(buffer, 20, jsonLength))); + const binStart = 20 + jsonLength + 8; + return (json.images ?? []).map((/** @type {any} */ image) => { + const bv = json.bufferViews?.[image.bufferView]; + return bv ? new Uint8Array(buffer, binStart + (bv.byteOffset ?? 0), bv.byteLength) : new Uint8Array(); + }); + } catch { + return []; + } +} + +const MAP_SLOTS = ['map', 'normalMap', 'roughnessMap', 'metalnessMap', 'emissiveMap', 'aoMap', 'alphaMap', 'bumpMap', 'displacementMap', 'lightMap']; + +/** + * One texture per distinct IMAGE across every kit piece: the architecture kit's six + * sandstone walls carry byte-identical JPEGs, so without this each wall variant uploaded + * its own three. Keyed by the image bytes' hash plus everything that makes two textures + * of one image differ (colour space, sampler, channel, flip). + * @param {any} gltf @param {ArrayBuffer} buffer + */ +async function shareTextures(gltf, buffer) { + const images = glbImages(buffer); + if (!images.length) return; + const json = gltf.parser?.json; + /** @type {string[]} */ + const imageKeys = await Promise.all( + images.map(async (bytes) => (bytes.byteLength ? hashBytes(bytes.slice().buffer) : '')) + ); + gltf.scene.traverse((/** @type {any} */ node) => { + if (!node.isMesh) return; + for (const material of Array.isArray(node.material) ? node.material : [node.material]) { + for (const slot of MAP_SLOTS) { + const texture = material?.[slot]; + if (!texture) continue; + const index = gltf.parser?.associations?.get(texture)?.textures; + const source = index == null ? undefined : json?.textures?.[index]?.source; + const imageKey = source == null ? '' : imageKeys[source]; + if (!imageKey) continue; + const key = [imageKey, texture.colorSpace, texture.wrapS, texture.wrapT, texture.magFilter, texture.minFilter, texture.channel, texture.flipY].join('|'); + const shared = sharedTextures.get(key); + if (shared && shared !== texture) { + material[slot] = shared; + texture.dispose(); + } else if (!shared) sharedTextures.set(key, texture); + } + } + }); +} + +/** + * Fetch + parse a pack file ONCE per url, and register the fingerprint of a pristine copy + * under the hash of the bytes actually fetched. + * @param {string} url @returns {Promise<{hash: string, scene: any}>} + */ +export function loadPackTemplate(url) { + let job = templates.get(url); + if (!job) { + job = (async () => { + const res = await fetch(url); + if (!res.ok) throw new Error('HTTP ' + res.status); + const buffer = await res.arrayBuffer(); + const hash = await hashBytes(buffer); + /** @type {any} */ + const gltf = await new Promise((resolve, reject) => createLoader().parse(buffer, '', resolve, reject)); + // an animated rig replicates as its BYTES (animatedImports) — never a reference + if (gltf.animations?.length) throw new Error('animated models are not referenced'); + await shareTextures(gltf, buffer); + const scene = gltf.scene; + scene.updateMatrixWorld(true); + fingerprints.set(hash, fingerprintOf(scene)); + return { hash, scene }; + })(); + templates.set(url, job); + job.catch(() => templates.delete(url)); + } + return job; +} + +// ---- the fingerprint --------------------------------------------------------- + +/** Seven significant digits, and anything under 1e-6 is zero — a GLTF round trip turns a + * rotation matrix's -1.14e-9 into -1.42e-9, which is the same matrix. + * @param {number} v */ +const num = (v) => + typeof v === 'number' && Number.isFinite(v) ? (Math.abs(v) < 1e-6 ? 0 : Number(v.toPrecision(7))) : String(v); + +/** Two weighted sums over a typed array — equal arrays give equal sums, and a moved + * vertex moves the second one even when it keeps the first. + * @param {ArrayLike} array */ +function checksum(array) { + let a = 0; + let b = 0; + for (let i = 0; i < array.length; i++) { + a += array[i]; + b += array[i] * ((i % 251) + 1); + } + return num(a) + '/' + num(b); +} + +/** @param {any} geometry */ +function geometryKey(geometry) { + if (!geometry?.attributes) return '-'; + const names = Object.keys(geometry.attributes).sort(); + const parts = names.map((name) => { + const attr = geometry.attributes[name]; + return name + ':' + attr.itemSize + ':' + attr.count + ':' + (attr.normalized ? 1 : 0) + ':' + checksum(attr.array); + }); + const index = geometry.index ? 'i' + geometry.index.count + ':' + checksum(geometry.index.array) : 'noindex'; + const groups = (geometry.groups ?? []).map((/** @type {any} */ g) => g.start + '+' + g.count + '@' + (g.materialIndex ?? 0)).join(';'); + return parts.join(',') + '|' + index + '|' + groups; +} + +/** 8×8 RGBA of a texture's image, cached per image. Null when it cannot be read (no + * canvas, a tainted or odd image) — then the texture is compared by its shape alone. + * @type {WeakMap} */ +const samples = new WeakMap(); +/** @param {any} texture @returns {number[] | null} */ +function textureSample(texture) { + const image = texture?.image; + if (!image || typeof image !== 'object') return null; + if (samples.has(image)) return samples.get(image) ?? null; + /** @type {number[] | null} */ + let out = null; + try { + if (typeof document !== 'undefined' && (image.width ?? 0) > 0) { + const canvas = document.createElement('canvas'); + canvas.width = 8; + canvas.height = 8; + const ctx = canvas.getContext('2d', { willReadFrequently: true }); + if (ctx) { + ctx.drawImage(image, 0, 0, 8, 8); + out = Array.from(ctx.getImageData(0, 0, 8, 8).data); + } + } + } catch { + out = null; + } + samples.set(image, out); + return out; +} + +/** @param {any} texture */ +function textureKey(texture) { + if (!texture) return '-'; + const image = texture.image ?? {}; + return [ + (image.width ?? '?') + 'x' + (image.height ?? '?'), + texture.flipY ? 1 : 0, + texture.wrapS, + texture.wrapT, + num(texture.repeat?.x) + ',' + num(texture.repeat?.y), + num(texture.offset?.x) + ',' + num(texture.offset?.y), + num(texture.rotation), + texture.channel ?? 0 + ].join(':'); +} + +/** @param {any} color */ +const colorKey = (color) => (color?.getHexString ? color.getHexString() : '-'); + +/** @param {any} material @param {(number[] | null)[]} sink */ +function materialKey(material, sink) { + if (!material) return '-'; + const head = [ + material.type, + colorKey(material.color), + num(material.roughness), + num(material.metalness), + colorKey(material.emissive), + num(material.emissiveIntensity), + num(material.opacity), + material.transparent ? 1 : 0, + material.side, + num(material.alphaTest), + material.vertexColors ? 1 : 0, + material.flatShading ? 1 : 0, + material.wireframe ? 1 : 0, + num(material.normalScale?.x) + ',' + num(material.normalScale?.y), + num(material.envMapIntensity) + ].join(','); + const maps = MAP_SLOTS.map((slot) => { + const texture = material[slot]; + if (texture) sink.push(textureSample(texture)); + return slot + '=' + textureKey(texture); + }).join(','); + return head + '|' + maps; +} + +/** + * What must match for a copy to be the file it came from: every DESCENDANT's type, name, + * visibility and local matrix, each mesh's geometry and material(s), plus texture samples + * (compared with a tolerance, because an autosave round trip re-encodes the JPEGs). The + * root itself is excluded — its transform, name and userData are the placement and ride + * the stub. + * @param {any} root @returns {{key: string, samples: (number[] | null)[]}} + */ +export function fingerprintOf(root) { + /** @type {(number[] | null)[]} */ + const sink = []; + /** @type {string[]} */ + const lines = []; + root.traverse((/** @type {any} */ node) => { + if (node === root) return; + let line = node.type + ':' + node.name + ':' + (node.visible ? 1 : 0) + ':' + node.matrix.elements.map(num).join(','); + if (node.isMesh || node.isLine || node.isPoints) { + line += '|' + geometryKey(node.geometry); + const materials = Array.isArray(node.material) ? node.material : [node.material]; + line += '|' + materials.map((/** @type {any} */ m) => materialKey(m, sink)).join('||'); + } + lines.push(line); + }); + return { key: lines.join('\n'), samples: sink }; +} + +/** Mean absolute difference per channel under which two samples are one image. */ +const SAMPLE_TOLERANCE = 10; + +/** @param {{key: string, samples: (number[] | null)[]}} a @param {{key: string, samples: (number[] | null)[]}} b */ +export function sameFingerprint(a, b) { + if (!a || !b || a.key !== b.key || a.samples.length !== b.samples.length) return false; + for (let i = 0; i < a.samples.length; i++) { + const x = a.samples[i]; + const y = b.samples[i]; + if (!x || !y) continue; // unreadable on one side: the shape key already matched + if (x.length !== y.length) return false; + let diff = 0; + for (let k = 0; k < x.length; k++) diff += Math.abs(x[k] - y[k]); + if (diff / x.length > SAMPLE_TOLERANCE) return false; + } + return true; +} + +/** The uuids of a root's descendants in traverse order. @param {any} root */ +function descendantUuids(root) { + /** @type {string[]} */ + const out = []; + root.traverse((/** @type {any} */ node) => { + if (node !== root) out.push(node.uuid); + }); + return out; +} + +/** + * Stamp a freshly imported pack piece BEFORE it replicates: its reference, its children's + * uuids, and — since it IS the file right now — the pristine fingerprint for its hash. + * @param {any} root @param {PackRef} ref @param {string} hash the bytes it was parsed from + */ +export function stampPackRef(root, ref, hash) { + if (!root || !ref) return; + root.updateMatrixWorld(true); + root.userData = { ...(root.userData ?? {}), packRef: { pack: ref.pack, item: ref.item, path: ref.path, hash, kids: descendantUuids(root) } }; + if (hash && !fingerprints.has(hash)) fingerprints.set(hash, fingerprintOf(root)); +} + +/** + * Is this object a reference that can travel as a stub? A hollow stub (not refilled yet, + * or its pack was unreachable) always is; a filled one only when it still IS the file. + * @param {any} object @returns {boolean} + */ +export function isPristinePackRef(object) { + const ref = packRefOf(object); + if (!ref) return false; + if (object.userData.packStub) return object.children.length === 0; + const fingerprint = ref.hash ? fingerprints.get(ref.hash) : null; + if (!fingerprint) return false; + object.updateMatrixWorld(true); + return sameFingerprint(fingerprint, fingerprintOf(object)); +} + +/** + * The stub: an empty Group carrying the root's identity, transform and userData, as a + * toJSON element — the shape `objects` and the `object` message already carry, so an + * older reader parses it into an empty group rather than failing. + * @param {any} root @returns {any} + */ +export function stubElementOf(root) { + const ref = /** @type {PackRef} */ (packRefOf(root)); + const hollow = new THREE.Group(); + hollow.uuid = root.uuid; + hollow.name = root.name; + hollow.position.copy(root.position); + hollow.quaternion.copy(root.quaternion); + hollow.scale.copy(root.scale); + hollow.visible = root.visible; + hollow.renderOrder = root.renderOrder; + hollow.layers.mask = root.layers.mask; + const kids = root.userData.packStub ? ref.kids ?? [] : descendantUuids(root); + hollow.userData = JSON.parse(JSON.stringify({ ...root.userData, packRef: { ...ref, kids }, packStub: true })); + hollow.updateMatrix(); + return hollow.toJSON(); +} + +/** How many nodes a stub stands for (the root + what it refills to), for object budgets. + * @param {any} node a serialized node (element.object) */ +export function stubNodeCount(node) { + const kids = node?.userData?.packStub ? node?.userData?.packRef?.kids : null; + return Array.isArray(kids) ? kids.length : 0; +} + +/** A child uuid every peer computes alike: the root's uuid with its last 12 hex digits + * replaced by the child's index. @param {string} rootUuid @param {number} index */ +function derivedUuid(rootUuid, index) { + return String(rootUuid).slice(0, 24) + (index + 1).toString(16).padStart(12, '0'); +} + +/** A private copy of a parsed piece: geometry and material cloned (the edit tools mutate + * both IN PLACE, and a copy must never move its siblings), textures shared. + * @param {any} scene */ +function instanceOf(scene) { + const copy = scene.clone(true); + copy.traverse((/** @type {any} */ node) => { + if (!node.isMesh) return; + node.geometry = node.geometry.clone(); + node.material = Array.isArray(node.material) ? node.material.map((/** @type {any} */ m) => m.clone()) : node.material.clone(); + }); + return copy; +} + +/** + * Refill a stub from its pack. Resolves true when it filled, false when it could not (or + * is no longer in the scene, or was filled meanwhile). The children take the recorded + * uuids, so every peer converges on the same ones. + * @param {any} root @returns {Promise} + */ +export function fillPackRef(root) { + if (!root?.userData?.packStub) return Promise.resolve(false); + const ref = packRefOf(root); + if (!ref) return Promise.resolve(false); + const inflight = filling.get(root.uuid); + if (inflight) return inflight; + const url = packRefUrl(ref); + packRefsPending.update((n) => n + 1); + const job = (async () => { + try { + const { hash, scene } = await loadPackTemplate(url); + // replaced (a clear, a reload of the same file) or filled while we fetched + if (!root.userData.packStub || root.children.length) return false; + if (get(objectsGroup)?.getObjectByProperty('uuid', root.uuid) !== root) return false; + const copy = instanceOf(scene); + /** @type {any[]} */ + const nodes = []; + copy.traverse((/** @type {any} */ node) => { + if (node !== copy) nodes.push(node); + }); + const kids = Array.isArray(ref.kids) ? ref.kids : []; + // a stub that lost its kids (a merge import re-uuids the tree) still has to give + // every peer the SAME child uuids: derive them from the root's + nodes.forEach((node, i) => { + node.uuid = typeof kids[i] === 'string' && kids[i] ? kids[i] : derivedUuid(root.uuid, i); + }); + const hideShadow = root.userData.shadow === false; + for (const child of [...copy.children]) root.add(child); + if (hideShadow) + root.traverse((/** @type {any} */ node) => { + if (!node.isMesh) return; + node.castShadow = false; + node.receiveShadow = false; + node.userData.shadow = false; + }); + delete root.userData.packStub; + root.userData.packRef = { ...ref, hash, kids: nodes.map((node) => node.uuid) }; + pokeScene(); + return true; + } catch (error) { + if (!reported.has(url)) { + reported.add(url); + console.log('kit piece could not be loaded: ' + url, error); + showToast('Could not load the kit piece "' + (ref.item || ref.path) + '" — its pack is unreachable. It stays in the scene as a placeholder.'); + } + return false; + } finally { + filling.delete(root.uuid); + packRefsPending.update((n) => n - 1); + } + })(); + filling.set(root.uuid, job); + return job; +} + +/** Resolves once no refill is in flight (the author script and the suites wait on it). */ +export function packRefsSettled() { + return new Promise((resolve) => { + /** @type {any} */ + let off = null; + off = packRefsPending.subscribe((n) => { + if (n > 0) return; + queueMicrotask(() => off?.()); + resolve(true); + }); + }); +} + +/** Refill every stub in the scene, and learn the pristine fingerprint of every piece whose + * file we have not parsed yet (a scene restored from the autosave), so a later save can + * write it as a stub again. */ +function scan() { + const group = get(objectsGroup); + if (!group) return; + group.traverse((/** @type {any} */ node) => { + const ref = packRefOf(node); + if (!ref) return; + if (node.userData.packStub) { + if (!node.children.length) fillPackRef(node); + } else if (ref.hash && !fingerprints.has(ref.hash)) { + loadPackTemplate(packRefUrl(ref)).catch(() => {}); + } + }); +} + +let started = false; +/** @type {any} */ +let scanTimer = null; + +/** Watch the scene for stubs (a loaded file, a peer's broadcast, a late joiner's sync). */ +export function startPackRefs() { + if (started || typeof window === 'undefined') return; + started = true; + objectsGroup.subscribe(() => { + if (scanTimer) return; + scanTimer = setTimeout(() => { + scanTimer = null; + scan(); + }, 30); + }); +} diff --git a/src/lib/scenePhysics.js b/src/lib/scenePhysics.js index 0bc8c1d5..9136d8f1 100644 --- a/src/lib/scenePhysics.js +++ b/src/lib/scenePhysics.js @@ -65,6 +65,22 @@ function nullableNum(v, lo, hi) { return Math.max(lo, Math.min(hi, n)); } +/** + * 30c: the play SPAWN — where desktop play mode puts the player's feet, and which way they + * face (`yaw`, radians about +Y; 0 looks down -Z, the fixed start's direction). Null when + * absent or malformed, and then the block OMITS the key, so a scene that never set one + * saves byte-identically and play starts where it always has. + * @param {any} raw @returns {{pos: number[], yaw: number} | null} + */ +function spawnOf(raw) { + const pos = raw?.pos; + if (!Array.isArray(pos) || pos.length !== 3) return null; + const out = pos.map((v) => Number(v)); + if (!out.every((v) => Number.isFinite(v) && Math.abs(v) <= 100000)) return null; + const yaw = Number(raw.yaw ?? 0); + return { pos: out, yaw: Number.isFinite(yaw) ? yaw : 0 }; +} + /** @param {any} v @param {boolean} fallback */ function bool(v, fallback) { return typeof v === 'boolean' ? v : fallback; @@ -148,9 +164,11 @@ export function normalizeScenePhysics(raw) { grounded: bool(playRaw.grounded, d.play.grounded), simOnPlay: bool(playRaw.simOnPlay, d.play.simOnPlay), // 30 P3: present only when free (see PLAY_CURSORS) - ...(pick(playRaw.cursor, PLAY_CURSORS, 'locked') === 'free' ? { cursor: 'free' } : {}) + ...(pick(playRaw.cursor, PLAY_CURSORS, 'locked') === 'free' ? { cursor: 'free' } : {}), + // 30c: present only when set (see spawnOf) + ...(spawnOf(playRaw.spawn) ? { spawn: spawnOf(playRaw.spawn) } : {}) }, - ['interaction', 'grounded', 'simOnPlay', 'cursor'] + ['interaction', 'grounded', 'simOnPlay', 'cursor', 'spawn'] ), // A1: the 20 ceiling is throwVelocity's MAX_LINVEL, restated rather than imported — // this module is store-only and the response clamps through clampThrow anyway @@ -195,7 +213,7 @@ scenePhysicsState_.subscribe((s) => sceneGravity.set(s.gravity)); export const scenePhysicsGround = derived(scenePhysicsState_, (s) => s.ground); /** out-of-bounds config. NOT named `sceneBounds` — that is sceneBounds.js */ export const scenePhysicsBounds = derived(scenePhysicsState_, (s) => s.bounds); -/** play-mode block ({interaction, grounded, simOnPlay, cursor?: 'free'}) */ +/** play-mode block ({interaction, grounded, simOnPlay, cursor?: 'free', spawn?: {pos, yaw}}) */ export const scenePlay = derived(scenePhysicsState_, (s) => s.play); /** A1: the knock block ({enabled, gain, maxSpeed, minSpeed, radius, spin, predict}) */ export const sceneKnock = derived(scenePhysicsState_, (s) => s.knock); diff --git a/src/lib/sessions.js b/src/lib/sessions.js index 4dfc1f4c..10fa8db0 100644 --- a/src/lib/sessions.js +++ b/src/lib/sessions.js @@ -6,6 +6,7 @@ import { serializeGraphs, copyGraphFrom } from './flowGraphs'; import { serializeNode, serializeEdge, sendNodes } from './nodesHandler'; import { parkAnimatedAtBase } from './flowRuntime'; import { stripEditOverlays } from './editOverlays'; +import { isPristinePackRef, stubElementOf, stubNodeCount } from './packRefs'; // B7: a spawner's copies exist only while the world runs — never in a scene file import { isTransient } from './transientObjects'; import { @@ -263,7 +264,9 @@ export function buildSessionPayload(name) { // scene file as permanent content objects: (group?.children ?? []) .filter((/** @type {any} */ child) => !animatedUuids.includes(child.uuid) && !isTransient(child)) - .map((/** @type {any} */ child) => child.toJSON()), + // 30c: a pristine KIT PIECE is written as a stub its pack refills (packRefs.js + // has the measurement: one wall is 7.9 MB as toJSON) + .map((/** @type {any} */ child) => (isPristinePackRef(child) ? stubElementOf(child) : child.toJSON())), animated: animatedImportsSnapshot(group), // authored movement tracks (the Animation window) were never saved animations: animationsSnapshot(), @@ -356,7 +359,7 @@ export function buildSelectionPayload(name, uuids) { const objects = roots .map((uuid) => group?.getObjectByProperty('uuid', uuid)) .filter((/** @type {any} */ object) => object && !animatedUuids.includes(object.uuid) && !isTransient(object)) - .map((/** @type {any} */ object) => object.toJSON()); + .map((/** @type {any} */ object) => (isPristinePackRef(object) ? stubElementOf(object) : object.toJSON())); const clips = animationsSnapshot(); /** @type {any} */ const animations = {}; @@ -1201,6 +1204,9 @@ export function importObjects(payload, indices) { uuidMap.set(node.uuid, fresh); node.uuid = fresh; }); + // 30c: a kit-piece stub refills its children under the RECORDED uuids — which are + // the file's, and this import is a merge; let the refill derive fresh ones + if (object.userData?.packStub && object.userData.packRef) object.userData.packRef.kids = []; group.add(object); recordObjectPresence('create', object); if (peer) peer.send({ type: 'object', element: object.toJSON() }); @@ -1301,6 +1307,18 @@ export async function applySession(payload, opts = {}) { const restored = await restoreSessionLibrary(payload); if (restored) showToast('Restored ' + restored + ' library file' + (restored === 1 ? '' : 's')); } + // 30c: a scene REPLACE ends the run. The physics world holds bodies for the objects + // about to be wiped and none for the ones arriving, and sim-on-play skips a start while + // `simulating` is still true — so after playing one level, the next one loaded was + // walked THROUGH (measured: the walker crossed the tavern's walls and stairs as if they + // were not there). Stopped the quiet way a yielded run stops (no settling moves, no + // undo entry for a layout that is being thrown away); the next Play builds a new world. + try { + const physics = await import('./physics'); + if (get(physics.simulating)) physics.stopSimulation({ yielded: true }); + } catch { + /* physics failing to load must never block a scene load */ + } if (replicate) sceneCommand('/clear all'); // replicated clear (objects + module content) else clearSceneLocal(); /** @type {any} */ @@ -1436,6 +1454,8 @@ export function countPayloadObjects(payload) { const walk = (node) => { if (!node) return; n++; + // 30c: a kit-piece stub stands for the nodes it refills to + n += stubNodeCount(node); for (const kid of node.children ?? []) walk(kid); }; for (const element of payload?.objects ?? []) { diff --git a/tests/e2e/level-templates.test.cjs b/tests/e2e/level-templates.test.cjs new file mode 100644 index 00000000..d95083d6 --- /dev/null +++ b/tests/e2e/level-templates.test.cjs @@ -0,0 +1,258 @@ +// 30c level design — the three GENERAL-tab levels built from the kits (Castle Courtyard, +// Forest Clearing, Tavern Interior; defs in scripts/level-templates.cjs). For each one: +// 1. it loads from the Templates modal (the real card click) and every kit piece refills +// from its pack (packRefs.js), with the file a fraction of what the pieces weigh; +// 2. Play puts you on the level's SPAWN (scenePhysics play.spawn) with the sim running; +// 3. the play frame is readable — centre luminance >= 0.25, round 1's bar (fork 11) — +// and no quarter of the frame is black (no void, no black corner); +// 4. the walker CANNOT pass a wall, and CAN pass the openings built for it (an archway +// and a staircase to a rampart, a doorway into a hut and a footbridge, a staircase to +// a balcony) — the custom colliders are the reason both halves hold. +// +// The staged scenes are served to the Templates modal by page.route (the templates-modal +// suite's idiom) from LEVELS_DIR (default: the lane's staging folder), and the kits — until +// the pack PRs are merged and format-1 is retagged — from PACKS_DIR when the build's own +// PACKS_BASE is the CDN. SKIPS (never fails) when LEVELS_DIR has no index. +// +// APP_URL=https://theprototype.app:5255/ node tests/e2e/level-templates.test.cjs +// LEVELS=castle-courtyard (a subset) +const fs = require('fs'); +const path = require('path'); +const h = require('./helpers.cjs'); + +const LEVELS_DIR = process.env.LEVELS_DIR || '/home/deck/.code/theprototype-app/cloud-lane-30-staging/30c-levels'; +const PACKS_DIR = process.env.PACKS_DIR || '/home/deck/.code/lanes-30/levels-packs'; +const ONLY = (process.env.LEVELS || '').split(',').filter(Boolean); + +// what each level is walked through: [label, start {x, z, yaw}, key, ms, predicate(end), detail] +// yaw: 0 looks -Z, +PI/2 looks -X, PI looks +Z, -PI/2 looks +X (the spawn convention) +const PI = Math.PI; +// an object only that level has — what the load waits for, so a check never reads the +// PREVIOUS level while the new one is still arriving +const MARKER = { 'castle-courtyard': 'Castle gate', 'forest-clearing': 'Footbridge', 'tavern-interior': 'Balcony stairs' }; +const WALKS = { + 'castle-courtyard': [ + { label: 'the walker cannot walk through the south wall by the gate', at: [0, 3], yaw: PI, ms: 2500, pass: (e) => e.z < 5.8, moved: (b, e) => e.z - b.z > 1.2 }, + { label: 'the walker cannot walk through the west curtain wall', at: [-4.8, 1.5], yaw: PI / 2, ms: 2500, pass: (e) => e.x > -7.9, moved: (b, e) => b.x - e.x > 1.5 }, + { label: 'the walker passes the ARCHWAY and climbs the stairs onto the rampart', at: [-5, -4.6], yaw: 0, ms: 4500, pass: (e) => e.feet > 2.6 && e.z < -11.2, moved: (b, e) => b.z - e.z > 5 }, + { label: 'on the rampart the battlements hold the walker in', at: [0, -12.8], y: 3, yaw: 0, ms: 2000, pass: (e) => e.z > -13.95 && e.feet > 2.6, moved: () => true } + ], + 'forest-clearing': [ + { label: 'the walker cannot walk through the hut wall', at: [16, -7], yaw: PI / 2, ms: 2500, pass: (e) => e.x > 14.1, moved: (b, e) => b.x - e.x > 1 }, + { label: 'the walker crosses the FOOTBRIDGE over the pond neck', at: [0, -6], yaw: -PI / 2, ms: 3000, pass: (e) => e.x > 5.3, moved: (b, e) => e.x - b.x > 5 }, + { label: 'the walker enters the hut through its DOORWAY', at: [8, -6], yaw: -PI / 2, ms: 2200, pass: (e) => e.x > 10.6, moved: (b, e) => e.x - b.x > 2.5 } + ], + 'tavern-interior': [ + { label: 'the walker cannot walk through the front wall', at: [0, 3], yaw: PI, ms: 2000, pass: (e) => e.z < 3.9, moved: (b, e) => e.z - b.z > 0.3 }, + { label: 'the walker cannot walk through the east wall of the kitchen', at: [3.4, -2.6], yaw: -PI / 2, ms: 2500, pass: (e) => e.x < 5.9, moved: (b, e) => e.x - b.x > 1 }, + { label: 'the walker climbs the oak STAIRS to the balcony', at: [-5, 1.3], yaw: 0, ms: 4500, pass: (e) => e.feet > 2.7 && e.z < -3.2, moved: (b, e) => b.z - e.z > 3.5 }, + { label: 'the walker goes through the kitchen DOORWAY', at: [1.1, 1], yaw: -PI / 2, ms: 2200, pass: (e) => e.x > 2.6, moved: (b, e) => e.x - b.x > 1.5 } + ] +}; + +/** the rig's world position (feet = eye - 1.7) */ +const rig = (page) => + page.evaluate(() => { + const s = window.__stores; + /** @type {any} */ let cam; + s.playerCam.subscribe((c) => (cam = c))(); + if (!cam) return null; + const w = cam.getWorldPosition(new s.THREE.Vector3()); + return { x: w.x, y: w.y, z: w.z, feet: w.y - 1.7 }; + }); + +/** put the rig's FEET at (x, y, z), level, facing `yaw` */ +const placeRig = (page, x, y, z, yaw) => + page.evaluate( + ({ x, y, z, yaw }) => { + const s = window.__stores; + /** @type {any} */ let cam; + s.playerCam.subscribe((c) => (cam = c))(); + const v = new s.THREE.Vector3(x, y + 1.7, z); + cam.parent?.worldToLocal(v); + cam.position.copy(v); + cam.quaternion.setFromEuler(new s.THREE.Euler(0, yaw, 0, 'YXZ')); + cam.updateMatrixWorld(true); + return true; + }, + { x, y, z, yaw } + ); + +/** centre-clip luminance + the darkest quadrant of a real screenshot */ +async function frameLight(page) { + return page.evaluate(async (b64) => { + const bmp = await createImageBitmap(await (await fetch('data:image/png;base64,' + b64)).blob()); + const c = document.createElement('canvas'); + c.width = bmp.width; + c.height = bmp.height; + const x = c.getContext('2d'); + x.drawImage(bmp, 0, 0); + const lum = (/** @type {number} */ x0, /** @type {number} */ y0, /** @type {number} */ w, /** @type {number} */ hgt) => { + const d = x.getImageData(x0, y0, w, hgt).data; + let sum = 0; + for (let i = 0; i < d.length; i += 4) sum += 0.2126 * d[i] + 0.7152 * d[i + 1] + 0.0722 * d[i + 2]; + return sum / (d.length / 4) / 255; + }; + const W = bmp.width; + const Hh = bmp.height; + const centre = lum(Math.round(W / 2 - 180), Math.round(Hh / 2 - 180), 360, 360); + const quads = [lum(0, 0, W / 2, Hh / 2), lum(W / 2, 0, W / 2, Hh / 2), lum(0, Hh / 2, W / 2, Hh / 2), lum(W / 2, Hh / 2, W / 2, Hh / 2)]; + return { centre, darkest: Math.min(...quads), quads }; + }, (await page.screenshot()).toString('base64')); +} + +h.run(async () => { + const indexFile = path.join(LEVELS_DIR, 'index.json'); + if (!fs.existsSync(indexFile)) { + console.log('SKIP no staged levels at ' + LEVELS_DIR + ' (author them: see the handover)'); + console.log('ALL PASS'); + return; + } + const index = JSON.parse(fs.readFileSync(indexFile, 'utf8')); + const levels = (index.templates ?? []).filter((t) => WALKS[t.slug] && (!ONLY.length || ONLY.includes(t.slug))); + h.check(levels.length > 0, `the staged index lists the levels (${levels.map((l) => l.slug).join(', ')})`); + + const browser = await h.launch({ args: h.GPU_ARGS }); + // warm the lazy rapier import (the physics-suite ritual): Play must have a world at once + { + const warm = await h.setupPage(browser, 'warm'); + await warm.page.evaluate(() => window.__stores.physics.warmup().catch(() => {})); + await warm.page.waitForTimeout(3000); + await warm.ctx.close(); + } + const A = await h.setupPage(browser, 'A', { context: { viewport: { width: 1540, height: 774 } } }); + const page = A.page; + // the Templates modal's feed -> the staged folder; the kits -> PACKS_DIR when this build + // reads them from the CDN (a relative pack path resolves against PACKS_BASE) + await page.route('**/cdn.jsdelivr.net/**', (route) => { + const url = route.request().url(); + const scenes = url.match(/\/theprototype-app\/scenes@[^/]+\/(.*)$/); + if (scenes) { + const file = path.join(LEVELS_DIR, decodeURIComponent(scenes[1])); + if (scenes[1] === 'index.json') return route.fulfill({ json: index }); + if (fs.existsSync(file)) return route.fulfill({ body: fs.readFileSync(file) }); + return route.fulfill({ status: 404 }); + } + const packs = url.match(/\/theprototype-app\/packs@[^/]+\/(.*)$/); + if (packs && fs.existsSync(PACKS_DIR)) { + const file = path.join(PACKS_DIR, decodeURIComponent(packs[1])); + if (fs.existsSync(file)) return route.fulfill({ body: fs.readFileSync(file) }); + } + return route.continue(); + }); + + for (const level of levels) { + console.log('\n--- ' + level.slug); + const size = fs.statSync(path.join(LEVELS_DIR, level.scene)).size; + h.check(size < 400_000, `${level.title}: the .tpscene is ${Math.round(size / 1024)} KB (kit pieces ride as references)`); + + // 1. load it from the Templates modal --------------------------------------- + await page.evaluate(() => window.__stores.isLocked.set(null)); + await page.locator('#logo-menu').click(); + await page.waitForTimeout(300); + await page.locator('#open-templates').click(); + await page.waitForTimeout(500); + await page.locator('#templates-tab-general').click(); + const card = page.locator(`[data-scene-slug="${level.slug}"]`); + h.check(await card.isVisible().catch(() => false), `${level.title}: the card is on the General tab`); + await card.click(); + // a replace of a non-empty scene answers nothing here (no peers); a large-scene ask + // would be a finding, so it is NOT auto-answered + await h.eventually( + () => + page.evaluate(async (marker) => { + const s = window.__stores; + /** @type {any} */ let g; + s.objectsGroup.subscribe((v) => (g = v))(); + let refs = 0; + let hollow = 0; + let meshes = 0; + g.traverse((/** @type {any} */ n) => { + if (n.userData?.packRef) refs++; + if (n.userData?.packStub) hollow++; + if (n.isMesh) meshes++; + }); + /** @type {any} */ let pending; + s.packRefs.packRefsPending.subscribe((v) => (pending = v))(); + return { refs, hollow, meshes, pending, marker: !!g.getObjectByName(marker) }; + }, MARKER[level.slug]), + (r) => r.marker && r.refs > 50 && r.hollow === 0 && r.pending === 0, + `${level.title}: the level loads and every kit piece refills from its pack`, + 60000 + ); + const facts = await page.evaluate(() => { + const s = window.__stores; + /** @type {any} */ let g; + s.objectsGroup.subscribe((v) => (g = v))(); + let refs = 0; + let meshes = 0; + const maps = new Set(); + g.traverse((/** @type {any} */ n) => { + if (n.userData?.packRef) refs++; + if (n.isMesh) { + meshes++; + if (n.material?.map) maps.add(n.material.map); + } + }); + /** @type {any} */ let play; + s.scenePhysics.scenePlay.subscribe((v) => (play = v))(); + return { refs, meshes, maps: maps.size, spawn: play?.spawn ?? null, simOnPlay: play?.simOnPlay }; + }); + h.check(facts.maps < facts.refs / 2, `${level.title}: ${facts.refs} kit pieces share ${facts.maps} base textures`); + h.check(!!facts.spawn && facts.simOnPlay === true, `${level.title}: a spawn point and sim-on-play are scene data (${JSON.stringify(facts.spawn)})`); + + // COUNTERFACTUALS (CF=arch | CF=spawn): break one guard in the page and watch its + // check go red — the archway loses its custom collider (the box of the whole arch + // then blocks the opening), or the scene loses its spawn (Play starts at the fixed + // (0, 2, 3)) + if (process.env.CF) + await page.evaluate((cf) => { + const s = window.__stores; + /** @type {any} */ let g; + s.objectsGroup.subscribe((v) => (g = v))(); + if (cf === 'arch') delete g.getObjectByName('Stair archway')?.userData?.physics; + if (cf === 'spawn') s.scenePhysics.setScenePhysics({ play: { spawn: null } }); + }, process.env.CF); + + // 2. Play: the spawn, the sim -------------------------------------------------- + await page.evaluate(() => window.__stores.isLocked.set(true)); + await h.eventually( + () => page.evaluate(() => { let v; window.__stores.physics.simulating.subscribe((x) => (v = x))(); return v; }), + (v) => v === true, + `${level.title}: Play starts the simulation (sim-on-play)`, + 15000 + ); + await page.waitForTimeout(1200); + const at = await rig(page); + h.check( + at && Math.hypot(at.x - facts.spawn.pos[0], at.z - facts.spawn.pos[2]) < 0.6 && Math.abs(at.feet - facts.spawn.pos[1]) < 0.6, + `${level.title}: Play starts at the spawn point (${at && [at.x, at.feet, at.z].map((v) => v.toFixed(2))})` + ); + const walker = await page.evaluate(() => { let v; window.__stores.charController.walkerState.subscribe((x) => (v = x))(); return v; }); + h.check(walker.source === 'rapier' && walker.grounded, `${level.title}: the walker stands on the level through the physics world (${walker.source}, grounded ${walker.grounded})`); + + // 3. the frame reads, and no quarter is a void ---------------------------------- + await page.waitForTimeout(800); + const light = await frameLight(page); + await page.screenshot({ path: `/home/deck/.code/lanes-30/after-30c/30c-level-design/play-${level.slug}.png` }); + h.check(light.centre >= 0.25, `${level.title}: the play frame reads — centre luminance ${light.centre.toFixed(3)} >= 0.25`); + h.check(light.darkest >= 0.08, `${level.title}: no black quarter (darkest quadrant ${light.darkest.toFixed(3)}; ${light.quads.map((q) => q.toFixed(2))})`); + + // 4. walls hold, openings pass ---------------------------------------------------- + for (const walk of WALKS[level.slug]) { + await placeRig(page, walk.at[0], walk.y ?? 0.3, walk.at[1], walk.yaw); + await page.waitForTimeout(700); + const before = await rig(page); + await page.keyboard.down('KeyW'); + await page.waitForTimeout(walk.ms); + await page.keyboard.up('KeyW'); + await page.waitForTimeout(300); + const end = await rig(page); + const ok = before && end && walk.moved(before, end) && walk.pass(end); + h.check(!!ok, `${level.title}: ${walk.label} (from ${before && [before.x, before.feet, before.z].map((v) => v.toFixed(2))} to ${end && [end.x, end.feet, end.z].map((v) => v.toFixed(2))})`); + } + await page.evaluate(() => window.__stores.isLocked.set(false)); + await page.waitForTimeout(2500); + } + await h.finish(browser); +}); diff --git a/tests/e2e/pack-refs.test.cjs b/tests/e2e/pack-refs.test.cjs new file mode 100644 index 00000000..19cee2c7 --- /dev/null +++ b/tests/e2e/pack-refs.test.cjs @@ -0,0 +1,291 @@ +// 30c — KIT REFERENCES (src/lib/packRefs.js): a piece placed from a pack carries where it +// came from, and a save / the wire write a PRISTINE one as a small stub that the loader and +// every peer refill from the pack URL. +// +// The fixture is the bundled Duck (static/library/default/Duck — always served by the dev +// server, nested nodes + a texture) addressed by an ABSOLUTE url, so the suite needs no pack +// server; when the dev server's PACKS_BASE serves the architecture kit (VITE_PACKS_BASE), a +// last section measures a real kit wall too, and SKIPS (never fails) without it. +// +// APP_URL=https://theprototype.app:5255/ node tests/e2e/pack-refs.test.cjs +const h = require('./helpers.cjs'); + +const DUCK = 'library/default/Duck/glTF-Binary/Duck.glb'; + +/** Poll until `predicate` holds and RETURN the last value (h.eventually returns nothing). */ +async function until(fn, predicate, label, timeout = 10000) { + const start = Date.now(); + let last; + while (Date.now() - start < timeout) { + last = await fn(); + if (predicate(last)) { + h.check(true, label); + return last; + } + await new Promise((r) => setTimeout(r, 400)); + } + console.log(' last: ' + JSON.stringify(last)?.slice(0, 600)); + h.check(false, label); + return last; +} + +/** Place the fixture through the REAL drop path (explorerDrop.dropExplorerItem with a pack + * payload), at the canvas centre. Resolves the placed root's uuid. */ +async function place(peer, name = 'Duck') { + return peer.page.evaluate( + async ({ path, name }) => { + const s = window.__stores; + /** @type {any} */ let g; + s.objectsGroup.subscribe((v) => (g = v))(); + const before = new Set(g.children.map((/** @type {any} */ c) => c.uuid)); + /** @type {any} */ let renderer; + s.globalRenderer.subscribe((v) => (renderer = v))(); + const r = renderer.domElement.getBoundingClientRect(); + await s.explorerDrop.dropExplorerItem({ kind: 'object', name, url: location.origin + '/' + path }, r.left + r.width / 2, r.top + r.height / 2); + for (let i = 0; i < 100; i++) { + const fresh = g.children.find((/** @type {any} */ c) => !before.has(c.uuid)); + if (fresh) return fresh.uuid; + await new Promise((res) => setTimeout(res, 100)); + } + return null; + }, + { path: DUCK, name } + ); +} + +/** Facts about one root in a page. */ +function facts(peer, uuid) { + return peer.page.evaluate((uuid) => { + const s = window.__stores; + /** @type {any} */ let g; + s.objectsGroup.subscribe((v) => (g = v))(); + const o = g.getObjectByProperty('uuid', uuid); + if (!o) return null; + let tris = 0; + const kids = []; + o.traverse((/** @type {any} */ n) => { + if (n !== o) kids.push(n.uuid); + if (n.isMesh) tris += (n.geometry.index ? n.geometry.index.count : n.geometry.attributes.position.count) / 3; + }); + return { + ref: o.userData.packRef ?? null, + stub: !!o.userData.packStub, + children: o.children.length, + kids, + tris, + pos: o.position.toArray().map((v) => Math.round(v * 1000) / 1000), + pristine: s.packRefs.isPristinePackRef(o) + }; + }, uuid); +} + +h.run(async () => { + const browser = await h.launch({ args: h.GPU_ARGS }); + const A = await h.setupPage(browser, 'A'); + + // ---- 1. a placement carries its reference ------------------------------------ + const duck = await place(A); + h.check(!!duck, 'the pack payload placed an object through the real drop path'); + const f1 = await facts(A, duck); + h.check(f1?.ref?.path?.endsWith(DUCK) && /^[0-9a-f]{64}$/.test(f1?.ref?.hash ?? ''), `the root carries packRef {path, hash} (${f1?.ref?.path} ${f1?.ref?.hash?.slice(0, 8)})`); + h.check(Array.isArray(f1?.ref?.kids) && f1.ref.kids.join() === f1.kids.join() && f1.kids.length > 0, `packRef.kids records the descendants in traverse order (${f1?.kids?.length})`); + h.check(f1?.pristine === true, 'a fresh placement is pristine'); + + // ---- 2. the save writes a stub, a fraction of the full element ---------------- + const saved = await A.page.evaluate((uuid) => { + const s = window.__stores; + const payload = s.sessions.buildSessionPayload('refs'); + const el = payload.objects.find((/** @type {any} */ e) => e.object?.uuid === uuid); + /** @type {any} */ let g; + s.objectsGroup.subscribe((v) => (g = v))(); + const full = JSON.stringify(g.getObjectByProperty('uuid', uuid).toJSON()).length; + return { stub: !!el?.object?.userData?.packStub, kids: el?.object?.children?.length ?? 0, bytes: JSON.stringify(el).length, full }; + }, duck); + h.check(saved.stub && saved.kids === 0, 'buildSessionPayload writes the pristine piece as a hollow stub'); + h.check(saved.bytes < 4000 && saved.full > saved.bytes * 20, `the stub is ${saved.bytes} B against a ${saved.full} B full element`); + + // ---- 3. an EDITED piece is written in full ------------------------------------ + const edited = await A.page.evaluate(async (uuid) => { + const s = window.__stores; + /** @type {any} */ let g; + s.objectsGroup.subscribe((v) => (g = v))(); + const o = g.getObjectByProperty('uuid', uuid); + let mesh = null; + o.traverse((/** @type {any} */ n) => { + if (n.isMesh && !mesh) mesh = n; + }); + const out = {}; + // a colour change + const was = mesh.material.color.getHex(); + mesh.material.color.setHex(0x00ff00); + out.color = s.packRefs.isPristinePackRef(o); + mesh.material.color.setHex(was); + out.colorBack = s.packRefs.isPristinePackRef(o); + // a vertex moved in place (the vertex-drag / sculpt path) + const pa = mesh.geometry.attributes.position; + const y = pa.getY(0); + pa.setY(0, y + 0.25); + out.vertex = s.packRefs.isPristinePackRef(o); + const payload = s.sessions.buildSessionPayload('edited'); + const el = payload.objects.find((/** @type {any} */ e) => e.object?.uuid === uuid); + out.savedFull = !el.object.userData?.packStub && (el.object.children?.length ?? 0) > 0; + pa.setY(0, y); + out.vertexBack = s.packRefs.isPristinePackRef(o); + // a child moved inside the piece + mesh.position.x += 0.5; + mesh.updateMatrix(); + out.child = s.packRefs.isPristinePackRef(o); + mesh.position.x -= 0.5; + mesh.updateMatrix(); + // the ROOT moved — that is the placement, and rides the stub + o.position.x += 3; + o.updateMatrix(); + out.root = s.packRefs.isPristinePackRef(o); + return out; + }, duck); + h.check(edited.color === false && edited.colorBack === true, `a colour change makes it NOT pristine, and undoing it restores it (${edited.color}/${edited.colorBack})`); + h.check(edited.vertex === false && edited.savedFull === true && edited.vertexBack === true, `an in-place vertex edit is NOT pristine and saves IN FULL (${edited.vertex}/${edited.savedFull}/${edited.vertexBack})`); + h.check(edited.child === false, 'a moved child inside the piece is NOT pristine'); + h.check(edited.root === true, 'moving the root is the placement: still pristine'); + + // ---- 4. a load refills stubs; copies share their textures --------------------- + const second = await place(A, 'Duck 2'); + const loaded = await A.page.evaluate(async () => { + const s = window.__stores; + const payload = s.sessions.buildSessionPayload('two'); + const stubs = payload.objects.filter((/** @type {any} */ e) => e.object?.userData?.packStub).length; + await s.sessions.applySession(payload, { backup: false }); + await new Promise((r) => setTimeout(r, 200)); + await s.packRefs.packRefsSettled(); + /** @type {any} */ let g; + s.objectsGroup.subscribe((v) => (g = v))(); + const maps = new Set(); + const geos = new Set(); + const mats = new Set(); + let filled = 0; + for (const c of g.children) { + if (!c.userData.packRef) continue; + if (!c.userData.packStub && c.children.length) filled++; + c.traverse((/** @type {any} */ n) => { + if (!n.isMesh) return; + geos.add(n.geometry); + mats.add(n.material); + if (n.material.map) maps.add(n.material.map); + }); + } + return { stubs, filled, maps: maps.size, geos: geos.size, mats: mats.size, names: payload.objects.map((/** @type {any} */ e) => e.object.name) }; + }); + h.check(loaded.stubs === 2, `both copies saved as stubs (${loaded.stubs})`); + h.check(loaded.filled === 2, `applySession refilled both (${loaded.filled})`); + h.check(loaded.maps === 1, `the two copies SHARE one texture (${loaded.maps} distinct maps)`); + h.check(loaded.geos === 2 && loaded.mats === 2, `...but each owns its geometry and material, so an edit to one cannot move the other (${loaded.geos}/${loaded.mats})`); + const f4 = await facts(A, duck); + h.check(f4 && f4.kids.join() === f1.kids.join(), 'the refilled piece takes the RECORDED child uuids'); + h.check(f4 && Math.abs(f4.pos[0] - (f1.pos[0] + 3)) < 1e-3, `the stub kept the root's placement (x ${f4?.pos?.[0]})`); + h.check(f4 && f4.tris === f1.tris && f4.pristine === true, `the refill is the file again: ${f4?.tris} tris, pristine ${f4?.pristine}`); + + // ---- 5. an autosave-style GLTF round trip re-learns pristine ------------------ + const round = await A.page.evaluate(async (uuid) => { + const s = window.__stores; + /** @type {any} */ let g; + s.objectsGroup.subscribe((v) => (g = v))(); + const o = g.getObjectByProperty('uuid', uuid); + const Exporter = s.GLTFExporterModule.GLTFExporter; + const glb = await new Promise((res, rej) => new Exporter().parse(o, res, rej, { binary: true, onlyVisible: false })); + const gltf = await new Promise((res, rej) => s.animatedImports.createGltfLoader().parse(glb, '', res, rej)); + const back = gltf.scene.children[0]; + const la = s.packRefs.fingerprintOf(o).key.split('\n'); + const lb = s.packRefs.fingerprintOf(back).key.split('\n'); + const diff = la.map((line, i) => (line === lb[i] ? null : [line.slice(0, 300), (lb[i] ?? '').slice(0, 300)])).filter(Boolean); + return { hasRef: !!back.userData?.packRef, pristine: s.packRefs.isPristinePackRef(back), diff: diff.slice(0, 2), n: [la.length, lb.length] }; + }, duck); + h.check(round.hasRef, 'packRef rides GLTF extras (the autosave path)'); + h.check(round.pristine === true, `after a GLTF round trip (re-encoded textures) the piece still reads pristine (${round.pristine})`); + if (!round.pristine) console.log(' diff ' + JSON.stringify(round.diff) + ' lines ' + round.n); + + // ---- 6. an unreachable pack keeps the reference ------------------------------- + const lost = await A.page.evaluate(async () => { + const s = window.__stores; + const T = s.THREE; + const hollow = new T.Group(); + hollow.name = 'Lost piece'; + hollow.userData = { packRef: { pack: 'nowhere', item: 'Ghost', path: location.origin + '/no/such/piece.glb', hash: 'x', kids: [] }, packStub: true }; + /** @type {any} */ let g; + s.objectsGroup.subscribe((v) => (g = v))(); + g.add(hollow); + s.pokeScene(); + await new Promise((r) => setTimeout(r, 300)); + await s.packRefs.packRefsSettled(); + const payload = s.sessions.buildSessionPayload('lost'); + const el = payload.objects.find((/** @type {any} */ e) => e.object?.uuid === hollow.uuid); + return { children: hollow.children.length, stillStub: !!hollow.userData.packStub, savedStub: !!el?.object?.userData?.packStub, path: el?.object?.userData?.packRef?.path, uuid: hollow.uuid }; + }); + h.check(lost.children === 0 && lost.stillStub, 'an unreachable piece stays a hollow stub'); + h.check(lost.savedStub && /no\/such\/piece\.glb$/.test(lost.path ?? ''), 'and SAVES back as the same reference — never silently dropped'); + await A.page.evaluate((uuid) => { + const s = window.__stores; + /** @type {any} */ let g; + s.objectsGroup.subscribe((v) => (g = v))(); + const o = g.getObjectByProperty('uuid', uuid); + o?.parent?.remove(o); + s.pokeScene(); + }, lost.uuid); + + // ---- 7. two peers: a late joiner and a live placement ------------------------- + const B = await h.setupPage(browser, 'B'); + await h.connect(B, A); + const joined = await until( + () => Promise.all([facts(B, duck), facts(B, second)]), + (r) => r.every((f) => f && !f.stub && f.children > 0), + 'the late joiner refilled both pieces from their pack', + 20000 + ); + h.check(joined[0]?.kids.join() === f1.kids.join(), 'the joiner has the same child uuids'); + h.check(joined[0]?.tris === f1.tris, `the joiner has the same geometry (${joined[0]?.tris} tris)`); + const wire = await A.page.evaluate(() => { + const s = window.__stores; + /** @type {any} */ const p = (() => { let v; s.peers.subscribe((x) => (v = x))(); return v; })(); + /** @type {any[]} */ const sent = []; + const orig = p.send.bind(p); + p.send = (msg) => { + sent.push({ type: msg.type, bytes: JSON.stringify(msg).length, stub: !!msg.element?.object?.userData?.packStub }); + return orig(msg); + }; + window.__refSent = sent; + return true; + }); + h.check(wire, 'a wire spy wraps A.peers.send'); + const third = await place(A, 'Duck 3'); + const live = await until(() => facts(B, third), (f) => f && !f.stub && f.children > 0, 'peer B refilled a LIVE placement', 20000); + h.check(live?.ref?.hash === f1.ref.hash, 'B built it from the same bytes (hash)'); + const spied = await A.page.evaluate(() => window.__refSent.filter((m) => m.type === 'object')); + h.check(spied.length === 1 && spied[0].stub && spied[0].bytes < 4000, `the placement crossed the wire as ONE stub message (${JSON.stringify(spied)})`); + + // ---- 8. a real kit wall, when this server's PACKS_BASE serves the architecture kit + const kit = await A.page.evaluate(async () => { + const s = window.__stores; + const url = String(s.packs.PACKS_BASE).replace(/\/+$/, '') + '/architecture-kit/WallStone/glTF-Binary/wall-stone.glb'; + const res = await fetch(url).catch(() => null); + if (!res?.ok) return null; + /** @type {any} */ let g; + s.objectsGroup.subscribe((v) => (g = v))(); + const before = new Set(g.children.map((/** @type {any} */ c) => c.uuid)); + await s.explorerDrop.dropExplorerItem({ kind: 'object', name: 'WallStone', url }, innerWidth / 2, innerHeight / 2); + let o = null; + for (let i = 0; i < 100 && !o; i++) { + o = g.children.find((/** @type {any} */ c) => !before.has(c.uuid)) ?? null; + if (!o) await new Promise((r) => setTimeout(r, 100)); + } + if (!o) return { placed: false }; + const payload = s.sessions.buildSessionPayload('kit'); + const el = payload.objects.find((/** @type {any} */ e) => e.object?.uuid === o.uuid); + return { placed: true, path: o.userData.packRef?.path, stub: JSON.stringify(el).length, full: JSON.stringify(o.toJSON()).length }; + }); + if (!kit) console.log('SKIP the architecture kit is not served by this build (set VITE_PACKS_BASE)'); + else { + h.check(kit.placed && kit.path === 'architecture-kit/WallStone/glTF-Binary/wall-stone.glb', `a kit wall carries a PACKS_BASE-relative path (${kit.path})`); + h.check(kit.stub < 4000 && kit.full > 1_000_000, `a kit wall saves as ${kit.stub} B instead of ${kit.full} B`); + } + + await h.finish(browser); +}); diff --git a/tests/unit/levelTemplates.test.js b/tests/unit/levelTemplates.test.js new file mode 100644 index 00000000..46a81c00 --- /dev/null +++ b/tests/unit/levelTemplates.test.js @@ -0,0 +1,113 @@ +// 30c level design — the three kit levels' DEFS (scripts/level-templates.cjs), checked with +// no browser: every piece names a real kit item, sits on the kit's grid, every custom +// collider fits the 1200-float cap, and each level has a spawn and a walk controller. +import { describe, it, expect } from 'vitest'; +import { createRequire } from 'node:module'; +import fs from 'node:fs'; +import path from 'node:path'; + +const require = createRequire(import.meta.url); +const { LEVEL_DEFS, COLLIDERS } = require('../../scripts/level-templates.cjs'); + +// the three kits' item lists, when a local snapshot of the packs is around (the pack PRs +// are separate repos; the list check SKIPS rather than fails without them) +const PACKS_DIR = process.env.PACKS_DIR || '/home/deck/.code/lanes-30/levels-packs'; +/** @param {string} pack */ +function itemsOf(pack) { + const file = path.join(PACKS_DIR, pack, 'default.json'); + return fs.existsSync(file) ? new Set(JSON.parse(fs.readFileSync(file, 'utf8')).map((/** @type {any} */ r) => r.name)) : null; +} + +/** @param {any} def */ +const kits = (def) => def.objects.filter((/** @type {any} */ o) => o.type === 'kit'); + +describe('level templates', () => { + it('are three general-tab templates kept out of the offline seed', () => { + expect(LEVEL_DEFS.map((/** @type {any} */ d) => d.slug)).toEqual(['castle-courtyard', 'forest-clearing', 'tavern-interior']); + for (const def of LEVEL_DEFS) { + expect(def.kind).toBe('template'); + expect(def.seed).toBe(false); + expect(def.description.length).toBeGreaterThan(40); + } + }); + + it('name every object uniquely', () => { + for (const def of LEVEL_DEFS) { + const names = def.objects.map((/** @type {any} */ o) => o.name); + expect(new Set(names).size).toBe(names.length); + } + }); + + it('build from the kits, and only from items the kits ship', () => { + for (const def of LEVEL_DEFS) { + expect(kits(def).length).toBeGreaterThan(90); + for (const o of kits(def)) { + const items = itemsOf(o.pack); + if (items) expect(items.has(o.item), def.slug + ': ' + o.pack + '/' + o.item).toBe(true); + } + } + }); + + it('stand walls and floors on the 1 m grid, turned in right angles', () => { + const grid = /^(Wall(Stone|Plaster)|Floor|Railing|Battlement|Block|Tower|Pillar|CornerPost|Stairs|Gate|RoofSlope|Arch|Door|Window)/; + for (const def of LEVEL_DEFS) + for (const o of kits(def)) { + // the tavern's bar is half walls used as FURNITURE (scaled, off the grid on purpose) + if (!grid.test(o.item) || /^Bar/.test(o.name)) continue; + const [x, , z] = o.pos; + // walls sit on grid lines, cells on odd metres; rails ride 0.08 inside an edge + expect(Math.abs(x * 2 - Math.round(x * 2)) < 0.17 || o.item === 'Railing', def.slug + ' ' + o.name + ' x ' + x).toBe(true); + expect(Math.abs(z * 2 - Math.round(z * 2)) < 0.17 || o.item === 'Railing', def.slug + ' ' + o.name + ' z ' + z).toBe(true); + const yaw = o.rot?.[1] ?? 0; + expect(Math.abs(yaw / (Math.PI / 2) - Math.round(yaw / (Math.PI / 2))) < 1e-3, def.slug + ' ' + o.name + ' yaw').toBe(true); + } + }); + + it('give every custom collider valid pieces under the 1200-float cap', () => { + for (const def of LEVEL_DEFS) + for (const o of def.objects) { + const p = o.physics; + if (p?.collider !== 'custom') continue; + expect(p.colliderVerts.length).toBeLessThanOrEqual(1200); + expect(p.colliderVerts.length % 3).toBe(0); + for (const [start, count] of p.colliderPieces) { + expect(count).toBeGreaterThanOrEqual(9); + expect(start + count).toBeLessThanOrEqual(p.colliderVerts.length); + } + } + }); + + it('leave the doorway and the archway walkable for the 0.3 m capsule', () => { + /** @param {any} c */ + const gap = (c) => { + const v = c.colliderVerts; + const [a, b] = c.colliderPieces; + const leftMax = Math.max(...v.slice(a[0], a[0] + a[1]).filter((/** @type {number} */ _, /** @type {number} */ i) => i % 3 === 0)); + const rightMin = Math.min(...v.slice(b[0], b[0] + b[1]).filter((/** @type {number} */ _, /** @type {number} */ i) => i % 3 === 0)); + return rightMin - leftMax; + }; + expect(gap(COLLIDERS.doorway)).toBeGreaterThan(0.6); + expect(gap(COLLIDERS.arch)).toBeGreaterThan(0.6); + // a staircase is a ramp the walker can climb (50° is its limit) + const slope = Math.atan2(3, 4) * (180 / Math.PI); + expect(slope).toBeLessThan(50); + }); + + it('carry a spawn, sim-on-play and ONE walk-mode character controller', () => { + for (const def of LEVEL_DEFS) { + expect(def.physics.play.simOnPlay).toBe(true); + expect(def.physics.play.spawn.pos).toHaveLength(3); + const nodes = def.graphs.scene.nodes; + expect(nodes.filter((/** @type {any} */ n) => n.type === 'charcontroller')).toHaveLength(1); + expect(nodes[0].data.mode).toBe('walk'); + // the sim needs something dynamic, or startSimulation declines to run at all + expect(def.objects.some((/** @type {any} */ o) => o.physics?.mode === 'dynamic')).toBe(true); + } + }); + + it('are deterministic (a re-author writes the same layout)', () => { + delete require.cache[require.resolve('../../scripts/level-templates.cjs')]; + const again = require('../../scripts/level-templates.cjs').LEVEL_DEFS; + expect(JSON.stringify(again)).toBe(JSON.stringify(LEVEL_DEFS)); + }); +}); diff --git a/tests/unit/scenePhysicsSpawn.test.js b/tests/unit/scenePhysicsSpawn.test.js new file mode 100644 index 00000000..e38882e1 --- /dev/null +++ b/tests/unit/scenePhysicsSpawn.test.js @@ -0,0 +1,26 @@ +// 30c: the play SPAWN is scene data on scenePhysics' play block — present only when set, so a +// scene that never set one normalizes (and saves) exactly as before. +import { describe, it, expect } from 'vitest'; +import { normalizeScenePhysics } from '../../src/lib/scenePhysics.js'; + +describe('scenePhysics play.spawn', () => { + it('is absent by default (a scene without one is byte-identical)', () => { + const state = normalizeScenePhysics({}); + expect('spawn' in state.play).toBe(false); + expect(JSON.stringify(normalizeScenePhysics({ play: { simOnPlay: true } }).play)).toBe( + JSON.stringify({ interaction: 'grab', grounded: false, simOnPlay: true }) + ); + }); + + it('keeps a valid spawn: feet position and yaw', () => { + const state = normalizeScenePhysics({ play: { spawn: { pos: [1, 0.3, 4.6], yaw: -0.2 } } }); + expect(state.play.spawn).toEqual({ pos: [1, 0.3, 4.6], yaw: -0.2 }); + // yaw defaults to 0 (looking down -Z, the fixed start's direction) + expect(normalizeScenePhysics({ play: { spawn: { pos: [0, 0, 0] } } }).play.spawn).toEqual({ pos: [0, 0, 0], yaw: 0 }); + }); + + it('drops a malformed or cleared spawn instead of carrying it', () => { + for (const spawn of [null, {}, { pos: [1, 2] }, { pos: [1, 'x', 3] }, { pos: [NaN, 0, 0] }, { pos: [1e9, 0, 0] }]) + expect('spawn' in normalizeScenePhysics({ play: { spawn } }).play).toBe(false); + }); +});