From 10e71fd028d2ef7af98e8c778cc269ee006f5a13 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Wed, 23 Sep 2026 00:35:13 +0300 Subject: [PATCH 01/12] [feat] author-kit p0: primitives, light kinds, physical/toon materials, object flags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - build() gains capsule / plane / ring / icosahedron / dodecahedron (core three geometries, rebuilt from their parameters by ObjectLoader) and box `bevel` (+ bevelSegments): a port of three/examples RoundedBoxGeometry baked into a PLAIN BufferGeometry, because toJSON writes a subclass type ObjectLoader cannot rebuild (core Box params carry no bevel) - light `kind`: spot (angle, penumbra, target) / directional (castShadow, shadowMapSize, frustum FITTED to the built meshes in the light's own frame; `fit: false` opts out) / hemisphere (groundColor); spot + directional follow createLight (cast by default, the V-1 bias pair) and aim by ROTATION at `target` (24-E1). point (no kind) is untouched - materials: `physical: true` or any physical-only field (clearcoat, clearcoatRoughness, transmission, thickness, ior, sheen, sheenColor, sheenRoughness, iridescence, specularIntensity) -> MeshPhysicalMaterial; `toon: true` -> MeshToonMaterial; `flatShading`, `side: 'double'|'back'`; emissiveIntensity as before - flags: `pick: 'through'` -> userData.pick; `origin: [x,y,z]` -> userData.origin; `particles: '' | {preset, ...patch}` -> userData.particles from particlePresets; `anim: '' | [..]` -> animationPreview.applyPreset (by key or name), an ordinary authored clip (not playing — a Play Animation node runs it) - loader: `--def ` adds defs from JSON (one or an array); a same-slug def REPLACES the DEFS entry. Unknown object types now throw instead of silently becoming cones - suite author-kit (new): authors a def-under-test via --def/--only into a temp dir, loads it back through sessions.readSessionZip + applySession, one check per feature: 40 PASS - byte-identity: level-blockout/towers/stars-room/untangle/waves authored before and after, scripts/compare-authored.cjs reports SAME for all five (thumbs identical in size) - counterfactual: 'clearcoat' dropped from PHYSICAL_KEYS + the pick line removed -> exactly "physical: clearcoat 0.8 survives (0)" and "pick: 'through' lands on userData.pick" FAIL - held: templates-modal 47 / template-modules 29 PASS lines at base (no src change in this commit); svelte-check 336/47 identical list; vitest 196; build green Co-Authored-By: Claude Opus 5.5 (1M context) --- scripts/author-templates.cjs | 251 ++++++++++++++++++++++++++++++++-- tests/e2e/author-kit.test.cjs | 244 +++++++++++++++++++++++++++++++++ 2 files changed, 487 insertions(+), 8 deletions(-) create mode 100644 tests/e2e/author-kit.test.cjs diff --git a/scripts/author-templates.cjs b/scripts/author-templates.cjs index 9d244779..3a9a0b6a 100644 --- a/scripts/author-templates.cjs +++ b/scripts/author-templates.cjs @@ -95,6 +95,22 @@ const ONLY = .split(',') .filter(Boolean) : null; +// 30 author-kit: `--def ` adds defs from JSON files — each file holds +// ONE def or an ARRAY of them. A file def whose slug matches a DEFS entry REPLACES it (so a +// lane can iterate on a def without editing DEFS); any other slug is appended. Combine with +// `--only ` to author just that def. This is also how the author-kit suite authors its +// def-under-test into a scratch folder. +const defFlag = process.argv.indexOf('--def'); +const FILE_DEFS = + defFlag !== -1 + ? String(process.argv[defFlag + 1] ?? '') + .split(',') + .filter(Boolean) + .flatMap((file) => { + const parsed = JSON.parse(fs.readFileSync(path.resolve(file), 'utf8')); + return Array.isArray(parsed) ? parsed : [parsed]; + }) + : []; // ---- declarative scene definitions ------------------------------------------ // objects: {type:'box'|'cylinder'|'sphere'|'cone', name, color, pos, rot?, ...dims, @@ -1332,7 +1348,11 @@ const DEFS = [ /** @type {Record} */ const built = {}; - const defs = ONLY ? DEFS.filter((d) => ONLY.includes(d.slug)) : DEFS; + // 30 author-kit: `--def` files replace a same-slug DEFS entry in place, or append + const allDefs = DEFS.map((d) => FILE_DEFS.find((f) => f.slug === d.slug) ?? d).concat( + FILE_DEFS.filter((f) => !DEFS.some((d) => d.slug === f.slug)) + ); + const defs = ONLY ? allDefs.filter((d) => ONLY.includes(d.slug)) : allDefs; if (ONLY && defs.length !== ONLY.length) console.log(' WARN --only names a slug DEFS does not have: ' + ONLY.join(',')); // a module-owned def whose file is absent must not quietly drop its row from a full @@ -1393,6 +1413,90 @@ const DEFS = [ s.objectsGroup.subscribe((g) => (group = g))(); /** @param {number} n */ const hex = (n) => '#' + Number(n).toString(16).padStart(6, '0'); + // 30 author-kit: material fields only MeshPhysicalMaterial has (a def using any of + // them gets one), the directional lights whose shadow frustum is fitted once the + // whole scene is built, and the animation presets applied once objects have uuids + const PHYSICAL_KEYS = ['clearcoat', 'clearcoatRoughness', 'transmission', 'thickness', 'ior', 'sheen', 'sheenColor', 'sheenRoughness', 'iridescence', 'specularIntensity']; + /** @type {any[]} */ const fitShadows = []; + /** @type {{object: any, anim: any}[]} */ const animQueue = []; + // 30 author-kit: a ROUNDED box. Core's Box params carry no bevel, so this is a port of + // three/examples' RoundedBoxGeometry (MIT) — same vertex placement, normals and UVs — + // BAKED into a plain BufferGeometry: toJSON writes a subclass's `type` and ObjectLoader + // cannot rebuild 'RoundedBoxGeometry', while a plain BufferGeometry round-trips its + // buffers. @param {number} width @param {number} height @param {number} depth + // @param {number} segments @param {number} radius + const roundedBox = (width, height, depth, segments, radius) => { + const total = segments * 2 + 1; + radius = Math.min(width / 2, height / 2, depth / 2, radius); + const src = new T.BoxGeometry(1, 1, 1, total, total, total).toNonIndexed(); + const positions = src.attributes.position.array; + const normals = src.attributes.normal.array; + const uvs = src.attributes.uv.array; + const position = new T.Vector3(); + const normal = new T.Vector3(); + const temp = new T.Vector3(); + const faceDir = new T.Vector3(); + const box = new T.Vector3(width, height, depth).divideScalar(2).subScalar(radius); + const faceTris = positions.length / 6; + const half = 0.5 / total; + /** @param {string} uvAxis @param {string} projAxis @param {number} side */ + const getUv = (uvAxis, projAxis, side) => { + const arc = (2 * Math.PI * radius) / 4; + const centre = Math.max(side - 2 * radius, 0); + temp.copy(normal); + /** @type {any} */ (temp)[projAxis] = 0; + temp.normalize(); + const arcUv = (0.5 * arc) / (arc + centre); + const arcAngle = 1.0 - temp.angleTo(faceDir) / (Math.PI / 4); + if (Math.sign(/** @type {any} */ (temp)[uvAxis]) === 1) return arcAngle * arcUv; + return centre / (arc + centre) + arcUv + arcUv * (1.0 - arcAngle); + }; + for (let i = 0, j = 0; i < positions.length; i += 3, j += 2) { + position.fromArray(positions, i); + normal.copy(position); + normal.x -= Math.sign(normal.x) * half; + normal.y -= Math.sign(normal.y) * half; + normal.z -= Math.sign(normal.z) * half; + normal.normalize(); + positions[i] = box.x * Math.sign(position.x) + normal.x * radius; + positions[i + 1] = box.y * Math.sign(position.y) + normal.y * radius; + positions[i + 2] = box.z * Math.sign(position.z) + normal.z * radius; + normals[i] = normal.x; + normals[i + 1] = normal.y; + normals[i + 2] = normal.z; + const face = Math.floor(i / faceTris); + if (face === 0) { + faceDir.set(1, 0, 0); + uvs[j] = getUv('z', 'y', depth); + uvs[j + 1] = 1.0 - getUv('y', 'z', height); + } else if (face === 1) { + faceDir.set(-1, 0, 0); + uvs[j] = 1.0 - getUv('z', 'y', depth); + uvs[j + 1] = 1.0 - getUv('y', 'z', height); + } else if (face === 2) { + faceDir.set(0, 1, 0); + uvs[j] = 1.0 - getUv('x', 'z', width); + uvs[j + 1] = getUv('z', 'x', depth); + } else if (face === 3) { + faceDir.set(0, -1, 0); + uvs[j] = 1.0 - getUv('x', 'z', width); + uvs[j + 1] = 1.0 - getUv('z', 'x', depth); + } else if (face === 4) { + faceDir.set(0, 0, 1); + uvs[j] = 1.0 - getUv('x', 'y', width); + uvs[j + 1] = 1.0 - getUv('y', 'x', height); + } else { + faceDir.set(0, 0, -1); + uvs[j] = getUv('x', 'y', width); + uvs[j + 1] = 1.0 - getUv('y', 'x', height); + } + } + const geo = new T.BufferGeometry(); + geo.setAttribute('position', new T.BufferAttribute(positions, 3)); + geo.setAttribute('normal', new T.BufferAttribute(normals, 3)); + geo.setAttribute('uv', new T.BufferAttribute(uvs, 2)); + return geo; + }; // 28-G: ONE recursive builder. The four original primitives take exactly the // steps they always did, in the same order, so every earlier def is byte-identical. // `mirror` reflects across x = 0: position x negated, yaw and roll NEGATED — a @@ -1409,6 +1513,29 @@ 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 === '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 + // by ROTATION (24-E1: they shine along local -Z; lightHelpers places the target + // on that forward) — `target` is a WORLD point applied with lookAt once the + // position is set, below. A directional's ortho frustum is FITTED to the built + // scene after every object exists (`fitShadows`), unless `fit: false`. + if (o.kind === 'hemisphere') { + object = new T.HemisphereLight(o.color ?? 0xffffff, o.groundColor ?? 0x444444, o.intensity ?? 1); + } else if (o.kind === 'spot' || o.kind === 'directional') { + object = + o.kind === 'spot' + ? new T.SpotLight(o.color ?? 0xffffff, o.intensity ?? 1, o.distance ?? 0, o.angle ?? Math.PI / 6, o.penumbra ?? 0.3, o.decay ?? 2) + : new T.DirectionalLight(o.color ?? 0xffffff, o.intensity ?? 1); + object.castShadow = o.castShadow ?? true; + object.shadow.bias = -0.0002; + object.shadow.normalBias = 0.02; + if (o.shadowMapSize) { + object.userData.shadowMapSize = o.shadowMapSize; + object.shadow.mapSize.set(o.shadowMapSize, o.shadowMapSize); + } + if (o.kind === 'directional' && object.castShadow && o.fit !== false) fitShadows.push(object); + } else throw new Error('light: unknown kind "' + o.kind + '" (point | spot | directional | hemisphere)'); } else if (o.type === 'light') { // a point light: a viewpoint's worth of scenery it lights, no shadow map object = new T.PointLight(o.color ?? 0xffffff, o.intensity ?? 1, o.distance ?? 0, o.decay ?? 2); @@ -1449,16 +1576,44 @@ const DEFS = [ ); } else { let geo; - if (o.type === 'box') geo = new T.BoxGeometry(o.size[0], o.size[1], o.size[2]); + if (o.type === 'box' && o.bevel > 0) geo = roundedBox(o.size[0], o.size[1], o.size[2], o.bevelSegments ?? 3, o.bevel); + else if (o.type === 'box') geo = new T.BoxGeometry(o.size[0], o.size[1], o.size[2]); else if (o.type === 'cylinder') geo = new T.CylinderGeometry(o.r, o.r2 ?? o.r, o.h, 24); else if (o.type === 'sphere') geo = new T.SphereGeometry(o.r, 24, 16); else if (o.type === 'torus') geo = new T.TorusGeometry(o.r, o.tube ?? o.r * 0.2, 16, 40); - else geo = new T.ConeGeometry(o.r, o.h, 24); - const mat = new T.MeshStandardMaterial({ - color: o.color, - roughness: o.roughness ?? 0.85, - metalness: o.metalness ?? 0 - }); + // 30 author-kit: four more primitives, all core three geometries ObjectLoader + // rebuilds from their parameters (so the .tpscene stays small). A plane faces +Z + // (rotate it -90deg on x to lie flat); a ring is the flat annulus, also +Z. + else if (o.type === 'capsule') geo = new T.CapsuleGeometry(o.r, o.h ?? o.length ?? 1, 8, 16); + else if (o.type === 'plane') geo = new T.PlaneGeometry(o.size[0], o.size[1]); + else if (o.type === 'ring') geo = new T.RingGeometry(o.inner ?? o.r * 0.5, o.r, 48); + else if (o.type === 'icosahedron') geo = new T.IcosahedronGeometry(o.r, o.detail ?? 0); + else if (o.type === 'dodecahedron') geo = new T.DodecahedronGeometry(o.r, o.detail ?? 0); + else if (o.type === 'cone') geo = new T.ConeGeometry(o.r, o.h, 24); + else throw new Error('object "' + o.name + '": unknown type "' + o.type + '"'); + // 30 author-kit: MeshPhysicalMaterial when a def asks for `physical` or uses any + // field only it has; MeshToonMaterial for `toon`. Absent all of those it is the + // same MeshStandardMaterial as ever (byte-identical defs). + const physical = o.physical || PHYSICAL_KEYS.some((k) => o[k] != null); + /** @type {any} */ + let mat; + if (o.toon) mat = new T.MeshToonMaterial({ color: o.color }); + else if (physical) { + mat = new T.MeshPhysicalMaterial({ color: o.color, roughness: o.roughness ?? 0.85, metalness: o.metalness ?? 0 }); + for (const k of PHYSICAL_KEYS) { + if (o[k] == null) continue; + if (k === 'sheenColor') mat.sheenColor = new T.Color(o[k]); + else mat[k] = o[k]; + } + } else + mat = new T.MeshStandardMaterial({ + color: o.color, + roughness: o.roughness ?? 0.85, + metalness: o.metalness ?? 0 + }); + if (o.flatShading) mat.flatShading = true; + if (o.side === 'double') mat.side = T.DoubleSide; + else if (o.side === 'back') mat.side = T.BackSide; // B8: material EMISSIVE + opacity, so a game can glow a pad or float a // translucent marker without a shader doc (which the user found "strange"). // emissiveIntensity multiplies the emissive COLOUR, so both are needed. @@ -1482,6 +1637,35 @@ const DEFS = [ if (pos && o.type !== 'spline') object.position.set(pos[0], pos[1], pos[2]); if (rot) object.rotation.set(rot[0], rot[1], rot[2]); if (o.physics && !mirror) object.userData.physics = o.physics; + // 30 author-kit: object FLAGS. Each lands where the app itself keeps it, so the + // .tpscene carries it the ordinary way (userData rides toJSON; a clip rides the + // animations block). A mirror ghost takes none of them (it is decoration). + if (!mirror) { + // select-through: a shell (wall, ceiling, glass) the editor's pick passes by + if (o.pick === 'through') object.userData.pick = 'through'; + // the transform ORIGIN (objectOrigin's local pivot offset) — a Door preset + // swings about it, so a hinge is authored here + if (Array.isArray(o.origin)) object.userData.origin = o.origin.map(Number); + // a particle emitter from a preset (particlePresets), optionally patched — + // the config addParticlesPreset writes to userData.particles + if (o.particles) { + const spec = typeof o.particles === 'string' ? { preset: o.particles } : o.particles; + if (!s.particlePresets.PARTICLE_PRESETS.some((/** @type {any} */ p) => p.key === spec.preset)) + throw new Error('object "' + o.name + '": no particle preset "' + spec.preset + '"'); + const base = s.particlePresets.particlePreset(spec.preset); + if (!base) throw new Error('object "' + o.name + '": no particle preset "' + spec.preset + '"'); + const { preset: _preset, ...patch } = spec; + object.userData.particles = { ...structuredClone(base), ...patch }; + } + // animation presets are applied once the object has a uuid in the scene + if (o.anim) animQueue.push({ object, anim: o.anim }); + } + // a spot/directional aims by rotation at a WORLD point (see the light branch) + if (o.target && (object.isSpotLight || object.isDirectionalLight)) { + const t = o.target; + object.updateMatrixWorld(true); + object.lookAt(mirror ? -t[0] : t[0], t[1], t[2]); + } if (o.shadow === false || opts.shadow === false) { // shadowDefaults sweeps cast/receive back ON unless the object opts out object.castShadow = false; @@ -1508,6 +1692,41 @@ const DEFS = [ } group.add(build(o)); } + // 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 + // target on that same forward). Saved with the light: LightShadow.toJSON carries the + // camera, so the fitted frustum is what the file (and every peer) renders with. + if (fitShadows.length) { + group.updateMatrixWorld(true); + const bounds = new T.Box3(); + group.traverse((/** @type {any} */ n) => { + if (n.isMesh && !n.userData?.camera) bounds.expandByObject(n); + }); + if (!bounds.isEmpty()) { + const corners = []; + for (const x of [bounds.min.x, bounds.max.x]) + for (const y of [bounds.min.y, bounds.max.y]) + for (const z of [bounds.min.z, bounds.max.z]) corners.push(new T.Vector3(x, y, z)); + for (const light of fitShadows) { + light.updateMatrixWorld(true); + const inv = light.matrixWorld.clone().invert(); + const local = new T.Box3().setFromPoints(corners.map((c) => c.clone().applyMatrix4(inv))); + const pad = Math.max(local.max.x - local.min.x, local.max.y - local.min.y) * 0.05 + 0.5; + const cam = light.shadow.camera; + // symmetric about the light's axis (the shadow camera is centred on it) + const rx = Math.max(Math.abs(local.min.x), Math.abs(local.max.x)) + pad; + const ry = Math.max(Math.abs(local.min.y), Math.abs(local.max.y)) + pad; + cam.left = -rx; + cam.right = rx; + cam.bottom = -ry; + cam.top = ry; + cam.near = Math.max(0.1, -local.max.z - pad); + cam.far = Math.max(cam.near + 1, -local.min.z + pad); + cam.updateProjectionMatrix(); + } + } + } s.objectsGroup.update((v) => v); // ---- C5.3: the scene DATA a game carries beyond its objects ------------- @@ -1542,6 +1761,22 @@ const DEFS = [ } s.animationPreview.animationsRestore(sets, false); } + // 30 author-kit: `anim: ''` (or a list of them) — through applyPreset, the + // Animation window's own preset path, so each is an ORDINARY authored clip keyed + // from where the object stands (and, for a Door, about its `origin`). A clip is + // authored, not playing: a Play Animation node (or the Animation window) runs it. + if (animQueue.length && s.animationPreview) { + const presets = s.animationPreview.PRESETS; + for (const { object, anim } of animQueue) { + for (const name of Array.isArray(anim) ? anim : [anim]) { + const key = Object.keys(presets).find( + (k) => k === String(name).toLowerCase() || presets[k].name.toLowerCase() === String(name).toLowerCase() + ); + if (!key) throw new Error('object "' + object.name + '": no animation preset "' + name + '" (' + Object.keys(presets).join(', ') + ')'); + s.animationPreview.applyPreset(key, object.uuid, object); + } + } + } // 28-G: THE TRACK. Into the Explorer (content-hashed, so re-runs dedupe) and into // the scene's music slot with playing OFF — the graph plays it from Start. The // hash is what a Sound node addresses, hence the `'$music'` remap below. diff --git a/tests/e2e/author-kit.test.cjs b/tests/e2e/author-kit.test.cjs new file mode 100644 index 00000000..e205a0bc --- /dev/null +++ b/tests/e2e/author-kit.test.cjs @@ -0,0 +1,244 @@ +// 30 author-kit: what a template/game DEF can say. The builder (scripts/author-templates.cjs) +// grew primitives, light kinds, physical/toon materials and object flags; each is only real +// if it survives the .tpscene round trip, so this suite AUTHORS a def-under-test through the +// real script (`--def --only --out `), then LOADS the written file back +// through sessions.readSessionZip + applySession — the Templates modal's own read path — and +// reads each object's geometry / material / userData / clip. One check per feature. +// +// Run: APP_URL=https://theprototype.app:5233/ npm run e2e -- author-kit +// (the authoring pass drives its own headless browser against the same APP_URL) +const h = require('./helpers.cjs'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { execFileSync } = require('child_process'); + +const SLUG = 'author-kit-test'; + +/** The def-under-test: every new field once, on a named object. */ +const DEF = { + kind: 'template', + slug: SLUG, + title: 'Author kit test', + description: 'every author-kit field, once', + license: 'CC0-1.0', + author: 'theprototype', + objects: [ + { type: 'box', name: 'Floor', color: 0x808890, size: [20, 0.2, 20], pos: [0, -0.1, 0] }, + { type: 'box', name: 'Rounded', color: 0xd97706, size: [2, 1, 1.2], bevel: 0.2, pos: [-4, 0.5, 0] }, + { type: 'capsule', name: 'Capsule', color: 0x3b82f6, r: 0.4, h: 1.2, pos: [-2, 1, 0] }, + { type: 'plane', name: 'Plane', color: 0x22c55e, size: [2, 3], pos: [0, 1.5, -3] }, + { type: 'ring', name: 'Ring', color: 0xeab308, r: 1, inner: 0.6, pos: [2, 1.5, -3] }, + { type: 'icosahedron', name: 'Ico', color: 0xa855f7, r: 0.6, detail: 1, pos: [2, 0.6, 0] }, + { type: 'dodecahedron', name: 'Dodeca', color: 0xec4899, r: 0.6, pos: [4, 0.6, 0] }, + { + type: 'sphere', + name: 'Glass', + color: 0xffffff, + r: 0.6, + pos: [0, 0.6, 2], + clearcoat: 0.8, + clearcoatRoughness: 0.15, + transmission: 0.9, + thickness: 0.5, + ior: 1.45, + sheen: 0.4, + sheenColor: 0xff0000, + roughness: 0.1 + }, + { type: 'sphere', name: 'Plain physical', color: 0x999999, r: 0.3, pos: [-2, 0.3, 2], physical: true }, + { type: 'box', name: 'Glow', color: 0x111111, size: [0.5, 0.5, 0.5], pos: [2, 0.25, 2], emissive: 0x00ffcc, emissiveIntensity: 2.5 }, + { type: 'cone', name: 'Faceted', color: 0x999999, r: 0.4, h: 0.8, pos: [4, 0.4, 2], flatShading: true, side: 'double' }, + { type: 'sphere', name: 'Toon', color: 0x44aa88, r: 0.5, pos: [-4, 0.5, 2], toon: true }, + { type: 'box', name: 'Shell', color: 0xffffff, size: [8, 3, 0.1], pos: [0, 1.5, -5], opacity: 0.1, pick: 'through' }, + { type: 'cylinder', name: 'Coin', color: 0xfacc15, r: 0.3, h: 0.05, pos: [-4, 1.5, -2], rot: [Math.PI / 2, 0, 0], anim: 'Turntable' }, + { type: 'box', name: 'Door', color: 0x8b5a2b, size: [1, 2, 0.1], pos: [4, 1, -2], origin: [-0.5, 0, 0], anim: ['door', 'Pulse'] }, + { type: 'sphere', name: 'Fountain', color: 0x999999, r: 0.2, pos: [0, 0.2, 4], particles: { preset: 'sparkles', count: 33 } }, + { type: 'box', name: 'Brazier', color: 0x333333, size: [0.4, 0.4, 0.4], pos: [-2, 0.2, 4], particles: 'fire' }, + { type: 'light', kind: 'spot', name: 'Spot', color: 0xffeecc, intensity: 20, angle: 0.5, penumbra: 0.4, pos: [0, 6, 4], target: [0, 0, 0] }, + { type: 'light', kind: 'directional', name: 'Key', color: 0xffffff, intensity: 2, pos: [10, 14, 8], target: [0, 0, 0], shadowMapSize: 1024 }, + { type: 'light', kind: 'hemisphere', name: 'Fill', color: 0xbbddff, groundColor: 0x332211, intensity: 0.6, pos: [0, 5, 0] }, + { type: 'light', name: 'Bulb', color: 0xffaa55, intensity: 3, distance: 8, pos: [3, 2, 3] } + ] +}; + +/** Author the def through the real script into a fresh temp folder; returns the .tpscene + * bytes and the thumbnail bytes. @param {any} def @param {string} tag */ +function author(def, tag) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'author-kit-' + tag + '-')); + const file = path.join(dir, 'def.json'); + fs.writeFileSync(file, JSON.stringify(def)); + const out = path.join(dir, 'out'); + const script = path.join(__dirname, '../../scripts/author-templates.cjs'); + const log = execFileSync('node', [script, '--def', file, '--only', def.slug, '--out', out], { + env: { ...process.env, APP_URL: h.URL }, + encoding: 'utf8', + timeout: 240000 + }); + console.log(log.trim().split('\n').filter((l) => /B, thumb|WARN|FATAL|PAGEERROR/.test(l)).join('\n')); + const section = def.kind === 'game' ? 'games' : def.kind === 'template' ? 'templates' : 'examples'; + const base = path.join(out, section, def.slug); + return { + scene: fs.readFileSync(path.join(base, 'scene.tpscene')), + thumb: fs.existsSync(path.join(base, 'thumb.webp')) ? fs.readFileSync(path.join(base, 'thumb.webp')) : null + }; +} + +/** Load a .tpscene into the page through the Templates modal's own read path. */ +async function load(page, bytes) { + return page.evaluate(async (b64) => { + const s = window.__stores; + const bin = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0)); + const payload = await s.sessions.readSessionZip(bin.buffer); + if (!payload) return false; + await s.sessions.applySession(payload, { backup: false, replicate: false }); + return true; + }, bytes.toString('base64')); +} + +h.run(async () => { + // ---- P0: primitives, lights, materials, flags ------------------------------------- + const authored = author(DEF, 'p0'); + check(authored.scene.length > 1000, 'the def authored a .tpscene (' + authored.scene.length + ' B)'); + + const browser = await h.launch({ args: h.GPU_ARGS }); + const peer = await h.setupPage(browser, 'author-kit', { context: { viewport: { width: 1540, height: 774 } } }); + const page = peer.page; + await page.waitForFunction(() => !!window.__stores?.sessions && !!window.__stores?.animationPreview, { timeout: 30000 }); + check(await load(page, authored.scene), 'the authored file loads through readSessionZip + applySession'); + await page.waitForTimeout(800); + + const r = await page.evaluate(() => { + const s = window.__stores; + /** @type {any} */ let group; + s.objectsGroup.subscribe((g) => (group = g))(); + const by = (/** @type {string} */ n) => group.getObjectByName(n); + const geo = (/** @type {string} */ n) => by(n)?.geometry; + const mat = (/** @type {string} */ n) => by(n)?.material; + /** @type {any} */ let anims; + s.animationPreview.animations.subscribe((v) => (anims = v))(); + const clipNames = (/** @type {string} */ n) => Object.values(anims[by(n)?.uuid]?.clips ?? {}).map((/** @type {any} */ c) => c.name); + const rounded = geo('Rounded'); + rounded.computeBoundingBox(); + const bb = rounded.boundingBox; + // a rounded box's corner is CUT: no vertex reaches the sharp corner's |x|+|y|+|z| + const pos = rounded.attributes.position.array; + let cornerMax = 0; + for (let i = 0; i < pos.length; i += 3) cornerMax = Math.max(cornerMax, Math.abs(pos[i]) + Math.abs(pos[i + 1]) + Math.abs(pos[i + 2])); + const aim = (/** @type {string} */ n, /** @type {number[]} */ t) => { + const l = by(n); + l.updateMatrixWorld(true); + const wp = l.getWorldPosition(new s.THREE.Vector3()); + const fwd = new s.THREE.Vector3(0, 0, -1).applyQuaternion(l.getWorldQuaternion(new s.THREE.Quaternion())); + return fwd.dot(new s.THREE.Vector3(...t).sub(wp).normalize()); + }; + const key = by('Key'); + const cam = key?.shadow?.camera; + return { + roundedType: rounded.type, + roundedVerts: rounded.attributes.position.count, + roundedSize: [bb.max.x - bb.min.x, bb.max.y - bb.min.y, bb.max.z - bb.min.z], + cornerMax, + capsule: geo('Capsule')?.type, + capsuleRadius: geo('Capsule')?.parameters?.radius, + plane: geo('Plane')?.type, + ring: geo('Ring')?.type, + ringInner: geo('Ring')?.parameters?.innerRadius, + ico: geo('Ico')?.type, + icoDetail: geo('Ico')?.parameters?.detail, + dodeca: geo('Dodeca')?.type, + glass: { + physical: !!mat('Glass')?.isMeshPhysicalMaterial, + clearcoat: mat('Glass')?.clearcoat, + clearcoatRoughness: mat('Glass')?.clearcoatRoughness, + transmission: mat('Glass')?.transmission, + thickness: mat('Glass')?.thickness, + ior: mat('Glass')?.ior, + sheen: mat('Glass')?.sheen, + sheenColor: mat('Glass')?.sheenColor?.getHexString() + }, + plainPhysical: !!mat('Plain physical')?.isMeshPhysicalMaterial, + floorStandard: mat('Floor')?.type, + glow: { emissive: mat('Glow')?.emissive?.getHexString(), intensity: mat('Glow')?.emissiveIntensity }, + faceted: { flat: mat('Faceted')?.flatShading, side: mat('Faceted')?.side, double: s.THREE.DoubleSide }, + toon: !!mat('Toon')?.isMeshToonMaterial, + shellPick: by('Shell')?.userData?.pick, + floorPick: by('Floor')?.userData?.pick ?? null, + coinClips: clipNames('Coin'), + doorClips: clipNames('Door'), + doorOrigin: by('Door')?.userData?.origin, + fountain: by('Fountain')?.userData?.particles, + brazier: by('Brazier')?.userData?.particles?.preset, + spot: { + is: !!by('Spot')?.isSpotLight, + angle: by('Spot')?.angle, + penumbra: by('Spot')?.penumbra, + shadow: by('Spot')?.castShadow, + aim: aim('Spot', [0, 0, 0]) + }, + key: { + is: !!key?.isDirectionalLight, + shadow: key?.castShadow, + aim: aim('Key', [0, 0, 0]), + frustum: cam ? [cam.left, cam.right, cam.bottom, cam.top, cam.near, cam.far] : null, + mapSize: key?.shadow?.mapSize?.x + }, + hemi: { is: !!by('Fill')?.isHemisphereLight, ground: by('Fill')?.groundColor?.getHexString() }, + bulb: { is: !!by('Bulb')?.isPointLight, distance: by('Bulb')?.distance } + }; + }); + console.log(JSON.stringify(r)); + + // primitives + check(r.roundedType === 'BufferGeometry', 'bevel: the rounded box is baked into a plain BufferGeometry (ObjectLoader rebuilds it) — ' + r.roundedType); + check(r.roundedVerts > 36, 'bevel: it carries the rounded tessellation (' + r.roundedVerts + ' verts)'); + check(r.roundedSize.every((v, i) => Math.abs(v - [2, 1, 1.2][i]) < 1e-3), 'bevel: its outer size is still the authored size ' + r.roundedSize.map((v) => v.toFixed(3))); + check(r.cornerMax < 2.1 - 0.05, 'bevel: the corners are cut (max |x|+|y|+|z| ' + r.cornerMax.toFixed(3) + ' < a sharp 2.1)'); + check(r.capsule === 'CapsuleGeometry' && Math.abs(r.capsuleRadius - 0.4) < 1e-6, 'capsule: CapsuleGeometry r=0.4 (' + r.capsule + ')'); + check(r.plane === 'PlaneGeometry', 'plane: PlaneGeometry (' + r.plane + ')'); + check(r.ring === 'RingGeometry' && Math.abs(r.ringInner - 0.6) < 1e-6, 'ring: RingGeometry inner 0.6 (' + r.ring + ')'); + check(r.ico === 'IcosahedronGeometry' && r.icoDetail === 1, 'icosahedron: IcosahedronGeometry detail 1 (' + r.ico + ')'); + check(r.dodeca === 'DodecahedronGeometry', 'dodecahedron: DodecahedronGeometry (' + r.dodeca + ')'); + // materials + check(r.glass.physical, 'physical: a physical-only field makes a MeshPhysicalMaterial'); + check(Math.abs(r.glass.clearcoat - 0.8) < 1e-6, 'physical: clearcoat 0.8 survives (' + r.glass.clearcoat + ')'); + check(Math.abs(r.glass.clearcoatRoughness - 0.15) < 1e-6, 'physical: clearcoatRoughness 0.15 survives'); + check(Math.abs(r.glass.transmission - 0.9) < 1e-6, 'physical: transmission 0.9 survives (' + r.glass.transmission + ')'); + check(Math.abs(r.glass.thickness - 0.5) < 1e-6, 'physical: thickness 0.5 survives'); + check(Math.abs(r.glass.ior - 1.45) < 1e-6, 'physical: ior 1.45 survives (' + r.glass.ior + ')'); + check(Math.abs(r.glass.sheen - 0.4) < 1e-6 && r.glass.sheenColor === 'ff0000', 'physical: sheen 0.4 + sheenColor #ff0000 survive (' + r.glass.sheenColor + ')'); + check(r.plainPhysical, 'physical: `physical: true` alone makes a MeshPhysicalMaterial'); + check(r.floorStandard === 'MeshStandardMaterial', 'an object using none of it stays MeshStandardMaterial (' + r.floorStandard + ')'); + check(r.glow.emissive === '00ffcc' && Math.abs(r.glow.intensity - 2.5) < 1e-6, 'emissive + emissiveIntensity 2.5 survive (' + JSON.stringify(r.glow) + ')'); + check(r.faceted.flat === true, 'flatShading survives'); + check(r.faceted.side === r.faceted.double, "side: 'double' survives as DoubleSide"); + check(r.toon, 'toon: MeshToonMaterial'); + // flags + check(r.shellPick === 'through', "pick: 'through' lands on userData.pick"); + check(r.floorPick === null, 'an unflagged object carries no pick key'); + check(r.coinClips.includes('Turntable'), 'anim: the Turntable preset is an authored clip on the coin (' + r.coinClips + ')'); + check(r.doorClips.includes('Door') && r.doorClips.includes('Pulse'), 'anim: a list of presets, by key or by name (' + r.doorClips + ')'); + check(Array.isArray(r.doorOrigin) && r.doorOrigin[0] === -0.5, 'origin: the hinge offset lands on userData.origin (' + JSON.stringify(r.doorOrigin) + ')'); + check(r.fountain?.preset === 'sparkles' && r.fountain?.count === 33 && r.fountain?.sprite === 'star', 'particles: a preset + a patch (count 33) on userData.particles'); + check(r.brazier === 'fire', "particles: a bare preset name ('fire')"); + // lights + check(r.spot.is && Math.abs(r.spot.angle - 0.5) < 1e-6 && Math.abs(r.spot.penumbra - 0.4) < 1e-6, 'spot: SpotLight with angle 0.5 + penumbra 0.4'); + check(r.spot.shadow === true, 'spot: casts shadows by default (createLight convention)'); + check(r.spot.aim > 0.999, 'spot: aimed by rotation at its target (forward . to-target = ' + r.spot.aim.toFixed(4) + ')'); + check(r.key.is && r.key.shadow === true, 'directional: DirectionalLight casting shadows'); + check(r.key.aim > 0.999, 'directional: aimed at its target (' + r.key.aim.toFixed(4) + ')'); + check( + !!r.key.frustum && r.key.frustum[1] > 5.5 && r.key.frustum[1] < 40 && r.key.frustum[4] > 0.1 && r.key.frustum[5] > r.key.frustum[4] + 10, + 'directional: its shadow frustum is FITTED to the scene, not three\'s default ±5 (' + (r.key.frustum ?? []).map((v) => v.toFixed(1)) + ')' + ); + check(r.key.mapSize === 1024, 'directional: shadowMapSize 1024 survives (' + r.key.mapSize + ')'); + check(r.hemi.is && r.hemi.ground === '332211', 'hemisphere: HemisphereLight with its ground colour'); + check(r.bulb.is && r.bulb.distance === 8, 'point: the existing point light is unchanged'); + + await h.finish(browser); +}); + +/** @param {boolean} ok @param {string} label */ +function check(ok, label) { + h.check(ok, label); +} From 688dc407712e45ac6bc65a513d9ab870f76218ad Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Wed, 23 Sep 2026 00:38:06 +0300 Subject: [PATCH 02/12] [fix] 30 P0: console hygiene - the PCF shadow warning, the router pushState warning, rapier's init warning - App.svelte: . threlte defaults to PCFSoftShadowMap, which three 0.185 deprecates and downgrades to PCF on the first shadow render - so the picture is identical and the warning is gone. - playMode.js: the back-button marker is a SvelteKit SHALLOW entry (pushState from $app/navigation) instead of a raw history.pushState, which the router answers in dev with "will conflict with SvelteKit's router". Same url, same navigation index, so the Back that spends it never navigates. New playMarkerState() reads the state under sveltekit:states. - physics.js: the wasm "deprecated parameters for the initialization function" warning is rapier-compat's OWN init() handing wasm-bindgen a bare byte array; the call is inside the frozen dependency, so there is no single-object form to pass. It is filtered for the length of that one call (exact prefix, restored in finally) - every other warn passes. - helpers.cjs: page.__console collects every console message from BEFORE the first navigation (the PCF warning fires on frame one; a listener attached after boot misses it). - touch-play: reads the marker through playMarkerState() (the state moved under the router's key). Suite console-hygiene (new) 13/13. Counterfactual: all three source fixes reverted at once -> 4 red (the PCF warning, the marker not being a shallow entry, the router warning, the wasm warning), restored -> green. touch-play 39/39. Held at base: game-state 45, game-presence 61, hud-actions 65, game-towers 20, play-interact 46. svelte-check 336/47 (no new message), vitest 196, build green. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/App.svelte | 6 +- src/lib/physics.js | 26 ++++++- src/lib/playMode.js | 38 +++++++-- tests/e2e/console-hygiene.test.cjs | 121 +++++++++++++++++++++++++++++ tests/e2e/helpers.cjs | 6 ++ tests/e2e/touch-play.test.cjs | 4 +- 6 files changed, 191 insertions(+), 10 deletions(-) create mode 100644 tests/e2e/console-hygiene.test.cjs diff --git a/src/App.svelte b/src/App.svelte index f93a67e8..4da8044a 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -2,6 +2,10 @@ import { safeStorage } from '$lib/safeStorage'; import { onMount } from 'svelte' import { Canvas } from '@threlte/core' + // 30 P0: threlte's Canvas defaults `shadows` to PCFSoftShadowMap, which three 0.185 + // deprecates and silently downgrades to PCFShadowMap on the first shadow render (with a + // console warning). Asking for PCF directly is the identical picture, minus the warning. + import { PCFShadowMap } from 'three' import Scene from './components/Scene.svelte' import Menu from './components/Menu.svelte' import ConfirmModal from './components/menu/ConfirmModal.svelte' @@ -582,7 +586,7 @@ import { startMusicToolbox } from './lib/musicToolbox' step reallocates the composer's render targets, so animating the inset turns one realloc into one per frame. -->
- +
diff --git a/src/lib/physics.js b/src/lib/physics.js index 1ed12567..14f0268b 100644 --- a/src/lib/physics.js +++ b/src/lib/physics.js @@ -148,10 +148,34 @@ const IMPACT_MIN_DOWN_VY = 1.2; // m/s downward (pre-step) for a contact to coun export async function warmup() { if (RAPIER) return; const module = await import('@dimforge/rapier3d-compat'); - await module.init(); + await initQuietly(module); RAPIER = module; } +// 30 P0: rapier-compat's own `init()` hands wasm-bindgen's init a bare byte array — the +// pre-0.2.93 calling convention — and wasm-bindgen answers with "using deprecated +// parameters for the initialization function; pass a single object instead". The call +// is INSIDE rapier's bundle (`init` takes no arguments and the inner init is not +// exported), so there is no single-object form for us to pass, and rapier is a FROZEN +// dependency (solver behaviour). So the one warning is filtered for the length of that +// call and nothing else: an exact-prefix match, restored in `finally`, every other +// console.warn passes straight through. +const RAPIER_INIT_WARNING = 'using deprecated parameters for the initialization function'; + +/** @param {any} module */ +async function initQuietly(module) { + const warn = console.warn; + console.warn = (/** @type {any[]} */ ...args) => { + if (typeof args[0] === 'string' && args[0].startsWith(RAPIER_INIT_WARNING)) return; + warn.apply(console, args); + }; + try { + await module.init(); + } finally { + console.warn = warn; + } +} + /** @param {any} object */ function transformOf(object) { return { diff --git a/src/lib/playMode.js b/src/lib/playMode.js index 6f573a36..a19f886b 100644 --- a/src/lib/playMode.js +++ b/src/lib/playMode.js @@ -3,6 +3,12 @@ import { isLocked, isVRMode, vrOverride, vrPassthrough } from '../stores/sceneSt // appStore is a LEAF (svelte/store and nothing else — sceneStore already pulls in more // than it does), so this does not widen the cycle surface the note below is about. import { showToast } from '../stores/appStore'; +// 30 P0: the back-button marker goes through SvelteKit's shallow-routing entry, not a raw +// `history.pushState` — the router patches the raw call in dev with a "will conflict with +// SvelteKit's router" warning, and it is right: its own popstate handler reads the index +// keys only ITS pushState writes. `$app/navigation` is SSR-safe to IMPORT (only a CALL on +// the server throws, and the marker is only ever pushed from a browser play press). +import { pushState } from '$app/navigation'; // THE PLAY STATE MACHINE, lifted out of Controls.svelte so the play FAB, the FAB's // right-click mode menu and (next) a keyboard shortcut all press the same button. @@ -154,20 +160,38 @@ export function exitPlay() { // no history to go back to, pushing and popping our own entry is invisible. // // It is safe to pop because the marker is ALWAYS the top entry when we hold one: this -// app keeps no state in the URL and nothing else in it calls pushState (grep), so -// there is no router to fight and nothing of the user's can be underneath ours. The -// flag is the whole guard — see `consumePlayMarker` for the double-pop it prevents. +// app keeps no state in the URL and nothing else in it pushes history entries (grep), so +// nothing of the user's can be underneath ours. The flag is the whole guard — see +// `consumePlayMarker` for the double-pop it prevents. +// +// 30 P0: the entry is a SvelteKit SHALLOW entry (`pushState` from $app/navigation) — +// same url, same navigation index, so the router's popstate handler treats the Back +// that spends it as a state change and never navigates; our listener below still sees +// the popstate. The state object lands under the router's `sveltekit:states` key, which +// is where `playMarkerState()` reads it. let backMarker = false; function pushPlayMarker() { if (backMarker || typeof history === 'undefined') return; try { - // the SAME url with a state object: no navigation, nothing for a router to - // resolve, and `history.state.tpPlay` is a readable answer to "is the marker up?" - history.pushState({ tpPlay: true }, ''); + // the SAME url ('' resolves to the current one) with a state object: no + // navigation, nothing to load, and `playMarkerState()` is a readable answer to + // "is the marker up?" + pushState('', { tpPlay: true }); backMarker = true; } catch { - /* a sandboxed frame can refuse pushState; play simply keeps its normal exits */ + /* a sandboxed frame can refuse pushState (and the router refuses before it has + started); play simply keeps its normal exits */ + } +} + +/** the marker's state object as the router stored it on the CURRENT entry (test/debug view) */ +export function playMarkerState() { + try { + const st = /** @type {any} */ (typeof history === 'undefined' ? null : history.state); + return !!st?.['sveltekit:states']?.tpPlay; + } catch { + return false; } } diff --git a/tests/e2e/console-hygiene.test.cjs b/tests/e2e/console-hygiene.test.cjs new file mode 100644 index 00000000..10ae74ce --- /dev/null +++ b/tests/e2e/console-hygiene.test.cjs @@ -0,0 +1,121 @@ +// 30 P0 — CONSOLE HYGIENE. Three warnings every session printed, each a real fault in +// miniature and each reported from the diagnostics log of a user's Quest: +// +// 1. `WebGLShadowMap: PCFSoftShadowMap has been deprecated. Using PCFShadowMap instead.` +// threlte's defaults `shadows` to PCFSoftShadowMap; three 0.185 downgrades +// it on the first shadow render. App.svelte asks for PCFShadowMap directly now — +// the identical picture, and this suite also asserts the renderer's type IS PCF. +// 2. `Avoid using history.pushState(...)` — SvelteKit's router, answering the play +// marker's raw pushState. playMode.js pushes a SvelteKit SHALLOW entry instead. +// 3. `using deprecated parameters for the initialization function` — wasm-bindgen, +// answering rapier-compat's own init(). physics.js filters exactly that line for +// the length of the call (the call is inside rapier, which is a frozen dependency). +// +// The console is read from BEFORE the first navigation (helpers.setupPage attaches its +// listener ahead of page.goto), because the PCF warning fires on the very first frame +// with a shadow-casting light — a listener attached after boot would never see it and +// this suite would pass over the bug. +const h = require('./helpers.cjs'); + +const BANNED = [ + { id: 'pcf', re: /PCFSoftShadowMap has been deprecated/ }, + { id: 'pushState', re: /Avoid using `history\.pushState/ }, + { id: 'wasm-init', re: /using deprecated parameters for the initialization function/ } +]; + +h.run(async () => { + const browser = await h.launch({ args: h.GPU_ARGS }); + const A = await h.setupPage(browser, 'A'); + const page = A.page; + /** @param {string} id */ + const seen = (id) => { + const rule = BANNED.find((b) => b.id === id); + return (page.__console ?? []).filter((m) => rule.re.test(m.text)); + }; + + h.check(Array.isArray(page.__console), 'the console is captured from before the first navigation'); + + // a lit, shadow-casting scene: a box under the default environment's sun + await page.evaluate(async () => { + const s = window.__stores; + s.commandsHandler.sceneCommand('/create box'); + await new Promise((r) => setTimeout(r, 800)); + }); + await page.waitForTimeout(1500); + + // ---- 1. the shadow map -------------------------------------------------------- + const shadow = await page.evaluate(() => { + const THREE = window.__stores.THREE; + let renderer; + window.__stores.globalRenderer.subscribe((v) => (renderer = v))(); + return { + enabled: !!renderer?.shadowMap?.enabled, + type: renderer?.shadowMap?.type, + pcf: THREE.PCFShadowMap, + soft: THREE.PCFSoftShadowMap, + lights: (() => { + let n = 0; + let scene; + window.__stores.globalScene.subscribe((v) => (scene = v))(); + scene?.traverse((o) => { + if (o.isLight && o.castShadow) n++; + }); + return n; + })() + }; + }); + h.check(shadow.lights > 0, `premise: a shadow-casting light is in the scene (${shadow.lights})`); + h.check(shadow.enabled, 'shadows stay enabled'); + h.check(shadow.type === shadow.pcf, `the renderer asks for PCFShadowMap (type ${shadow.type}, PCF ${shadow.pcf}, soft ${shadow.soft})`); + h.check(seen('pcf').length === 0, `no PCFSoftShadowMap deprecation warning (${seen('pcf').length})`); + + // ---- 2 + 3. enter play (pushes the back marker, warms rapier), exit --------------- + await page.evaluate(async () => { + const s = window.__stores; + await s.physics.warmup(); + s.playMode.requestPlay(); + }); + await h.eventually( + () => page.evaluate(() => window.__stores.playMode.playBackMarker()), + (v) => v === true, + 'entering play pushes the back marker', + 6000 + ); + const marker = await page.evaluate(() => window.__stores.playMode.playMarkerState?.() ?? false); + h.check(marker === true, 'the marker is a SvelteKit shallow entry (sveltekit:states.tpPlay)'); + await page.evaluate(() => window.__stores.playMode.exitPlay()); + await h.eventually( + () => page.evaluate(() => window.__stores.playMode.playBackMarker()), + (v) => v === false, + 'leaving play spends the marker', + 6000 + ); + await h.eventually( + () => page.evaluate(() => { let v; window.__stores.isLocked.subscribe((x) => (v = x))(); return v; }), + (v) => v === null, + 'and play settles back to the editor', + 6000 + ); + await page.waitForTimeout(600); + const rapierReady = await page.evaluate(async () => { + try { + await window.__stores.physics.warmup(); + return true; + } catch { + return false; + } + }); + h.check(rapierReady, 'premise: rapier initialised (its init is what prints the wasm warning)'); + h.check(seen('pushState').length === 0, `no SvelteKit router pushState warning (${seen('pushState').length})`); + h.check(seen('wasm-init').length === 0, `no wasm "deprecated parameters" warning (${seen('wasm-init').length})`); + + // the filter must not eat anything ELSE: an ordinary warning still gets through + await page.evaluate(() => console.warn('console-hygiene: an ordinary warning')); + await page.waitForTimeout(200); + h.check( + (page.__console ?? []).some((m) => /console-hygiene: an ordinary warning/.test(m.text)), + 'an ordinary console.warn still reaches the console (the filter is scoped to one call)' + ); + + await h.finish(browser); +}); diff --git a/tests/e2e/helpers.cjs b/tests/e2e/helpers.cjs index ca8d20c0..28a62fae 100644 --- a/tests/e2e/helpers.cjs +++ b/tests/e2e/helpers.cjs @@ -119,6 +119,12 @@ async function setupPage(browser, name, options = {}) { page.__errors.push(err.message ?? String(err)); console.log(`[${name} pageerror] ` + err.stack); }); + // 30 P0: every console message, from BEFORE the first navigation — a warning printed on + // the first frame (three's shadow-map deprecation) is invisible to a listener attached + // after boot. `{type, text}`; `console-hygiene` reads it. + /** @type {{type: string, text: string}[]} */ + page.__console = []; + page.on('console', (message) => page.__console.push({ type: message.type(), text: message.text() })); // 27-D: `options.hash` loads the app WITH a hash (`{ hash: '#safe' }`). It has to be // on the initial navigation, not set afterwards: safe mode is read once during // onMount, so a hash assigned to a live page arrives long after the decision. diff --git a/tests/e2e/touch-play.test.cjs b/tests/e2e/touch-play.test.cjs index 9fb0e0e9..3d0f1e72 100644 --- a/tests/e2e/touch-play.test.cjs +++ b/tests/e2e/touch-play.test.cjs @@ -94,7 +94,9 @@ const watchClaims = (page) => const historyState = (page) => page.evaluate(() => ({ - tpPlay: !!(history.state && history.state.tpPlay), + // 30 P0: the marker is a SvelteKit SHALLOW entry now, so its state object sits under + // the router's `sveltekit:states` key — playMarkerState() reads it there + tpPlay: window.__stores.playMode.playMarkerState(), marker: window.__stores.playMode.playBackMarker(), href: location.href, mounted: !!document.querySelector('#play-button') || !!document.querySelector('canvas') From bafc4191cd5065418640d958acb1e0efc9e58177 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Wed, 23 Sep 2026 00:59:30 +0300 Subject: [PATCH 03/12] =?UTF-8?q?[feat]=20author-kit=20p1:=20a=20custom=20?= =?UTF-8?q?sky=20=E2=80=94=20gradient=20background,=20fog,=20ground,=20sun?= =?UTF-8?q?,=20hemi?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - a def `env` may be {preset:'custom' | , base?, exposure, background:'#hex' | {top, bottom}, fog:{color, near, far} | null, ground:{color, roughness?}, sun:{color, intensity, dir | position} | null, hemi:{sky, ground, intensity} | null}; colours may be numbers. It builds a payload from a base preset and commits it through the environment module's own custom path (applyCustomPreset), so the scene saves it as customPreset and a peer / late joiner gets it on the existing environment singleton. The payload's own exposure stays 1 and the def's exposure is the STATE multiplier (applyCustomPreset would otherwise square it). A plain preset env takes the old line verbatim - environment.js (ADDITIVE, absent on every stock preset and every older save): `gradient: {top, bottom}` -> a cached 2x256 CanvasTexture background (`background` keeps the flat horizon colour for the backgroundColor store and older peers); `ground: {color, roughness?}` -> an `env-ground` disc in ENV_ROOT (scene root, never synced or saved as an object) that receives the sun's shadows, so the ShadowMaterial catcher stands down while it shows; hidden in wireframe and in passthrough (it would cover the real room). Read only through skyGradientOf / skyGroundOf (a malformed value reads as absent) - the per-def reset uses environmentRestore(null) — setEnvironment('studio') kept a custom payload in the state, which environmentSnapshot would have saved into the NEXT def - suite author-kit: + a sky section (state, texture background, fog, ground visible, catcher down, sun dir, hemi, exposure not squared) and a PIXEL check on the loaded scene: sky bands R 206 vs 81 (upper redder), B 206 vs 81 (lower bluer) — 53 PASS - counterfactuals: the builder ignoring the gradient -> 4 FAIL (saved gradient, texture background, both pixel bands 34/34, 255/255); environment.js skyGradientOf -> null -> 3 FAIL (texture background + both pixel bands) - byte-identity: level-blockout/towers/stars-room/untangle/waves SAME vs the pre-lane build - held: templates-modal 47/0, template-modules 29/0 (= base); svelte-check 336/47 with an identical list; vitest 196; build green on this environment.js Co-Authored-By: Claude Opus 5.5 (1M context) --- scripts/author-templates.cjs | 69 ++++++++++++++++++++++++++- src/lib/environment.js | 89 +++++++++++++++++++++++++++++++++- tests/e2e/author-kit.test.cjs | 90 +++++++++++++++++++++++++++++++++++ 3 files changed, 244 insertions(+), 4 deletions(-) diff --git a/scripts/author-templates.cjs b/scripts/author-templates.cjs index 3a9a0b6a..ddbfc442 100644 --- a/scripts/author-templates.cjs +++ b/scripts/author-templates.cjs @@ -1732,7 +1732,70 @@ const DEFS = [ // ---- C5.3: the scene DATA a game carries beyond its objects ------------- // Each of these lands through the app's own write path, so what the script // produces is exactly what a user authoring by hand would have saved. - if (d.env) s.environment.setEnvironment(d.env.preset ?? d.env, d.env.exposure ?? 1); + // 30 author-kit: a CUSTOM sky. `env` is still a preset name or {preset, exposure} + // (unchanged path, byte-identical); a def that says preset:'custom' or overrides any + // sky field builds a custom payload from a base preset (`base`, else the named + // preset, else studio) and commits it through the environment module's own custom + // path (applyCustomPreset), so the scene saves it as `customPreset` and a peer or a + // late joiner receives it on the environment singleton like any authored sky. + // The payload's own `exposure` stays 1 — the def's exposure is the STATE multiplier + // (applyCustomPreset would otherwise square it). + const SKY_KEYS = ['background', 'fog', 'ground', 'sun', 'hemi']; + const env = d.env && typeof d.env === 'object' ? d.env : null; + /** @param {any} c */ + const col = (c) => (typeof c === 'number' ? hex(c) : c); + if (env && (env.preset === 'custom' || SKY_KEYS.some((k) => env[k] !== undefined))) { + const presets = s.environment.ENVIRONMENT_PRESETS; + const baseKey = env.base ?? (env.preset && env.preset !== 'custom' ? env.preset : 'studio'); + if (!presets[baseKey]) throw new Error('env: no base preset "' + baseKey + '"'); + const payload = JSON.parse(JSON.stringify(presets[baseKey])); + payload.label = env.label ?? 'Custom'; + payload.exposure = 1; + if (env.background !== undefined) { + if (env.background && typeof env.background === 'object') { + // a GRADIENT sky: `background` keeps a flat colour beside it (the horizon) + // for the backgroundColor store and for an older peer that ignores it + payload.gradient = { top: col(env.background.top), bottom: col(env.background.bottom) }; + payload.background = col(env.background.bottom); + } else payload.background = col(env.background); + } + if (env.fog !== undefined) + payload.fog = + env.fog === null + ? null + : { + ...(payload.fog ?? { color: payload.background, near: 30, far: 160 }), + ...env.fog, + ...(env.fog.color != null ? { color: col(env.fog.color) } : {}) + }; + if (env.ground) payload.ground = { color: col(env.ground.color), ...(env.ground.roughness != null ? { roughness: env.ground.roughness } : {}) }; + if (env.sun !== undefined) { + if (env.sun === null) payload.sun = null; + else { + const prev = payload.sun ?? { color: '#ffffff', intensity: 1.8, position: [6, 10, 4] }; + // `dir` points FROM the scene TOWARD the sun; the rig wants a position + const dir = env.sun.dir ? new T.Vector3(...env.sun.dir).normalize().multiplyScalar(16) : null; + payload.sun = { + color: env.sun.color != null ? col(env.sun.color) : prev.color, + intensity: env.sun.intensity ?? prev.intensity, + position: dir ? dir.toArray().map((v) => Math.round(v * 1000) / 1000) : env.sun.position ?? prev.position + }; + } + } + if (env.hemi !== undefined) { + if (env.hemi === null) payload.hemi = null; + else { + const prev = payload.hemi ?? { sky: '#ffffff', ground: '#4c525c', intensity: 1 }; + payload.hemi = { + sky: env.hemi.sky != null ? col(env.hemi.sky) : prev.sky, + ground: env.hemi.ground != null ? col(env.hemi.ground) : prev.ground, + intensity: env.hemi.intensity ?? prev.intensity + }; + } + } + s.environment.applyCustomPreset(payload); + s.environment.setEnvironment('custom', env.exposure ?? 1); + } else if (d.env) s.environment.setEnvironment(d.env.preset ?? d.env, d.env.exposure ?? 1); if (typeof d.gravity === 'number') s.scenePhysics.setSceneGravity(d.gravity); // B8: the whole scenePhysics block (ground/bounds/material/damping/play), not // just gravity — a physics GAME is authored in these numbers. setScenePhysics @@ -1966,7 +2029,9 @@ const DEFS = [ // leave no look or rule behind for the next def — a leaked sky or gravity is // exactly the bug A6 exists to fix, and it would be baked into the next scene s.commandsHandler.sceneCommand('/clear all'); - s.environment.setEnvironment('studio', 1); + // 30 author-kit: the FULL reset — setEnvironment('studio') keeps a custom payload + // in the state, and environmentSnapshot would then save it into the NEXT def + s.environment.environmentRestore(null, false); s.scenePhysics.resetSceneGravity(); s.restoreGraphs({}); // B8: every singleton a game def can now set — a null/empty restore is the diff --git a/src/lib/environment.js b/src/lib/environment.js index 8eaedb53..607d8a6d 100644 --- a/src/lib/environment.js +++ b/src/lib/environment.js @@ -88,6 +88,7 @@ export const ENV_ROOT = 'environment-root'; const RIG_HEMI = 'env-rig-hemi'; const RIG_SUN = 'env-rig-sun'; const CATCHER = 'env-shadow-catcher'; +const GROUND = 'env-ground'; const EXTRA_PREFIX = 'env-extra-'; let userLightFactor = 1; @@ -159,6 +160,73 @@ function shadowCatcher(scene, create) { return disc; } +/** + * 30 author-kit: a custom payload may carry two ADDITIVE sky fields, both absent on every + * stock preset and every older save, so those render exactly as before: + * gradient: {top, bottom} — a vertical background gradient. `background` stays a plain + * colour beside it (the `backgroundColor` store and an older peer, which ignores the + * gradient, read that one — authors set it to the horizon colour). + * ground: {color, roughness?} — a solid ground disc under the scene that RECEIVES the sun's + * shadows (so the ShadowMaterial catcher stands down while it shows). + * Both are read through these two normalizers only — a malformed value reads as absent. + * @param {any} preset @returns {{top: string, bottom: string} | null} */ +export function skyGradientOf(preset) { + const g = preset?.gradient; + return g && typeof g.top === 'string' && typeof g.bottom === 'string' ? { top: g.top, bottom: g.bottom } : null; +} + +/** @param {any} preset @returns {{color: string, roughness: number} | null} */ +export function skyGroundOf(preset) { + const g = preset?.ground; + if (!g || typeof g.color !== 'string') return null; + return { color: g.color, roughness: Number.isFinite(g.roughness) ? g.roughness : 1 }; +} + +/** @type {{key: string, texture: THREE.CanvasTexture} | null} one cached gradient texture */ +let gradientCache = null; + +/** A 2x256 vertical gradient as a screen-filling background texture (three stretches a + * plain texture background over the viewport). Cached by its two colours. + * @param {{top: string, bottom: string}} gradient */ +function gradientTexture(gradient) { + const key = gradient.top + '|' + gradient.bottom; + if (gradientCache?.key === key) return gradientCache.texture; + if (typeof document === 'undefined') return null; + const canvas = document.createElement('canvas'); + canvas.width = 2; + canvas.height = 256; + const ctx = canvas.getContext('2d'); + if (!ctx) return null; + const fill = ctx.createLinearGradient(0, 0, 0, 256); + fill.addColorStop(0, gradient.top); + fill.addColorStop(1, gradient.bottom); + ctx.fillStyle = fill; + ctx.fillRect(0, 0, 2, 256); + const texture = new THREE.CanvasTexture(canvas); + texture.colorSpace = THREE.SRGBColorSpace; + gradientCache?.texture.dispose(); + gradientCache = { key, texture }; + return texture; +} + +/** The solid ground disc (a scene-root mesh in ENV_ROOT, like the catcher, so it never + * enters objectsGroup, sync or a save — the payload's `ground` field is what travels). + * @param {any} scene @param {boolean} create */ +function groundDisc(scene, create) { + const root = envRoot(scene); + let disc = scene.getObjectByName(GROUND); + if (!disc && create) { + disc = new THREE.Mesh(new THREE.CircleGeometry(1, 64), new THREE.MeshStandardMaterial({ color: '#808080', roughness: 1 })); + disc.name = GROUND; + disc.rotation.x = -Math.PI / 2; + // under the catcher's plane and a hair under y = 0, so a floor authored AT 0 wins + disc.position.y = -0.01; + disc.receiveShadow = true; + root.add(disc); + } + return disc; +} + /** Create/update/remove `env-extra-*` lights to mirror state.lights @param {any} scene @param {any[]} defs */ function reconcileExtraLights(scene, defs) { const root = envRoot(scene); @@ -215,7 +283,9 @@ export function applyEnvironment() { scene.background = null; scene.fog = null; } else { - scene.background = new THREE.Color(preset.background); + // 30 author-kit: a gradient sky when the payload carries one, else the flat colour + const gradient = skyGradientOf(preset); + scene.background = (gradient && gradientTexture(gradient)) || new THREE.Color(preset.background); // fog never swallows a big scene: its reach grows with the scene bounds scene.fog = preset.fog ? new THREE.Fog( @@ -283,9 +353,24 @@ export function applyEnvironment() { // object to a real table (the sky/fog lift above is the whole AR stand-down; // the sun rig keeps casting untouched) const shadowsOff = shadowsDisabled(); + // 30 author-kit: an authored solid ground receives the shadows itself, so the catcher + // (which only darkens) stands down while the ground shows + // (and, like the sky, the ground lifts in passthrough: it would cover the real room) + const ground = skyGroundOf(preset); + const groundShown = !!ground && !wireframeActive() && !get(passthroughActive); + const disc = groundDisc(scene, !!ground); + if (disc) { + disc.visible = groundShown; + if (ground) { + disc.material.color.set(ground.color); + disc.material.roughness = ground.roughness; + } + const span = Math.max(200, sceneRadius() * 4); + disc.scale.set(span, span, span); + } const catcher = shadowCatcher(scene, !!(preset.sun && !shadowsOff)); if (catcher) { - catcher.visible = !!(preset.sun && !shadowsOff) && !wireframeActive(); + catcher.visible = !!(preset.sun && !shadowsOff) && !groundShown && !wireframeActive(); const span = Math.max(60, sceneRadius() * 2); catcher.scale.set(span, span, span); } diff --git a/tests/e2e/author-kit.test.cjs b/tests/e2e/author-kit.test.cjs index e205a0bc..df89df06 100644 --- a/tests/e2e/author-kit.test.cjs +++ b/tests/e2e/author-kit.test.cjs @@ -235,6 +235,96 @@ h.run(async () => { check(r.hemi.is && r.hemi.ground === '332211', 'hemisphere: HemisphereLight with its ground colour'); check(r.bulb.is && r.bulb.distance === 8, 'point: the existing point light is unchanged'); + // ---- P1: a custom sky --------------------------------------------------------------- + const SKY = { + kind: 'template', + slug: 'author-kit-sky', + title: 'Author kit sky', + description: 'a custom env: gradient sky, fog, ground, sun, hemi', + license: 'CC0-1.0', + author: 'theprototype', + env: { + preset: 'custom', + exposure: 1.2, + background: { top: '#ff2020', bottom: 0x2020ff }, + fog: { color: '#6070a0', near: 20, far: 90 }, + ground: { color: '#3a5a2a' }, + sun: { color: '#fff0dd', intensity: 2.2, dir: [1, 2, 0.5] }, + hemi: { sky: '#cfe0ff', ground: 0x303820, intensity: 0.7 } + }, + // looking steeply UP, so the whole frame is sky (no grid, no ground, no object) + view: { pos: [0, 1.6, 6], target: [0, 40, -12] }, + objects: [{ type: 'box', name: 'Block', color: 0x999999, size: [1, 1, 1], pos: [0, 0.5, 0] }] + }; + const sky = author(SKY, 'p1'); + check(await load(page, sky.scene), 'the custom-sky file loads'); + await page.waitForTimeout(1200); + const e = await page.evaluate(() => { + const s = window.__stores; + /** @type {any} */ let state; + s.environment.environment.subscribe((v) => (state = v))(); + /** @type {any} */ let scene; + s.globalScene.subscribe((v) => (scene = v))(); + /** @type {any} */ let renderer; + s.globalRenderer.subscribe((v) => (renderer = v))(); + const ground = scene.getObjectByName('env-ground'); + const catcher = scene.getObjectByName('env-shadow-catcher'); + return { + preset: state.preset, + exposure: state.exposure, + custom: state.customPreset, + backgroundIsTexture: !!scene.background?.isTexture, + fog: scene.fog ? { color: scene.fog.color.getHexString(), near: scene.fog.near } : null, + groundVisible: !!ground?.visible, + groundColor: ground?.material?.color?.getHexString(), + catcherVisible: !!catcher?.visible, + rendererExposure: renderer?.toneMappingExposure + }; + }); + console.log(JSON.stringify(e)); + const c = e.custom ?? {}; + check(e.preset === 'custom' && Math.abs(e.exposure - 1.2) < 1e-9, "env: preset 'custom' with the def's exposure 1.2 as the STATE multiplier"); + check(c.exposure === 1 && Math.abs(e.rendererExposure - 1.2) < 1e-6, 'env: exposure is applied once, not squared (renderer ' + e.rendererExposure + ')'); + check(c.gradient?.top === '#ff2020' && c.gradient?.bottom === '#2020ff', 'env: the gradient {top, bottom} is saved (numbers become #hex)'); + check(c.background === '#2020ff', 'env: `background` keeps a flat horizon colour beside the gradient (older peers read it)'); + check(e.backgroundIsTexture, 'env: the live scene background is the gradient texture'); + check(c.fog?.color === '#6070a0' && c.fog?.near === 20 && e.fog?.color === '6070a0', 'env: fog {color, near, far} is applied'); + check(c.ground?.color === '#3a5a2a' && e.groundVisible && e.groundColor === '3a5a2a', 'env: the ground disc shows in the authored colour'); + check(!e.catcherVisible, 'env: the shadow catcher stands down while the ground receives the shadows'); + const sunDir = c.sun?.position ? c.sun.position.map((v) => v / Math.hypot(...c.sun.position)) : []; + const want = [1, 2, 0.5].map((v) => v / Math.hypot(1, 2, 0.5)); + check(sunDir.length === 3 && sunDir.every((v, i) => Math.abs(v - want[i]) < 1e-3) && c.sun.intensity === 2.2 && c.sun.color === '#fff0dd', 'env: sun {color, intensity, dir} -> the rig sun position along dir'); + check(c.hemi?.sky === '#cfe0ff' && c.hemi?.ground === '#303820' && c.hemi?.intensity === 0.7, 'env: hemi {sky, ground, intensity}'); + + // the pixels: the sky's upper band is red-heavy and its lower band blue-heavy + const frame = await h.grabFrame(peer); + const bands = await page.evaluate( + async ({ b64 }) => { + const img = new Image(); + img.src = 'data:image/png;base64,' + b64; + await img.decode(); + const canvas = document.createElement('canvas'); + canvas.width = img.width; + canvas.height = img.height; + const ctx = canvas.getContext('2d'); + ctx.drawImage(img, 0, 0); + // clear of the Connect pill (top centre) and the Controls HUD (bottom centre) + const band = (/** @type {number} */ y0, /** @type {number} */ y1) => { + const x0 = Math.round(img.width * 0.08); + const w = Math.round(img.width * 0.22); + const d = ctx.getImageData(x0, Math.round(img.height * y0), w, Math.round(img.height * (y1 - y0))).data; + const sum = [0, 0, 0]; + for (let i = 0; i < d.length; i += 4) for (let k = 0; k < 3; k++) sum[k] += d[i + k]; + return sum.map((v) => v / (d.length / 4)); + }; + return { top: band(0.18, 0.26), bottom: band(0.74, 0.82) }; + }, + { b64: frame.toString('base64') } + ); + console.log('sky bands', JSON.stringify(bands)); + check(bands.top[0] - bands.bottom[0] > 60, 'pixels: the upper sky is redder than the lower (R ' + bands.top[0].toFixed(0) + ' vs ' + bands.bottom[0].toFixed(0) + ')'); + check(bands.bottom[2] - bands.top[2] > 60, 'pixels: the lower sky is bluer than the upper (B ' + bands.bottom[2].toFixed(0) + ' vs ' + bands.top[2].toFixed(0) + ')'); + await h.finish(browser); }); From 0da7522d098d439a4cedb348f09d334adec63b2a Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Wed, 23 Sep 2026 01:20:30 +0300 Subject: [PATCH 04/12] [feat] 30 P1: the editor stops masquerading as the running game MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - hudDocs: `isGameHud(docs)` + the `hudIsGame` store. THE RULE: a scene is a game when any HUD document holds a screen bound to a game state (`showWhile` set) - that binding is the one thing that makes the editor masquerade (the shared state picks the screen, so the menu paints itself over the editor with nobody in Play). Every Games-tab template has one. A hudbutton->setgamestate wiring alone was considered and NOT taken: with no state-bound screen nothing masquerades, and reading the graph would put a flow edge into a leaf that keeps none. A HUD with no state-bound screen keeps its old behaviour. - HudLayer: outside Play a game's screens (scene and camera docs) are not drawn. The HUD editor's preview eye (`hudPreviewInViewport`) still draws them, with every element INERT: HudElement `editor` (the artboard's swallow, out of the tab order) AND the whole preview click-through (`.hud-inert`), so a previewed menu can never fire the game nor eat a viewport click (the roadmap's selection finding 4). An inert preview claims no keys. - GameChip.svelte (new, mounted in App.svelte beside HudLayer): "Game · " + an eye that toggles the same preview store. Editor-only: hidden in Play, VR and embed mode. Top-left under the logo (tucks under a docked Connect bar via --connect-bottom), z 38 - the authoring HUD band, under every window. Test play joins it in P2. - Suites broken by the change, updated in place: game-towers ("the menu renders" moved to after the play press), game-loop (the 21-D scenario presses Start IN PLAY now - both peers play, the menu screen frees the pointer, A approves the late joiner from the editor while B keeps the round alive), game-waves/game-untangle/game-dungeon-realms (the editor-side menu check becomes "the chip stands in for it", the menu check moves into play). Suite game-editor-flow (new) 17/17 on the real Towers .tpscene + collectible zip. Counterfactual: HudLayer restored to base -> 8 red, incl. "clicking where Start used to be leaves the game on its menu (playing)" - the reported bug, reproduced. game-loop 31/31 · game-untangle 47/47 · game-dungeon-realms 65/65 · game-waves 85/91 with the SAME six ledger/run-log reds on base source (A/B'd: pre-existing, module-side: a run logged twice 1 ms apart, an overkill pulse count). Held: game-towers 20 · hud-actions 65 (base 20/65). svelte-check 336/47, no new message. Build green. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/App.svelte | 5 + src/components/hud/GameChip.svelte | 86 +++++++++++++++ src/components/hud/HudLayer.svelte | 28 ++++- src/lib/hudDocs.js | 24 ++++- tests/e2e/game-dungeon-realms.test.cjs | 3 +- tests/e2e/game-editor-flow.test.cjs | 142 +++++++++++++++++++++++++ tests/e2e/game-loop.test.cjs | 18 +++- tests/e2e/game-towers.test.cjs | 4 +- tests/e2e/game-untangle.test.cjs | 3 +- tests/e2e/game-waves.test.cjs | 3 +- 10 files changed, 305 insertions(+), 11 deletions(-) create mode 100644 src/components/hud/GameChip.svelte create mode 100644 tests/e2e/game-editor-flow.test.cjs diff --git a/src/App.svelte b/src/App.svelte index 4da8044a..315b9a83 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -106,6 +106,7 @@ import { startMusicToolbox } from './lib/musicToolbox' import { loadProjectManifest } from '$lib/projectManifest' import { startSharedLibrary } from '$lib/sharedLibrary' import { startSceneIdentity } from '$lib/sceneIdentity' + import GameChip from './components/hud/GameChip.svelte' import HudLayer from './components/hud/HudLayer.svelte' import HudEditor from './components/editors/HudEditor.svelte' import { importFile, load } from '$lib/fileHandler.svelte' @@ -572,6 +573,10 @@ import { startMusicToolbox } from './lib/musicToolbox' above (a game HUD that dies when you press play is no HUD at all). --z-hud, no new tier: it beats the camera PiP and loses to modal/toast/menu. --> + +