diff --git a/index.json b/index.json index 4b88975..d05b3bb 100644 --- a/index.json +++ b/index.json @@ -264,8 +264,8 @@ { "id": "untangle", "name": "Untangle", - "version": "2.3.1", - "description": "Untangle the graph: drag the dots until no edges cross, on a flat 2D board or around a 3D globe (great-circle arcs). 30 levels with ONE progress across both modes (switching keeps your level), best times kept on your device, reset any time. In VR: you start in front of the board, grab dots with the trigger (laser or tip), hold, turn and scale the globe, the grips move and scale the world, a level picker with Globe / 2D in the headset and a waist-height level bar, haptics. Procedural and replicated, with game sounds and quiet puzzle music - board pose, level and readouts as flow nodes.", + "version": "2.4.0", + "description": "Untangle the graph: drag the dots until no edges cross, on a flat 2D board or around a 3D globe (great-circle arcs). 30 levels with ONE progress across both modes (switching keeps your level), best times kept on your device, reset any time. In VR: you start in front of the board, grab dots with the trigger (laser or tip), hold, turn and scale the globe, the stick pushes the board or the held globe farther and pulls it closer like an object in the editor, the grips move and scale the world, a level picker with Globe / 2D in the headset and a waist-height level bar, haptics. Procedural and replicated, with game sounds and quiet puzzle music - board pose, level and readouts as flow nodes.", "author": "theprototype-app", "source": "modules/untangle", "zip": "untangle.zip", diff --git a/modules/untangle/README.md b/modules/untangle/README.md index 438c61e..82d5efc 100644 --- a/modules/untangle/README.md +++ b/modules/untangle/README.md @@ -28,9 +28,10 @@ modules/untangle/ src/vrbar.js the VR level bar (pure layout/labels/pose + one canvas plane) src/vrmenu.js the VR level picker (pure layout/states + the hole in core's VR panel) src/stance.js the PURE VR spawn in front of the board + the "is it ahead" measure + src/reel.js the PURE stick reel / scale (Edit's grab) for the held globe and the pointed board src/index.js the module: board, modes, nodes, replication src/def.js the games/untangle template def (room, HUD shell, graph) - test/ puzzle / progress / sphere / sfx / vrdrag / vrbar / vrmenu / stance / def tests (npm run test:untangle) + test/ puzzle / progress / sphere / sfx / vrdrag / vrbar / vrmenu / stance / reel / def tests (npm run test:untangle) module.js the bundled, self-contained entry (committed) ``` @@ -73,8 +74,8 @@ APP_URL=https://localhost:5216/ npm test -- untangle.test # the test-flight On a core without the 30b sound set a tiny local synth plays the four sounds instead. - **Hold the globe.** Pull the trigger on the globe itself (not on a dot, not on the bar): it rides that hand like an object in the editor's grab — move it, turn it — and that - hand's stick scales it (forward grows, back shrinks, 0.35x-4x; the dots and edges scale - with it). The OTHER hand keeps grabbing and moving dots meanwhile. The hold is yours + hand's stick scales it (0.35x-4x; the dots and edges scale with it — 2.4.0: left/right + scales, up/down pushes it away / pulls it closer, the Edit mapping). The OTHER hand keeps grabbing and moving dots meanwhile. The hold is yours alone (the dots replicate as unit vectors, the hold never), and is forgotten when you switch to the flat board. Left-stick walking pauses while you hold. - **The level bar.** A console in front of the board's lower edge at waist height, tilted @@ -88,6 +89,33 @@ APP_URL=https://localhost:5216/ npm test -- untangle.test # the test-flight (and the template's play block has it): on a core with contract K1 the GRIPS move, turn and scale the world DURING the game — the trigger owns the dots. +## In VR, the third round (2.4.0, roadmap 33) + +- **The stick pushes and pulls the board, as Edit does to a held object (G2).** Holding the + globe (trigger on it), that hand's stick now maps like an Edit grab: UP pushes the globe + away, DOWN pulls it closer, LEFT / RIGHT scales it (it was up/down = scale). Pointing the + right laser at the board or the globe, the right stick's up/down pushes it away / pulls it + closer along the laser (left/right still turns the globe). Local, like the hold; a mode + switch puts the board back where it was authored. The numbers are Edit's reel (`reel.js`, + core's grabStickAdjust: 3 % of the distance per frame at full stick). On a 1.19 core + `api.claimInput('sticks')` pauses BOTH sticks while they act on the board (no snap turn, no + walking); on 1.18 the hold claims the left stick only and the right stick's left/right stays + the player's snap turn (so only the left hand scales there). Core 1.19 also reels the WORLD: + a right grip that pans the world + the stick up/down pushes it away / pulls it closer. +- **The level picker shows in the headset (G3).** On a 1.18 core the picker was invisible over + core's VR panel when you stood with the head level: core re-stamps every registered VR + panel to its own draw order each frame, so the tie with core's panel was broken by depth, + and the panel's backdrop won (looking down past ~3 degrees it showed — why the round-31 eye + shots, aimed down at the panel, had it). three sorts by each geometry's bounding-sphere + centre, so the picker's geometry now carries a sphere centred 15 cm toward the eyes + (`vrmenu.js` SORT_LEAD): it draws last at any head pitch on any core. The Board choice is also registered `onLevels`: a 1.19 core draws it as tabs (Globe / + 2D board) above the pause menu's Levels grid, desktop and VR, and that grid shows all 30 + levels in VR (it stopped at 20). +- Flights: `untangle-vr2` L.5b/c (the picker's pixel from a LEVEL head and looking down), R + (push / pull on the board and the globe, the claim), S (left X → the pause menu → Levels → + the Board tabs → a level, through core's panel press path); `untangle-vr` G.5-G.6 (the + held globe reels on Y, scales on X). + ## In VR, the second round (2.3.0, roadmap 31) 2.3.1: the VR HUD line above the board fits its canvas (a long "Level 12 · 7 crossings" was cut diff --git a/modules/untangle/manifest.json b/modules/untangle/manifest.json index c8a58c1..39e22f2 100644 --- a/modules/untangle/manifest.json +++ b/modules/untangle/manifest.json @@ -1,9 +1,9 @@ { "id": "untangle", "name": "Untangle", - "version": "2.3.1", + "version": "2.4.0", "format": 1, - "description": "Untangle the graph: drag the dots until no edges cross, on a flat 2D board or around a 3D globe (great-circle arcs). 30 levels with ONE progress across both modes (switching keeps your level), best times kept on your device, reset any time. In VR: you start in front of the board, grab dots with the trigger (laser or tip), hold, turn and scale the globe, the grips move and scale the world, a level picker with Globe / 2D in the headset and a waist-height level bar, haptics. Procedural and replicated, with game sounds and quiet puzzle music - board pose, level and readouts as flow nodes.", + "description": "Untangle the graph: drag the dots until no edges cross, on a flat 2D board or around a 3D globe (great-circle arcs). 30 levels with ONE progress across both modes (switching keeps your level), best times kept on your device, reset any time. In VR: you start in front of the board, grab dots with the trigger (laser or tip), hold, turn and scale the globe, the stick pushes the board or the held globe farther and pulls it closer like an object in the editor, the grips move and scale the world, a level picker with Globe / 2D in the headset and a waist-height level bar, haptics. Procedural and replicated, with game sounds and quiet puzzle music - board pose, level and readouts as flow nodes.", "entry": "module.js", "files": ["module.js"] } diff --git a/modules/untangle/module.js b/modules/untangle/module.js index 162d6ea..8e2f4d2 100644 --- a/modules/untangle/module.js +++ b/modules/untangle/module.js @@ -1686,6 +1686,7 @@ function panelHole(screen, id, meshW, meshH) { const cy = r.top + r.h / 2 - crop.y; return { x: cx * k - meshW / 2, y: meshH / 2 - cy * k, w: r.w * k, h: r.h * k }; } +var SORT_LEAD = 0.15; var AMBER3 = "#fbbf24"; var GREEN3 = "#3ee08f"; function makeVRMenu(THREE) { @@ -1698,8 +1699,10 @@ function makeVRMenu(THREE) { texture = new THREE.CanvasTexture(canvas); texture.colorSpace = THREE.SRGBColorSpace ?? texture.colorSpace; } + const plane = new THREE.PlaneGeometry(1, 1); + plane.boundingSphere = new THREE.Sphere(new THREE.Vector3(0, 0, SORT_LEAD), 2); const mesh = new THREE.Mesh( - new THREE.PlaneGeometry(1, 1), + plane, new THREE.MeshBasicMaterial({ map: texture, transparent: true, depthTest: false, depthWrite: false, toneMapped: false, side: THREE.DoubleSide }) ); mesh.name = "untangle-vrmenu"; @@ -1856,6 +1859,23 @@ function untangleHud() { }; } +// modules/untangle/src/reel.js +var REEL_RATE = 0.03; +var SCALE_RATE = 0.025; +var DEAD = 0.15; +var REACH2 = [0.15, 12]; +var dead = (v) => Math.abs(v) > DEAD ? v : 0; +function heldStick({ dist, scale, x, y, range }) { + return { + dist: Math.min(Math.max(dist * (1 - dead(y) * REEL_RATE), REACH2[0]), REACH2[1]), + scale: Math.min(Math.max(scale * (1 + dead(x) * SCALE_RATE), range[0]), range[1]) + }; +} +function pointPush(dist, y) { + const next = Math.min(Math.max(dist * (1 - dead(y) * REEL_RATE), REACH2[0] * 2), REACH2[1]); + return next - dist; +} + // modules/untangle/src/index.js var GROUP = "untangle-module"; var MODES_PLAYED = ["2d", "3d"]; @@ -1864,8 +1884,8 @@ var EXPIRE_FRAMES = 40; var index_default = { id: "untangle", name: "Untangle", - version: "2.3.1", - description: "Drag the dots until no edges cross \u2014 on a flat board or around a globe, 30 levels that unlock as you solve them, one progress for both (it stays on your device). In VR: you start in front of the board, grab dots with the trigger, hold/turn/scale the globe, the grips scale the world, a level picker with Globe / 2D and a level bar. Replicated; board pose, level and readouts as flow nodes.", + version: "2.4.0", + description: "Drag the dots until no edges cross \u2014 on a flat board or around a globe, 30 levels that unlock as you solve them, one progress for both (it stays on your device). In VR: you start in front of the board, grab dots with the trigger, hold/turn/scale the globe, the stick pushes the board or the held globe farther / pulls it closer (as Edit does to a held object), the grips scale the world, a level picker with Globe / 2D and a level bar. Replicated; board pose, level and readouts as flow nodes.", /** @param {any} api */ register(api) { const THREE = api.THREE; @@ -1932,7 +1952,7 @@ var index_default = { function placeGroup() { if (!group) return; group.position.set(board.x, board.boardY, board.z); - if (mode === "3d") group.position.add(hold.offset); + group.position.add(hold.offset); group.scale.setScalar(mode === "3d" ? hold.scale : 1); group.rotation.set(0, board.yaw, 0); group.updateMatrixWorld(true); @@ -2407,6 +2427,26 @@ var index_default = { let vrMoved = false; let holdStart = null; const HOLD_SCALE = [0.35, 4]; + let sticksKnown = ( + /** @type {boolean | null} */ + null + ); + function sticksScope() { + if (sticksKnown === null) { + sticksKnown = api.claimInput?.("sticks") === true; + api.releaseInput?.("sticks"); + } + return sticksKnown; + } + function claimSticks() { + const scope = sticksScope() ? "sticks" : "locomotion"; + api.claimInput?.(scope); + return scope; + } + const reel = { holds: 0, reels: 0, pushes: 0, pointing: false, scope: ( + /** @type {string | null} */ + null + ), turns: 0 }; function globeUnder(pose) { if (mode !== "3d" || !group) return false; const s = surface(); @@ -2417,15 +2457,23 @@ var index_default = { } function startHold(pose, hand) { group.updateMatrixWorld(); + const p0 = new THREE.Vector3().fromArray(pose.position); + const c0 = group.getWorldPosition(new THREE.Vector3()); + const off0 = c0.clone().sub(p0); + if (off0.length() < 0.05) off0.set(0, 0, -0.05).applyQuaternion(new THREE.Quaternion().fromArray(pose.quaternion)); holdStart = { - p0: new THREE.Vector3().fromArray(pose.position), + p0, q0inv: new THREE.Quaternion().fromArray(pose.quaternion).invert(), - c0: group.getWorldPosition(new THREE.Vector3()), + c0, + off0, + dist: off0.length(), g0: globeQuat.clone(), // the group's world turn WITHOUT the view (the hold moves and scales, never turns, it) - w: group.getWorldQuaternion(new THREE.Quaternion()) + w: group.getWorldQuaternion(new THREE.Quaternion()), + // the holding hand's stick reels + scales: it must not walk / turn / teleport + scope: claimSticks() }; - api.claimInput?.("locomotion"); + reel.holds++; sfx.play("pop", holdStart.c0.toArray()); sfx.haptic("tap", hand); } @@ -2435,10 +2483,15 @@ var index_default = { function holdTo(pose, hand) { if (!holdStart || !group) return; const axes = api.input?.()?.axes; + const x = (hand === "left" ? axes?.lx : axes?.rx) ?? 0; const y = (hand === "left" ? axes?.ly : axes?.ry) ?? 0; - if (Math.abs(y) > 0.15) hold.scale = Math.min(HOLD_SCALE[1], Math.max(HOLD_SCALE[0], hold.scale * (1 - y * 0.025))); + const xs = holdStart.scope === "sticks" || hand === "left" ? x : 0; + const next = heldStick({ dist: holdStart.dist, scale: hold.scale, x: xs, y, range: HOLD_SCALE }); + if (next.dist !== holdStart.dist) reel.reels++; + holdStart.dist = next.dist; + hold.scale = next.scale; dq.fromArray(pose.quaternion).multiply(holdStart.q0inv); - newC.copy(holdStart.c0).sub(holdStart.p0).applyQuaternion(dq).add(handP.fromArray(pose.position)); + newC.copy(holdStart.off0).applyQuaternion(dq).setLength(holdStart.dist).add(handP.fromArray(pose.position)); globeQuat.copy(holdStart.w).invert().multiply(dq).multiply(holdStart.w).multiply(holdStart.g0).normalize(); const at = group.parent ? group.parent.worldToLocal(newC.clone()) : newC.clone(); hold.offset.set(at.x - board.x, at.y - board.boardY, at.z - board.z); @@ -2446,8 +2499,9 @@ var index_default = { redraw(lastCounts); } function endHold(hand) { + const scope = holdStart?.scope; holdStart = null; - api.releaseInput?.("locomotion"); + if (scope) api.releaseInput?.(scope); sfx.play("click", group ? group.getWorldPosition(new THREE.Vector3()).toArray() : void 0); sfx.haptic("bump", hand); } @@ -2640,6 +2694,8 @@ var index_default = { options: ["globe", "2d"], optionLabels: ["Globe", "2D board"], default: choiceOf(mode), + // 33 G3: also the tabs above the shell's Levels grid (a 1.19 core; older ones ignore it) + onLevels: true, onChange: (v) => { if (shellInit) return; shell.changes++; @@ -2656,7 +2712,9 @@ var index_default = { "Drag the dots until no line crosses another \u2014 red lines cross, green ones are free.", "Desktop: drag a dot (or click it, then click where it goes); on the globe, right-drag turns it.", "VR: point at a dot and hold the trigger (or touch it with the controller tip), release to drop it.", - "VR: hold the trigger on the globe to carry and turn it, its stick scales it; the grips move and scale the world.", + "VR: hold the trigger on the globe to carry and turn it \u2014 its stick up/down pushes it away / pulls it closer, left/right scales it.", + "VR: point at the board or the globe and push the right stick up/down to move it farther / closer (left/right turns the globe).", + "VR: the grips move and scale the world; a grip plus the stick up/down pushes the world away / pulls it closer.", "The bar in front of the board: previous / next level, Globe / 2D, restart, Levels.", "One progress: a level you reach on the globe is open on the 2D board too." ]); @@ -2683,6 +2741,57 @@ var index_default = { }, { modes: ["interact", "play"], sweep: false } ); + const pushV = new THREE.Vector3(); + const parentQ = new THREE.Quaternion(); + const boardPlane = new THREE.Plane(); + const planeHit = new THREE.Vector3(); + function boardUnder(ray) { + if (mode === "3d") return globeHit(ray, false); + const s = surface(); + boardPlane.setFromNormalAndCoplanarPoint(planeNormal.fromArray(s.normal), hitPoint.fromArray(s.point)); + if (!ray.ray.intersectPlane(boardPlane, planeHit)) return null; + const at = group.worldToLocal(planeHit.clone()); + const r = board.radius * 1.15; + return Math.abs(at.x) <= r && Math.abs(at.y) <= r ? planeHit.clone() : null; + } + function releasePoint() { + if (reel.scope) api.releaseInput?.(reel.scope); + reel.scope = null; + reel.pointing = false; + } + function pointStick() { + const pose = api.isVR?.() && carried === -1 && !vrDrag.holder() && interactive() ? handPose("right") : null; + const ray = pose && !menuUnder(pose) && !coreUiOn(pose) ? poseRay(pose) : null; + const hit = ray ? boardUnder(ray) : null; + reel.pointing = !!hit; + const axes = hit ? api.input?.()?.axes : null; + const rx = axes?.rx ?? 0; + const ry = axes?.ry ?? 0; + const turning = mode === "3d" && Math.abs(rx) > 0.2; + if (!hit || !turning && Math.abs(ry) <= 0.15) { + if (reel.scope) { + api.releaseInput?.(reel.scope); + reel.scope = null; + } + return; + } + if (!reel.scope && sticksScope()) reel.scope = claimSticks(); + if (turning) { + rotateBy(rx * 4, 0); + reel.turns++; + } + const push = pointPush(hit.distanceTo(ray.ray.origin), ry); + if (!push) return; + pushV.copy(ray.ray.direction).multiplyScalar(push); + if (group.parent) { + group.parent.getWorldQuaternion(parentQ).invert(); + pushV.applyQuaternion(parentQ).divideScalar(group.parent.getWorldScale(localHit).x || 1); + } + hold.offset.add(pushV); + reel.pushes++; + placeGroup(); + redraw(lastCounts); + } api.registerFrameTask(() => { frame++; if (!built && nodeSeen < 0 && frame > EXPIRE_FRAMES) setLevel(level); @@ -2705,12 +2814,7 @@ var index_default = { const t = performance.now() / 1e3; burst?.tick(t); const ray = aim.current(); - if (mode === "3d" && api.isVR?.() && carried === -1 && !vrDrag.holder() && globeHit(ray, false)) { - const axes = api.input?.()?.axes; - const rx = axes?.rx ?? 0; - const ry = axes?.ry ?? 0; - if (Math.abs(rx) > 0.2 || Math.abs(ry) > 0.2) rotateBy(rx * 4, ry * 4); - } + pointStick(); vrMoved = false; const vr = vrDragOn(); placePicker(vr); @@ -2897,6 +3001,7 @@ var index_default = { api.onSceneClear?.(() => { sceneClears++; resetHold(); + releasePoint(); level = 1; nodeLevel = null; remoteApplied = false; @@ -2972,9 +3077,10 @@ var index_default = { }, vr: () => ({ carrier: vrDrag.carrier(), candidate: vrDrag.candidate(), holder: vrDrag.holder(), lastHand: vrHandLast, on: vrDragOn() }), /** 30b: the LOCAL globe hold — offset (parent frame), scale, and the view quaternion */ + reel: () => ({ ...reel, sticks: sticksScope(), holdDist: holdStart ? holdStart.dist : null, offset: hold.offset.toArray(), centre: group ? group.getWorldPosition(new THREE.Vector3()).toArray() : null }), globeHold: () => ({ offset: hold.offset.toArray(), scale: hold.scale, quat: globeQuat.toArray(), centre: group ? group.getWorldPosition(new THREE.Vector3()).toArray() : null }), /** roadmap 31: the VR level picker — where it is drawn, open?, the hovered rect, the last press */ - vrMenu: () => ({ at: pickerAt, open: pickerOpen, visible: vrMenu.mesh.visible, centre: vrMenu.mesh.getWorldPosition(new THREE.Vector3()).toArray(), size: [vrMenu.mesh.scale.x, vrMenu.mesh.scale.y], hover: pickerHover, last: lastPick, cells: pickerCells({ mode, level, progress }, vrMenu.ids()), renderOrder: vrMenu.mesh.renderOrder, depthTest: vrMenu.mesh.material.depthTest }), + vrMenu: () => ({ at: pickerAt, open: pickerOpen, visible: vrMenu.mesh.visible, centre: vrMenu.mesh.getWorldPosition(new THREE.Vector3()).toArray(), sortCentre: vrMenu.mesh.localToWorld(vrMenu.mesh.geometry.boundingSphere.center.clone()).toArray(), size: [vrMenu.mesh.scale.x, vrMenu.mesh.scale.y], hover: pickerHover, last: lastPick, cells: pickerCells({ mode, level, progress }, vrMenu.ids()), renderOrder: vrMenu.mesh.renderOrder, depthTest: vrMenu.mesh.material.depthTest }), /** world centre of picker rect `id` ('mode:3d', 'level:7', 'continue', 'close') */ vrMenuCell: (id) => vrMenu.worldOf(id), /** roadmap 31 K3: what the game shell was handed (feature-detected) */ diff --git a/modules/untangle/src/def.js b/modules/untangle/src/def.js index 61c6d6e..b0919a9 100644 --- a/modules/untangle/src/def.js +++ b/modules/untangle/src/def.js @@ -207,7 +207,7 @@ export function untangleDef() { license: 'CC0-1.0', author: 'theprototype', tags: ['puzzle', 'co-op', 'procedural', '3d'], - modules: [{ id: 'untangle', version: '2.3.1' }], + modules: [{ id: 'untangle', version: '2.4.0' }], installModules: ['untangle'], env: ENV, // play.cursor 'free' (30-core-flow): no pointer lock — the real cursor drags; a core diff --git a/modules/untangle/src/index.js b/modules/untangle/src/index.js index 10f3e1d..5925cc7 100644 --- a/modules/untangle/src/index.js +++ b/modules/untangle/src/index.js @@ -30,6 +30,7 @@ import { makeVRBar, barCells, barPose, CELLS } from './vrbar.js'; import { spawnFor } from './stance.js'; import { makeVRMenu, panelHole, pickerCells, PX_W, PX_H } from './vrmenu.js'; import { untangleHud } from './def.js'; +import { heldStick, pointPush } from './reel.js'; const GROUP = 'untangle-module'; /** the modes this build plays: the flat board and (P3) the globe */ @@ -41,8 +42,8 @@ const EXPIRE_FRAMES = 40; // a node gone from the graph -> the module's own defa export default { id: 'untangle', name: 'Untangle', - version: '2.3.1', - description: 'Drag the dots until no edges cross — on a flat board or around a globe, 30 levels that unlock as you solve them, one progress for both (it stays on your device). In VR: you start in front of the board, grab dots with the trigger, hold/turn/scale the globe, the grips scale the world, a level picker with Globe / 2D and a level bar. Replicated; board pose, level and readouts as flow nodes.', + version: '2.4.0', + description: 'Drag the dots until no edges cross — on a flat board or around a globe, 30 levels that unlock as you solve them, one progress for both (it stays on your device). In VR: you start in front of the board, grab dots with the trigger, hold/turn/scale the globe, the stick pushes the board or the held globe farther / pulls it closer (as Edit does to a held object), the grips scale the world, a level picker with Globe / 2D and a level bar. Replicated; board pose, level and readouts as flow nodes.', /** @param {any} api */ register(api) { const THREE = api.THREE; @@ -123,7 +124,8 @@ export default { const fits = (p) => Array.isArray(p) && p.length === (mode === '3d' ? 3 : 2) && p.every((v) => Number.isFinite(+v)); /** 30b: the player's LOCAL hold of the globe (VR): where it was carried to (an offset in * the board's parent frame) and how big it is; its turn goes into globeQuat. Never - * replicated — like the view rotation, the dots replicate as unit vectors. */ + * replicated — like the view rotation, the dots replicate as unit vectors. 33 G2: the + * stick's push/pull moves the flat board by the same offset (its scale stays 1). */ const hold = { offset: new THREE.Vector3(), scale: 1 }; function resetHold() { hold.offset.set(0, 0, 0); @@ -132,7 +134,8 @@ export default { function placeGroup() { if (!group) return; group.position.set(board.x, board.boardY, board.z); - if (mode === '3d') group.position.add(hold.offset); + // 33 G2: the LOCAL push/pull (the stick while pointing) moves the flat board too + group.position.add(hold.offset); group.scale.setScalar(mode === '3d' ? hold.scale : 1); group.rotation.set(0, board.yaw, 0); group.updateMatrixWorld(true); @@ -700,6 +703,26 @@ export default { /** @type {any} the hold's start: the hand pose, the globe centre, the view, the frame */ let holdStart = null; const HOLD_SCALE = [0.35, 4]; + // ---------- 33 G2: the sticks (reel.js) ---------- + /** does this core pause BOTH sticks for a module (`claimInput('sticks')` answers true, 1.19)? + * Asked once: claim + release. */ + let sticksKnown = /** @type {boolean | null} */ (null); + function sticksScope() { + if (sticksKnown === null) { + sticksKnown = api.claimInput?.('sticks') === true; + api.releaseInput?.('sticks'); + } + return sticksKnown; + } + /** claim the sticks for a hold / a point: 'sticks' where the core has it, else 'locomotion' + * (the left stick only — what 2.3 claimed). Returns the scope to release. */ + function claimSticks() { + const scope = sticksScope() ? 'sticks' : 'locomotion'; + api.claimInput?.(scope); + return scope; + } + /** what the sticks did (the flights read it) */ + const reel = { holds: 0, reels: 0, pushes: 0, pointing: false, scope: /** @type {string | null} */ (null), turns: 0 }; /** does this hand's laser (or tip) touch the globe? */ function globeUnder(pose) { if (mode !== '3d' || !group) return false; @@ -711,15 +734,24 @@ export default { } function startHold(pose, hand) { group.updateMatrixWorld(); + const p0 = new THREE.Vector3().fromArray(pose.position); + const c0 = group.getWorldPosition(new THREE.Vector3()); + // 33 G2: the hand -> centre offset the stick reels (a tip inside the globe: along the aim) + const off0 = c0.clone().sub(p0); + if (off0.length() < 0.05) off0.set(0, 0, -0.05).applyQuaternion(new THREE.Quaternion().fromArray(pose.quaternion)); holdStart = { - p0: new THREE.Vector3().fromArray(pose.position), + p0, q0inv: new THREE.Quaternion().fromArray(pose.quaternion).invert(), - c0: group.getWorldPosition(new THREE.Vector3()), + c0, + off0, + dist: off0.length(), g0: globeQuat.clone(), // the group's world turn WITHOUT the view (the hold moves and scales, never turns, it) - w: group.getWorldQuaternion(new THREE.Quaternion()) + w: group.getWorldQuaternion(new THREE.Quaternion()), + // the holding hand's stick reels + scales: it must not walk / turn / teleport + scope: claimSticks() }; - api.claimInput?.('locomotion'); // the holding hand's stick scales, it must not walk + reel.holds++; sfx.play('pop', holdStart.c0.toArray()); sfx.haptic('tap', hand); } @@ -727,16 +759,23 @@ export default { const newC = new THREE.Vector3(); const handP = new THREE.Vector3(); /** the globe rides the hand rigidly (the edit-mode object grab): its centre keeps its - * offset from the controller, its turn follows the controller's; the stick scales it */ + * offset from the controller, its turn follows the controller's. 33 G2: the holding hand's + * stick does what it does to a held object in Edit — Y reels it (forward pushes it away, + * back pulls it closer), X scales it (reel.js) */ function holdTo(pose, hand) { if (!holdStart || !group) return; const axes = api.input?.()?.axes; + const x = (hand === 'left' ? axes?.lx : axes?.rx) ?? 0; const y = (hand === 'left' ? axes?.ly : axes?.ry) ?? 0; - // forward (y < 0 in xr-standard) grows, back shrinks — the Y axis, because the right - // stick's X is core's snap turn and a module cannot pause it - if (Math.abs(y) > 0.15) hold.scale = Math.min(HOLD_SCALE[1], Math.max(HOLD_SCALE[0], hold.scale * (1 - y * 0.025))); + // a core without the 'sticks' scope cannot pause the RIGHT stick's snap turn: X stays + // the player's turn there (the left stick's is free under the 'locomotion' claim) + const xs = holdStart.scope === 'sticks' || hand === 'left' ? x : 0; + const next = heldStick({ dist: holdStart.dist, scale: hold.scale, x: xs, y, range: HOLD_SCALE }); + if (next.dist !== holdStart.dist) reel.reels++; + holdStart.dist = next.dist; + hold.scale = next.scale; dq.fromArray(pose.quaternion).multiply(holdStart.q0inv); - newC.copy(holdStart.c0).sub(holdStart.p0).applyQuaternion(dq).add(handP.fromArray(pose.position)); + newC.copy(holdStart.off0).applyQuaternion(dq).setLength(holdStart.dist).add(handP.fromArray(pose.position)); // the view: W^-1 dq W G0 — the controller's world turn, expressed in the group frame globeQuat.copy(holdStart.w).invert().multiply(dq).multiply(holdStart.w).multiply(holdStart.g0).normalize(); const at = group.parent ? group.parent.worldToLocal(newC.clone()) : newC.clone(); @@ -745,8 +784,9 @@ export default { redraw(lastCounts); } function endHold(hand) { + const scope = holdStart?.scope; holdStart = null; - api.releaseInput?.('locomotion'); + if (scope) api.releaseInput?.(scope); sfx.play('click', group ? group.getWorldPosition(new THREE.Vector3()).toArray() : undefined); sfx.haptic('bump', hand); } @@ -882,6 +922,7 @@ export default { at = 'panel'; if (m.parent !== panel.parent) panel.parent.add(m); // a sibling of the panel: its pose is the panel's, offset onto the hole, 4 mm proud + // (its draw order: vrmenu SORT_LEAD) m.position.set(hole.x, hole.y, 0.004).applyQuaternion(panel.quaternion).add(panel.position); m.quaternion.copy(panel.quaternion); m.scale.set(hole.w, hole.h, 1); @@ -969,6 +1010,8 @@ export default { options: ['globe', '2d'], optionLabels: ['Globe', '2D board'], default: choiceOf(mode), + // 33 G3: also the tabs above the shell's Levels grid (a 1.19 core; older ones ignore it) + onLevels: true, onChange: (v) => { // a stored choice replayed while registering must not switch the room's board if (shellInit) return; @@ -987,7 +1030,9 @@ export default { 'Drag the dots until no line crosses another — red lines cross, green ones are free.', 'Desktop: drag a dot (or click it, then click where it goes); on the globe, right-drag turns it.', 'VR: point at a dot and hold the trigger (or touch it with the controller tip), release to drop it.', - 'VR: hold the trigger on the globe to carry and turn it, its stick scales it; the grips move and scale the world.', + 'VR: hold the trigger on the globe to carry and turn it — its stick up/down pushes it away / pulls it closer, left/right scales it.', + 'VR: point at the board or the globe and push the right stick up/down to move it farther / closer (left/right turns the globe).', + 'VR: the grips move and scale the world; a grip plus the stick up/down pushes the world away / pulls it closer.', 'The bar in front of the board: previous / next level, Globe / 2D, restart, Levels.', 'One progress: a level you reach on the globe is open on the 2D board too.' ]); @@ -1026,6 +1071,64 @@ export default { }, { modes: ['interact', 'play'], sweep: false } ); + // ---------- 33 G2: pointing + the right stick ---------- + const pushV = new THREE.Vector3(); + const parentQ = new THREE.Quaternion(); + const boardPlane = new THREE.Plane(); + const planeHit = new THREE.Vector3(); + /** where the right laser meets the board (the flat plate, or the globe), world, or null */ + function boardUnder(ray) { + if (mode === '3d') return globeHit(ray, false); + const s = surface(); + boardPlane.setFromNormalAndCoplanarPoint(planeNormal.fromArray(s.normal), hitPoint.fromArray(s.point)); + if (!ray.ray.intersectPlane(boardPlane, planeHit)) return null; + const at = group.worldToLocal(planeHit.clone()); + const r = board.radius * 1.15; + return Math.abs(at.x) <= r && Math.abs(at.y) <= r ? planeHit.clone() : null; + } + /** let go of the point's claim */ + function releasePoint() { + if (reel.scope) api.releaseInput?.(reel.scope); + reel.scope = null; + reel.pointing = false; + } + function pointStick() { + const pose = api.isVR?.() && carried === -1 && !vrDrag.holder() && interactive() ? handPose('right') : null; + const ray = pose && !menuUnder(pose) && !coreUiOn(pose) ? poseRay(pose) : null; + const hit = ray ? boardUnder(ray) : null; + reel.pointing = !!hit; + const axes = hit ? api.input?.()?.axes : null; + const rx = axes?.rx ?? 0; + const ry = axes?.ry ?? 0; + const turning = mode === '3d' && Math.abs(rx) > 0.2; + if (!hit || (!turning && Math.abs(ry) <= 0.15)) { + // the stick back at rest (or the laser off the board): navigation is the player's again + if (reel.scope) { + api.releaseInput?.(reel.scope); + reel.scope = null; + } + return; + } + // the stick is the board's while it is deflected at it ('sticks': no turn, no teleport; + // a core without it: the right stick's Y is free anyway, X still turns the player) + if (!reel.scope && sticksScope()) reel.scope = claimSticks(); + if (turning) { + rotateBy(rx * 4, 0); + reel.turns++; + } + const push = pointPush(hit.distanceTo(ray.ray.origin), ry); + if (!push) return; + // along the laser, in the board's parent frame (the world rig may turn / scale it) + pushV.copy(ray.ray.direction).multiplyScalar(push); + if (group.parent) { + group.parent.getWorldQuaternion(parentQ).invert(); + pushV.applyQuaternion(parentQ).divideScalar(group.parent.getWorldScale(localHit).x || 1); + } + hold.offset.add(pushV); + reel.pushes++; + placeGroup(); + redraw(lastCounts); + } api.registerFrameTask(() => { frame++; // the node-or-fallback gate: no utboard node within EXPIRE_FRAMES of load (or of a @@ -1056,13 +1159,9 @@ export default { const t = performance.now() / 1000; burst?.tick(t); const ray = aim.current(); - // P3: in VR the thumbstick turns the globe while the hand points at it - if (mode === '3d' && api.isVR?.() && carried === -1 && !vrDrag.holder() && globeHit(ray, false)) { - const axes = api.input?.()?.axes; - const rx = axes?.rx ?? 0; - const ry = axes?.ry ?? 0; - if (Math.abs(rx) > 0.2 || Math.abs(ry) > 0.2) rotateBy(rx * 4, ry * 4); - } + // P3 + 33 G2: the RIGHT hand pointing at the board / the globe — its stick's Y pushes it + // away / pulls it closer along the laser (Edit's reel), X turns the globe + pointStick(); // VR: the controllers drive the drag (a carry left over from VR drops on leaving it) vrMoved = false; const vr = vrDragOn(); @@ -1260,6 +1359,7 @@ export default { api.onSceneClear?.(() => { sceneClears++; resetHold(); + releasePoint(); level = 1; nodeLevel = null; remoteApplied = false; @@ -1325,9 +1425,10 @@ export default { }, vr: () => ({ carrier: vrDrag.carrier(), candidate: vrDrag.candidate(), holder: vrDrag.holder(), lastHand: vrHandLast, on: vrDragOn() }), /** 30b: the LOCAL globe hold — offset (parent frame), scale, and the view quaternion */ + reel: () => ({ ...reel, sticks: sticksScope(), holdDist: holdStart ? holdStart.dist : null, offset: hold.offset.toArray(), centre: group ? group.getWorldPosition(new THREE.Vector3()).toArray() : null }), globeHold: () => ({ offset: hold.offset.toArray(), scale: hold.scale, quat: globeQuat.toArray(), centre: group ? group.getWorldPosition(new THREE.Vector3()).toArray() : null }), /** roadmap 31: the VR level picker — where it is drawn, open?, the hovered rect, the last press */ - vrMenu: () => ({ at: pickerAt, open: pickerOpen, visible: vrMenu.mesh.visible, centre: vrMenu.mesh.getWorldPosition(new THREE.Vector3()).toArray(), size: [vrMenu.mesh.scale.x, vrMenu.mesh.scale.y], hover: pickerHover, last: lastPick, cells: pickerCells({ mode, level, progress }, vrMenu.ids()), renderOrder: vrMenu.mesh.renderOrder, depthTest: vrMenu.mesh.material.depthTest }), + vrMenu: () => ({ at: pickerAt, open: pickerOpen, visible: vrMenu.mesh.visible, centre: vrMenu.mesh.getWorldPosition(new THREE.Vector3()).toArray(), sortCentre: vrMenu.mesh.localToWorld(vrMenu.mesh.geometry.boundingSphere.center.clone()).toArray(), size: [vrMenu.mesh.scale.x, vrMenu.mesh.scale.y], hover: pickerHover, last: lastPick, cells: pickerCells({ mode, level, progress }, vrMenu.ids()), renderOrder: vrMenu.mesh.renderOrder, depthTest: vrMenu.mesh.material.depthTest }), /** world centre of picker rect `id` ('mode:3d', 'level:7', 'continue', 'close') */ vrMenuCell: (id) => vrMenu.worldOf(id), /** roadmap 31 K3: what the game shell was handed (feature-detected) */ diff --git a/modules/untangle/src/reel.js b/modules/untangle/src/reel.js new file mode 100644 index 0000000..2a63d35 --- /dev/null +++ b/modules/untangle/src/reel.js @@ -0,0 +1,46 @@ +// THE STICK REELS THE BOARD — roadmap 33 G2. The user on a Quest: "I can make bigger/smaller +// the globe but cannot use up/down on stick to move it further/closer and I want to be able to +// do this, just as in edit mode for objects." +// +// Edit's object grab (core vrControls grabStickAdjust): the holding hand's stick Y REELS the +// object along the hand (forward pushes it away, back pulls it in, a share of the distance per +// frame) and X SCALES it. Untangle's globe hold had Y on scale, because a module could not pause +// the right stick's snap turn — a core with the 'sticks' input scope (1.19) can, so the hold now +// maps exactly like Edit. And pointing the right laser at the board (or the globe) with the stick +// up/down pushes it away / pulls it closer along the ray, the same reel without holding. +// +// Pure: the numbers only (test/reel.test.mjs). index.js applies them to the LOCAL hold. + +/** a share of the distance per frame at full deflection — Edit's reel (grabStickAdjust) */ +export const REEL_RATE = 0.03; +/** a share of the size per frame at full deflection — Edit's stick scale */ +export const SCALE_RATE = 0.025; +/** the stick's dead zone (Edit's) */ +export const DEAD = 0.15; +/** how near / far a reel may take the board from the hand, metres */ +export const REACH = [0.15, 12]; + +const dead = (/** @type {number} */ v) => (Math.abs(v) > DEAD ? v : 0); + +/** + * One frame of the HOLDING hand's stick: y reels `dist` (xr-standard: forward is y < 0 and + * pushes away), x scales within `range`. Pure. + * @param {{dist: number, scale: number, x: number, y: number, range: number[]}} input + * @returns {{dist: number, scale: number}} + */ +export function heldStick({ dist, scale, x, y, range }) { + return { + dist: Math.min(Math.max(dist * (1 - dead(y) * REEL_RATE), REACH[0]), REACH[1]), + scale: Math.min(Math.max(scale * (1 + dead(x) * SCALE_RATE), range[0]), range[1]) + }; +} + +/** + * One frame of the stick while POINTING at the board `dist` metres along the ray: the metres it + * moves AWAY along the ray (negative = toward you). Pure. + * @param {number} dist @param {number} y + */ +export function pointPush(dist, y) { + const next = Math.min(Math.max(dist * (1 - dead(y) * REEL_RATE), REACH[0] * 2), REACH[1]); + return next - dist; +} diff --git a/modules/untangle/src/vrmenu.js b/modules/untangle/src/vrmenu.js index 4303e02..b5b846a 100644 --- a/modules/untangle/src/vrmenu.js +++ b/modules/untangle/src/vrmenu.js @@ -172,6 +172,23 @@ export function panelHole(screen, id, meshW, meshH) { // ---- the drawing ------------------------------------------------------------------------------ +/** + * Roadmap 33 G3 — why the picker was INVISIBLE in the headset over core's panel. A core with + * K2 (1.18) re-stamps every api.vrPanel object to its PANEL_ORDER each XR frame — the order its + * own 'vr-game-panel' has — so the picker's own renderOrder (1001, "over core's 1000") is gone + * and three breaks the tie by DEPTH — of each mesh's GEOMETRY BOUNDING-SPHERE CENTRE (three r185; + * not the object origin: an origin lead was tried and measured to do nothing). The hole sits ~6 cm + * above the panel's centre on a panel pitched back, so with the head LEVEL the picker's centre is a + * few mm FARTHER than the panel's: the panel drew last and its menu backdrop covered the picker + * (looking down past ~3 degrees it showed — the 31 eye shots looked down at the panel; raycasts + * ignore draw order, so every hit test passed). The fix needs no core: the picker's geometry + * carries a bounding sphere whose centre leads the plane by SORT_LEAD toward the eyes (radius 2, + * so it still encloses the plane for culling and the raycast early-out), so it always sorts + * nearer than the panel and draws after it, at any head pitch, on any core. Metres (the picker's + * z scale is 1). + */ +export const SORT_LEAD = 0.15; + const AMBER = '#fbbf24'; const GREEN = '#3ee08f'; @@ -186,9 +203,12 @@ export function makeVRMenu(THREE) { texture = new THREE.CanvasTexture(canvas); texture.colorSpace = THREE.SRGBColorSpace ?? texture.colorSpace; } - // unit plane, scaled to its placement; drawn over the scene AND over core's panel (1000) + // unit plane, scaled to its placement; drawn over the scene AND over core's panel: its SORT + // key (the bounding sphere's centre) leads it SORT_LEAD toward the eyes, see SORT_LEAD + const plane = new THREE.PlaneGeometry(1, 1); + plane.boundingSphere = new THREE.Sphere(new THREE.Vector3(0, 0, SORT_LEAD), 2); const mesh = new THREE.Mesh( - new THREE.PlaneGeometry(1, 1), + plane, new THREE.MeshBasicMaterial({ map: texture, transparent: true, depthTest: false, depthWrite: false, toneMapped: false, side: THREE.DoubleSide }) ); mesh.name = 'untangle-vrmenu'; diff --git a/modules/untangle/test/reel.test.mjs b/modules/untangle/test/reel.test.mjs new file mode 100644 index 0000000..4def37e --- /dev/null +++ b/modules/untangle/test/reel.test.mjs @@ -0,0 +1,24 @@ +// reel.js — 33 G2: the held globe reels on Y and scales on X (Edit's object grab); pointing reels. +import { heldStick, pointPush, REEL_RATE, SCALE_RATE, REACH } from '../src/reel.js'; + +export function run(check) { + const range = [0.35, 4]; + // the holding hand's stick + let r = heldStick({ dist: 1, scale: 1, x: 0, y: -1, range }); + check(Math.abs(r.dist - (1 + REEL_RATE)) < 1e-12 && r.scale === 1, 'held: stick FORWARD pushes the globe away (1 m -> ' + r.dist.toFixed(3) + ' m), the size stays'); + r = heldStick({ dist: 1, scale: 1, x: 0, y: 1, range }); + check(Math.abs(r.dist - (1 - REEL_RATE)) < 1e-12, 'held: stick BACK pulls it closer (' + r.dist.toFixed(3) + ' m)'); + r = heldStick({ dist: 1, scale: 1, x: 1, y: 0, range }); + check(r.dist === 1 && Math.abs(r.scale - (1 + SCALE_RATE)) < 1e-12, 'held: stick RIGHT grows it (x' + r.scale.toFixed(3) + '), LEFT shrinks — Edit\'s mapping (Y reel, X scale)'); + r = heldStick({ dist: 1, scale: 1, x: 0.1, y: -0.1, range }); + check(r.dist === 1 && r.scale === 1, 'inside the dead zone nothing changes'); + r = heldStick({ dist: REACH[1], scale: range[1], x: 1, y: -1, range }); + check(r.dist === REACH[1] && r.scale === range[1], 'clamped: no farther than ' + REACH[1] + ' m, no bigger than x' + range[1]); + let d = 1; + for (let i = 0; i < 72; i++) d = heldStick({ dist: d, scale: 1, x: 0, y: -1, range }).dist; + check(d > 7 && d < 9, 'a second of full stick at 72 Hz is Edit\'s reel: about 8x the distance (' + d.toFixed(2) + ' m)'); + // pointing + check(pointPush(2, -1) > 0.05 && pointPush(2, 1) < -0.05, 'pointing: forward pushes the board away, back pulls it closer (' + pointPush(2, -1).toFixed(3) + ' / ' + pointPush(2, 1).toFixed(3) + ' m)'); + check(pointPush(2, 0.1) === 0, 'pointing: the dead zone moves nothing'); + check(pointPush(REACH[0] * 2, 1) === 0 && pointPush(REACH[1], -1) === 0, 'pointing: never nearer than ' + REACH[0] * 2 + ' m, never farther than ' + REACH[1] + ' m'); +} diff --git a/modules/untangle/test/run.mjs b/modules/untangle/test/run.mjs index a0974bf..4684233 100644 --- a/modules/untangle/test/run.mjs +++ b/modules/untangle/test/run.mjs @@ -9,6 +9,7 @@ import { run as vrdragTests } from './vrdrag.test.mjs'; import { run as vrbarTests } from './vrbar.test.mjs'; import { run as stanceTests } from './stance.test.mjs'; import { run as vrmenuTests } from './vrmenu.test.mjs'; +import { run as reelTests } from './reel.test.mjs'; let failures = 0; function check(ok, label) { @@ -27,6 +28,8 @@ console.log('\n=== vrdrag.test.mjs ==='); vrdragTests(check); console.log('\n=== vrbar.test.mjs ==='); vrbarTests(check); +console.log('\n=== reel.test.mjs ==='); +reelTests(check); console.log('\n=== vrmenu.test.mjs ==='); vrmenuTests(check); console.log('\n=== stance.test.mjs ==='); diff --git a/modules/untangle/test/vrmenu.test.mjs b/modules/untangle/test/vrmenu.test.mjs index ed9978f..31bae83 100644 --- a/modules/untangle/test/vrmenu.test.mjs +++ b/modules/untangle/test/vrmenu.test.mjs @@ -1,5 +1,5 @@ // vrmenu.js — the VR level picker's layout, cell states and the hole in core's panel (pure). -import { pickerLayout, pickerHit, pickerCells, panelHole, menuCrop, stageRect, PX_W, PX_H, COLS } from '../src/vrmenu.js'; +import { pickerLayout, pickerHit, pickerCells, panelHole, menuCrop, stageRect, PX_W, PX_H, COLS, SORT_LEAD } from '../src/vrmenu.js'; import { defaultProgress, recordSolve, MAX_LEVEL } from '../src/progress.js'; import { untangleHud } from '../src/def.js'; @@ -51,4 +51,25 @@ export function run(check) { const solved = untangleHud().scene.screens.find((s) => s.id === 'solved'); const sc = menuCrop(solved); check(panelHole(menu, 'levels', sc.w * 0.0016, (sc.h + 96) * 0.0016) === null, 'the solved card\'s panel is refused'); + + // ---- 33 G3: the picker DRAWS after core's panel at every head pitch ---- + // Both are at core's PANEL_ORDER on a K2 core, so three draws the one whose sort key (the + // geometry's bounding-sphere centre) is nearer along the view LAST. Core's panel: 1.2 m ahead, + // 0.22 m below the eyes, pitched -0.12 rad (vrGamePanel), its sphere at its centre. The picker's + // in the panel frame: (hole.x, hole.y, 4 mm + lead). It draws after the panel iff that offset, + // turned into the world, points TOWARD the eye (dot < 0). + const PITCH = -0.12; + const lead = (L0, headPitchDeg) => { + const y = hole.y; + const z = 0.004 + L0; + const wy = y * Math.cos(PITCH) - z * Math.sin(PITCH); + const wz = y * Math.sin(PITCH) + z * Math.cos(PITCH); + const p = (headPitchDeg * Math.PI) / 180; + return wy * Math.sin(p) + wz * -Math.cos(p); // the offset along the view direction + }; + check(lead(0, 0) > 0, 'CF: without the lead a LEVEL head sees the picker\'s origin behind the panel\'s (+' + (lead(0, 0) * 1000).toFixed(1) + ' mm): the panel drew over it (the Quest report)'); + check(lead(0, -20) < 0, '...looking down 20 deg it drew (why the 31 eye shots, aimed down at the panel, showed it)'); + const pitches = []; + for (let d = -40; d <= 15; d += 5) pitches.push(d); + check(SORT_LEAD >= 0.1 && pitches.every((d) => lead(SORT_LEAD, d) < -0.05), 'with SORT_LEAD ' + SORT_LEAD + ' m the picker sorts nearer at every head pitch -40..+15 deg (worst ' + (Math.max(...pitches.map((d) => lead(SORT_LEAD, d))) * 1000).toFixed(0) + ' mm)'); } diff --git a/modules/untangle/untangle.def.json b/modules/untangle/untangle.def.json index 79bd363..b141a4b 100644 --- a/modules/untangle/untangle.def.json +++ b/modules/untangle/untangle.def.json @@ -14,7 +14,7 @@ "modules": [ { "id": "untangle", - "version": "2.3.1" + "version": "2.4.0" } ], "installModules": [ diff --git a/tests/untangle-vr.test.cjs b/tests/untangle-vr.test.cjs index 7b1d21f..2e98da3 100644 --- a/tests/untangle-vr.test.cjs +++ b/tests/untangle-vr.test.cjs @@ -554,7 +554,9 @@ run(async () => { await A.page.waitForTimeout(120); const g1v = await A.page.evaluate(() => ({ vr: window.__untangle.vr(), s: window.__untangle.state() })); check(g1v.vr.holder?.hand === 'right' && g1v.s.carried === -1, 'G.1 the right trigger on the globe (not a dot) HOLDS it (holder ' + JSON.stringify(g1v.vr.holder) + ', carried ' + g1v.s.carried + ')'); - check(await A.page.evaluate(() => { let v; window.__stores.inputRuntime.inputClaims.subscribe((x) => (v = x))(); return v.includes('locomotion'); }), 'G.2 left-stick walking is paused while the globe is held'); + // 33 G2: a 1.19 core pauses BOTH sticks for the hold ('sticks'); an older one the left stick + const sticks = await A.page.evaluate(() => window.__untangle.reel().sticks); + check(await A.page.evaluate((sticks) => { let v; window.__stores.inputRuntime.inputClaims.subscribe((x) => (v = x))(); return v.includes(sticks ? 'sticks' : 'locomotion'); }, sticks), 'G.2 stick navigation is paused while the globe is held (' + (sticks ? 'both sticks: no walk, turn or teleport' : 'the left stick: this core has no "sticks" scope') + ')'); // move the hand 30 cm right and 10 up, and turn it 35 degrees about +Y const H1 = [H0[0] + 0.3, H0[1] + 0.1, H0[2]]; const q1 = await A.page.evaluate((q0) => { @@ -582,16 +584,44 @@ run(async () => { }, { H0, H1, q0, q1, before, c0: spot.c }); check(rigid.moved > 0.25 && rigid.centreErr < 2e-3, 'G.3 the globe rides the hand: its centre moved ' + rigid.moved.toFixed(2) + ' m, exactly where the controller carried it (error ' + rigid.centreErr.toExponential(1) + ')'); check(rigid.dotErr < 2e-3, 'G.4 and it TURNED with the hand: every dot followed the controller\'s move + 35 deg turn (max error ' + rigid.dotErr.toExponential(1) + ' m)'); - // the holding hand's stick scales it — the dots with it + // 33 G2: the holding hand's stick does what it does to a held object in Edit — Y REELS the globe + // (forward pushes it away, back pulls it closer), X SCALES it, the dots with it + const reach = () => A.page.evaluate((h) => { const c = window.__untangle.globeHold().centre; return Math.hypot(c[0] - h[0], c[1] - h[1], c[2] - h[2]); }, H1); const look0 = await A.page.evaluate(() => ({ s: window.__untangle.globeHold().scale, r: window.__untangle.look().dotRadius })); + let heldScale = look0.s; // what G.12 expects the release to keep + const gd0 = await reach(); await A.page.evaluate(() => window.__stores.inputRuntime.setVRAxes('right', 0, -1)); - await A.page.waitForTimeout(500); + await A.page.waitForTimeout(450); + await A.page.evaluate(() => window.__stores.inputRuntime.setVRAxes('right', 0, 0)); + await A.page.waitForTimeout(80); + const gd1 = await reach(); + const lookY = await A.page.evaluate(() => ({ s: window.__untangle.globeHold().scale })); + check(gd1 > gd0 * 1.3 && Math.abs(lookY.s - look0.s) < 1e-9, 'G.5 stick FORWARD on the holding hand pushes the globe away (' + gd0.toFixed(2) + ' -> ' + gd1.toFixed(2) + ' m from the hand), its size unchanged — Edit\'s reel'); + await A.page.evaluate(() => window.__stores.inputRuntime.setVRAxes('right', 0, 1)); + await A.page.waitForTimeout(300); await A.page.evaluate(() => window.__stores.inputRuntime.setVRAxes('right', 0, 0)); await A.page.waitForTimeout(80); - const look1 = await A.page.evaluate(() => ({ s: window.__untangle.globeHold().scale, r: window.__untangle.look().dotRadius })); - check(look1.s > look0.s * 1.3, 'G.5 stick forward on the holding hand grows the globe (scale ' + look0.s.toFixed(2) + ' -> ' + look1.s.toFixed(2) + ')'); - await shot(A.page, 'g-globe-held-scaled'); - check(Math.abs(look1.r / look0.r - look1.s / look0.s) < 1e-3, 'G.6 the dots grew in proportion (dot radius x' + (look1.r / look0.r).toFixed(3) + ')'); + const gd2 = await reach(); + check(gd2 < gd1 * 0.85, 'G.5b stick BACK pulls it closer (' + gd1.toFixed(2) + ' -> ' + gd2.toFixed(2) + ' m)'); + // reel it back to where the hold began (the two-hand checks below expect the globe there) + for (let k = 0; k < 40 && (await reach()) > gd0 * 1.02; k++) { + await A.page.evaluate(() => window.__stores.inputRuntime.setVRAxes('right', 0, 0.5)); + await A.page.waitForTimeout(30); + await A.page.evaluate(() => window.__stores.inputRuntime.setVRAxes('right', 0, 0)); + await A.page.waitForTimeout(20); + } + check(Math.abs((await reach()) / gd0 - 1) < 0.08, 'G.5d ...and reels back to where the hold began (' + (await reach()).toFixed(2) + ' vs ' + gd0.toFixed(2) + ' m)'); + if (sticks) { + await A.page.evaluate(() => window.__stores.inputRuntime.setVRAxes('right', 1, 0)); + await A.page.waitForTimeout(500); + await A.page.evaluate(() => window.__stores.inputRuntime.setVRAxes('right', 0, 0)); + await A.page.waitForTimeout(80); + const look1 = await A.page.evaluate(() => ({ s: window.__untangle.globeHold().scale, r: window.__untangle.look().dotRadius })); + heldScale = look1.s; + check(look1.s > look0.s * 1.3, 'G.5c stick RIGHT on the holding hand grows the globe (scale ' + look0.s.toFixed(2) + ' -> ' + look1.s.toFixed(2) + ')'); + await shot(A.page, 'g-globe-held-scaled'); + check(Math.abs(look1.r / look0.r - look1.s / look0.s) < 1e-3, 'G.6 the dots grew in proportion (dot radius x' + (look1.r / look0.r).toFixed(3) + ')'); + } else console.log('SKIP G.5c/G.6: without the "sticks" scope the right stick\'s X stays the player\'s snap turn'); // the OTHER hand grabs a dot while the globe is held, and carries it across the moving globe const front = await A.page.evaluate((lh) => { const THREE = window.__stores.THREE; @@ -607,13 +637,17 @@ run(async () => { best = i; } }); - return { i: best, at: all[best].toArray() }; + // the left hand stands 0.9 m out along THAT dot's normal, so the dot is in its view whatever + // the globe's size (unscaled, the cap facing the old fixed spot can hold no dot at all) + const n = all[best].clone().sub(c).normalize(); + return { i: best, at: all[best].toArray(), lh: all[best].clone().addScaledVector(n, 0.9).add(new THREE.Vector3(-0.05, -0.05, 0)).toArray() }; }, LH); - await setHands(A.page, { left: await aimPose(A.page, LH, front.at, false) }); + const LH2 = front.lh; + await setHands(A.page, { left: await aimPose(A.page, LH2, front.at, false) }); await A.page.waitForTimeout(80); - await setHands(A.page, { left: await aimPose(A.page, LH, front.at, true) }); + await setHands(A.page, { left: await aimPose(A.page, LH2, front.at, true) }); await A.page.waitForTimeout(120); - const g7 = await A.page.evaluate(() => ({ vr: window.__untangle.vr(), s: window.__untangle.state() })); + const g7 = await A.page.evaluate(() => ({ vr: window.__untangle.vr(), s: window.__untangle.state(), hold: window.__untangle.globeHold() })); check(g7.s.carried === front.i && g7.vr.carrier?.hand === 'left' && g7.vr.holder?.hand === 'right', 'G.7 with the right hand holding the globe, the LEFT hand grabs dot ' + front.i + ' (carried ' + g7.s.carried + ')'); const gp0 = g7.s.positions[front.i]; // the right hand moves the globe while the left laser holds still: the dot stays on the left ray @@ -623,16 +657,16 @@ run(async () => { await A.page.waitForTimeout(40); } await A.page.waitForTimeout(100); - const onRay = await A.page.evaluate(({ LH, leftAim, i }) => { + const onRay = await A.page.evaluate(({ LH2, leftAim, i }) => { const THREE = window.__stores.THREE; - const o = new THREE.Vector3(...LH); + const o = new THREE.Vector3(...LH2); const d = new THREE.Vector3(...leftAim).sub(o).normalize(); // the dot's point ON the globe (a carried dot is DRAWN lifted off it, toward the player) const p = new THREE.Vector3(...window.__untangle.boardWorld(window.__untangle.state().positions[i])); return new THREE.Ray(o, d).distanceSqToPoint(p) ** 0.5; - }, { LH, leftAim, i: front.i }); + }, { LH2, leftAim, i: front.i }); check(onRay < 0.03, 'G.8 as the right hand carries the globe away, the carried dot stays under the LEFT laser (its globe point ' + (onRay * 100).toFixed(1) + ' cm off the ray)'); - await setHands(A.page, { left: await aimPose(A.page, LH, leftAim, false) }); + await setHands(A.page, { left: await aimPose(A.page, LH2, leftAim, false) }); await A.page.waitForTimeout(150); const g9 = await state(A.page); const gq = g9.positions[front.i]; @@ -644,9 +678,9 @@ run(async () => { const g12 = await A.page.evaluate(() => { let claims; window.__stores.inputRuntime.inputClaims.subscribe((x) => (claims = x))(); - return { holder: window.__untangle.vr().holder, hold: window.__untangle.globeHold(), walk: !claims.includes('locomotion') }; + return { holder: window.__untangle.vr().holder, hold: window.__untangle.globeHold(), walk: !claims.includes('locomotion'), sticks: claims.includes('sticks') }; }); - check(g12.holder === null && g12.hold.scale > 1.3 && g12.walk, 'G.12 the release lets it go where it is (scale ' + g12.hold.scale.toFixed(2) + ' kept) and walking is back'); + check(g12.holder === null && Math.abs(g12.hold.scale - heldScale) < 1e-9 && g12.walk && !g12.sticks, 'G.12 the release lets it go where it is (scale ' + g12.hold.scale.toFixed(2) + ' kept) and walking is back'); const bHold = await B.page.evaluate(() => window.__untangle.globeHold()); check(bHold.scale === 1 && bHold.offset.every((v) => v === 0), 'G.13 the hold is LOCAL: B\'s globe did not move or grow'); // the flat board has no hold diff --git a/tests/untangle-vr2.test.cjs b/tests/untangle-vr2.test.cjs index 3683965..73e3b37 100644 --- a/tests/untangle-vr2.test.cjs +++ b/tests/untangle-vr2.test.cjs @@ -229,7 +229,49 @@ run(async () => { check(!!fit && Math.abs(fit.size[0] - fit.wantW) < 0.005, 'L.3 ...and is the level grid\'s size (' + (fit ? fit.size[0].toFixed(3) + ' vs ' + fit.wantW.toFixed(3) : '?') + ' m wide)'); check(!!fit && fit.proud > 0 && fit.proud < 0.02, 'L.4 ...a few mm in front of the panel, toward the eyes (' + (fit ? (fit.proud * 1000).toFixed(1) : '?') + ' mm)'); check(m1.renderOrder > 1000 && m1.depthTest === false, 'L.5 drawn over the scene AND over core\'s panel (renderOrder ' + m1.renderOrder + ', depthTest off)'); + // 33 G3 — the RENDER, not the hit test: from the spawn's eyes with the head LEVEL (how a player + // stands; the 31 shots looked DOWN at the panel), the lit "2D board" cell's pixel is the + // picker's amber, not core's panel backdrop. Both sit at core's overlay order on a K2 core, so + // three draws the nearer ORIGIN last — the picker lost that tie on a Quest (vrmenu SORT_LEAD). + const looks = await A.page.evaluate(({ eye }) => { + const s = window.__stores; + const THREE = s.THREE; + let renderer, scene; + s.globalRenderer.subscribe((v) => (renderer = v))(); + s.globalScene.subscribe((v) => (scene = v))(); + const W = 640; + const H = 360; + const out = {}; + for (const [name, pitch] of [['level', 0], ['down10', -10], ['down25', -25]]) { + const cam = new THREE.PerspectiveCamera(70, W / H, 0.05, 200); + cam.position.set(...eye); + cam.rotation.set((pitch * Math.PI) / 180, 0, 0, 'YXZ'); + cam.updateMatrixWorld(true); + const at = new THREE.Vector3(...window.__untangle.vrMenuCell('mode:2d')).project(cam); + const x = Math.round(((at.x + 1) / 2) * W); + const y = Math.round(((at.y + 1) / 2) * H); // GL rows run bottom-up + const rt = new THREE.WebGLRenderTarget(W, H); + const xr = renderer.xr.enabled; + renderer.xr.enabled = false; + renderer.setRenderTarget(rt); + renderer.render(scene, cam); + const px = new Uint8Array(4); + renderer.readRenderTargetPixels(rt, x, y, 1, 1, px); + renderer.setRenderTarget(null); + renderer.xr.enabled = xr; + rt.dispose(); + out[name] = { px: Array.from(px), x, y }; + } + return out; + }, { eye: EYE }); + // the render target is LINEAR: the picker's amber (#fbbf24) reads ~(245, 133, 4); core's backdrop ~(3, 2, 3) + const amber = (p) => p[0] > 120 && p[1] > 55 && p[2] < 60 && p[0] > p[1] * 1.4; + console.log(' picker pixels: ' + JSON.stringify(looks)); + check(m1.cells['mode:2d'].state === 'on', 'L.5a (premise) "2D board" is the lit (amber) cell'); + check(amber(looks.level.px), 'L.5b the head LEVEL (as a player stands): the picker is SEEN over core\'s panel — its amber cell, not the panel backdrop (' + looks.level.px + ')'); + check(amber(looks.down10.px) && amber(looks.down25.px), 'L.5c ...and looking down 10 / 25 degrees (' + looks.down10.px + ' / ' + looks.down25.px + ')'); await eyeShot(A.page, 'vr-menu-panel', EYE, [0, EYE[1] - 0.25, 0]); + await eyeShot(A.page, 'vr-menu-panel-level', EYE, [0, EYE[1], EYE[2] - 2]); const at3d = await A.page.evaluate(() => window.__untangle.vrMenuCell('mode:3d')); await setHands(A.page, { right: await aimPose(A.page, HAND, at3d, false) }); await eventually(() => menuOf(A.page), (m) => m.hover === 'mode:3d', 'L.6 the laser on "3D globe" lights it (hover)', 3000); @@ -458,6 +500,76 @@ run(async () => { if (sh.hasSetting) check(sh.setting && !sh.error, 'K.2 the module added the "Board: Globe / 2D" setting'); else console.log('SKIP K.2: this core has no api.game.addSetting'); + // ---- R. 33 G2: the right stick pushes / pulls the board while the laser is on it ------------- + // "I can make bigger/smaller the globe but cannot use up/down on stick to move it further/closer + // ... just as in edit mode for objects" — Edit's reel (grabStickAdjust) on the board, LOCAL + { + const reelOf = () => A.page.evaluate(() => window.__untangle.reel()); + const stickR = (x, y) => A.page.evaluate(({ x, y }) => window.__stores.inputRuntime.setVRAxes('right', x, y), { x, y }); + const claims = () => A.page.evaluate(() => { let v; window.__stores.inputRuntime.inputClaims.subscribe((x) => (v = x))(); return [...v]; }); + const dist = (a, b) => Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]); + const along = (a, b, from, to) => { + const d = b.map((v, i) => v - a[i]); + const r = to.map((v, i) => v - from[i]); + return d.reduce((acc, v, i) => acc + v * r[i], 0) / (Math.hypot(...d) * Math.hypot(...r) || 1); + }; + if ((await state(A.page)).mode !== '2d') { + await A.page.evaluate(() => window.__untangle.select(12, '2d')); + await eventually(() => state(A.page), (s) => s.mode === '2d', 'R.0a (premise) the flat board'); + } + const sticks = (await reelOf()).sticks; + console.log(' the "sticks" input scope in this core: ' + sticks); + const bc0 = B ? await B.page.evaluate(() => window.__untangle.centre()) : null; + for (const md of ['2d', '3d']) { + if (md === '3d') { + await A.page.evaluate(() => window.__untangle.select(12, '3d')); + await eventually(() => state(A.page), (s) => s.mode === '3d', 'R.3d.0a (premise) the globe'); + await A.page.waitForTimeout(150); + } + const c0 = await A.page.evaluate(() => window.__untangle.centre()); + await setHands(A.page, { right: await aimPose(A.page, HAND, c0, false) }); + await A.page.waitForTimeout(150); + check((await reelOf()).pointing, 'R.' + md + '.0 (premise) the right laser is on the ' + (md === '3d' ? 'globe' : 'board')); + await stickR(0, -1); + await A.page.waitForTimeout(450); + const held = await claims(); + await stickR(0, 0); + await A.page.waitForTimeout(150); + const c1 = await A.page.evaluate(() => window.__untangle.centre()); + check(dist(HAND, c1) > dist(HAND, c0) + 0.3, 'R.' + md + '.1 stick FORWARD pushes it away (' + dist(HAND, c0).toFixed(2) + ' -> ' + dist(HAND, c1).toFixed(2) + ' m from the hand)'); + check(along(c0, c1, HAND, c0) > 0.99, 'R.' + md + '.2 ...straight along the laser (cos ' + along(c0, c1, HAND, c0).toFixed(4) + ')'); + if (sticks) check(held.includes('sticks'), 'R.' + md + '.3 while the stick is deflected at the board it is the board\'s: both sticks claimed, no snap turn (' + held + ')'); + else console.log('SKIP R.' + md + '.3: this core has no "sticks" scope (1.19) — the right stick\'s Y is free in Untangle anyway'); + check(!(await claims()).includes('sticks'), 'R.' + md + '.4 back at rest the stick is the player\'s again (' + (await claims()) + ')'); + await setHands(A.page, { right: await aimPose(A.page, HAND, c1, false) }); + await stickR(0, 1); + await A.page.waitForTimeout(450); + await stickR(0, 0); + await A.page.waitForTimeout(150); + const c2 = await A.page.evaluate(() => window.__untangle.centre()); + check(dist(HAND, c2) < dist(HAND, c1) - 0.3, 'R.' + md + '.5 stick BACK pulls it closer (' + dist(HAND, c1).toFixed(2) + ' -> ' + dist(HAND, c2).toFixed(2) + ' m)'); + if (md === '3d') { + const t0 = (await reelOf()).turns; + await setHands(A.page, { right: await aimPose(A.page, HAND, c2, false) }); + await stickR(1, 0); + await A.page.waitForTimeout(300); + const heldX = await claims(); + await stickR(0, 0); + await A.page.waitForTimeout(150); + const r = await reelOf(); + check(r.turns > t0 && (!sticks || heldX.includes('sticks')), 'R.3d.6 left/right turns the globe' + (sticks ? ' — and no longer ALSO snap-turns the player (sticks claimed)' : '') + ' (' + (r.turns - t0) + ' frames)'); + } + } + if (bc0) { + const bc1 = await B.page.evaluate(() => window.__untangle.centre()); + check(dist(bc0, bc1) < 1e-6, 'R.7 the push is LOCAL: B\'s board never moved'); + } + // back to the authored pose (a mode change drops the local hold) for the spawn checks below + await A.page.evaluate(() => window.__untangle.select(1, '2d')); + await eventually(() => A.page.evaluate(() => window.__untangle.reel()), (r) => r.offset.every((v) => v === 0), 'R.8 a mode switch puts the board back where it was authored'); + await setHands(A.page, { right: await aimPose(A.page, HAND, AWAY, false) }); + } + // ---- U2. the spawn, through the real per-frame XR path -------------------------------------- if (!fs.existsSync(FAKE_XR)) { console.log('SKIP U2: no ' + FAKE_XR + ' (set CORE_DIR to a core checkout)'); @@ -513,6 +625,71 @@ run(async () => { check(Math.abs(ws - g1) < 1e-6, 'U1.6 ...and the board scales with it (world scale ' + ws.toFixed(3) + ')'); check((await state(A.page)).carried === -1, 'U1.7 ...the grips grabbed no dot'); } else console.log('SKIP U1.5-U1.7: this core has no play.locomotion.worldGrab (contract K1, 31-vr-core P3)'); + + // ---- S. 33 G3 — the headset's path to the levels: the controller's menu button (left X) opens + // the game's pause menu; the laser presses Levels; the Board tabs sit above the grid; a tab + // switches the board; a tile loads that level. The board is core's VR panel (drawn from a + // synthetic head at the spawn's eyes, as core's suites do); the presses go through core's own + // panel press path along a laser. + await A.page.evaluate(() => window.__untangle.select(1, '2d')); + await eventually(() => state(A.page), (s) => s.mode === '2d' && s.level === 1, 'S.0 (premise) 2D level 1'); + await A.page.evaluate(() => window.__stores.objectActions.setEditorMode('interact')); + const hasTabs = await A.page.evaluate(() => typeof window.__stores.gameKit.gameShell.shellLevelTabs === 'function'); + console.log(' the shell\'s level tabs (onLevels) in this core: ' + hasTabs); + const shellOpen = () => A.page.evaluate(() => { let v; window.__stores.gameKit.gameShell.shellMenu.subscribe((x) => (v = x))(); return v; }); + const boardIds = async () => { + await panelFrame(A.page, EYE); + return A.page.evaluate(() => window.__stores.gameKit.vrGamePanel.vrGamePanelDebug().hits['vr-game-panel'].map((x) => x.id)); + }; + const laser = (id) => + A.page.evaluate(({ id, hand }) => { + const s = window.__stores; + const k = s.gameKit.vrGamePanel; + const surf = k.vrGameSurface('vr-game-panel'); + const rect = k.vrGamePanelDebug().hits['vr-game-panel'].find((x) => x.id === id); + if (!rect) return { ok: false, why: 'no rect ' + id }; + const g = surf.mesh.geometry.parameters; + const target = surf.mesh.localToWorld(new s.THREE.Vector3(((rect.x + rect.w / 2) / surf.canvas.width - 0.5) * g.width, (0.5 - (rect.y + rect.h / 2) / surf.canvas.height) * g.height, 0)); + const from = new s.THREE.Vector3(...hand); + const t = k.panelTargetAlong(new s.THREE.Raycaster(from, target.clone().sub(from).normalize())); + if (!t) return { ok: false, why: 'the laser missed' }; + return { ok: k.pressPanelTarget(t), hit: t.hit.id }; + }, { id, hand: HAND }); + await xr.button(A.page, 'left', 4, true); + await A.page.waitForTimeout(250); + await xr.button(A.page, 'left', 4, false); + await A.page.waitForTimeout(150); + check((await shellOpen()).open, 'S.1 the controller\'s menu button (left X) opens the pause menu, through the real per-frame path'); + let ids = await boardIds(); + check(ids.includes('shell:item:levels') && ids.includes('shell:item:settings'), 'S.2 the VR board offers Levels and Settings (' + ids.filter((i) => i.startsWith('shell:item')).join(', ') + ')'); + let p = await laser('shell:item:levels'); + ids = await boardIds(); + check(p.ok && (await shellOpen()).page === 'levels' && ids.includes('shell:level:1'), 'S.3 the laser opens Levels: the level tiles are on the board (' + JSON.stringify(p) + ')'); + const unlocked = await A.page.evaluate(() => window.__stores.gameKit.gameShell.gameShellDebug().levels.list.filter((l) => !l.locked).length); + check(ids.filter((i) => i.startsWith('shell:level:')).length === unlocked, 'S.4 every open level is a tile (' + unlocked + ')'); + await eyeShot(A.page, 'vr-shell-levels', EYE, [0, EYE[1], EYE[2] - 2]); + if (hasTabs) { + check(ids.includes('shell:tab:0:0') && ids.includes('shell:tab:0:1'), 'S.5 the Board tabs (Globe / 2D board) sit above the grid (' + ids.filter((i) => i.startsWith('shell:tab')) + ')'); + p = await laser('shell:tab:0:0'); + await eventually(() => state(A.page), (s) => s.mode === '3d' && s.level === 1, 'S.6 the laser on the Globe tab switches the board to the globe, same level (' + JSON.stringify(p) + ')', 4000); + check((await shellOpen()).page === 'levels', 'S.7 ...and the Levels page stays up (the tiles are now the globe\'s)'); + } else { + console.log('SKIP S.5-S.7: this core has no level tabs (1.19) — the Board choice is the Settings row'); + await laser('shell:back'); + await boardIds(); + await laser('shell:item:settings'); + ids = await boardIds(); + check(ids.includes('shell:set:board:next'), 'S.5b the Settings page carries the Board row'); + await laser('shell:set:board:prev'); + await eventually(() => state(A.page), (s) => s.mode === '3d', 'S.6b ...and it switches the board to the globe', 4000); + await laser('shell:back'); + await boardIds(); + await laser('shell:item:levels'); + await boardIds(); + } + p = await laser('shell:level:1'); + await eventually(() => state(A.page), (s) => s.level === 1 && s.mode === '3d', 'S.8 a tile press loads that level on the globe (' + JSON.stringify(p) + ')', 4000); + check(!(await shellOpen()).open, 'S.9 ...and closes the menu'); await xr.uninstall(A.page); } diff --git a/tests/waves-hit-reset.test.cjs b/tests/waves-hit-reset.test.cjs new file mode 100644 index 0000000..80e25e4 --- /dev/null +++ b/tests/waves-hit-reset.test.cjs @@ -0,0 +1,249 @@ +// waves-hit-reset (31-integrate, the owner's Quest report on preview-1-18): "as soon as I hit an +// enemy, ALL enemies return to their start position". Played the headset way (the module's own +// seams — isVRMode, Interact, api.vrHand — exactly as waves-shooter does): the round starts from +// the board, wave 1 walks, ONE real trigger shot lands ONE hit on ONE enemy, and every OTHER +// walker must keep the ground it has covered (its distance from its own portal never collapses). +// WAVES_TPSCENE= APP_URL=https://theprototype.app:5269/ E2E_GPU=1 node tests/waves-hit-reset.test.cjs +const h = require('./helpers.cjs'); +const fs = require('fs'); + +const SCENE = process.env.WAVES_TPSCENE || '/home/deck/.code/theprototype-app/cloud-lane-30-staging/30b-waves/games/waves/scene.tpscene'; + +async function loadTemplate(page) { + if (!fs.existsSync(SCENE)) throw new Error('template not found: ' + SCENE + ' (set WAVES_TPSCENE)'); + const bytes = fs.readFileSync(SCENE); + await page.evaluate(async (arr) => { + const s = window.__stores; + await s.sessions.applySession(await s.sessions.readSessionZip(new Uint8Array(arr).buffer), { backup: false }); + }, Array.from(bytes)); + await page.waitForTimeout(2500); +} +const enemies = (page) => + page.evaluate(() => { + let g; + window.__stores.objectsGroup.subscribe((v) => (g = v))(); + return g.children.filter((c) => /^Enemy/.test(c.name)).map((c) => ({ name: c.name, uuid: c.uuid, pos: c.position.toArray(), visible: c.visible })); + }); +const gameState = (page) => + page.evaluate(() => { + let g; + window.__stores.gameState.gameState.subscribe((v) => (g = v))(); + return g?.state ?? null; + }); +/** the emulated headset: VR on, Interact, and a hand the flight points */ +const headset = (page, on) => + page.evaluate((on) => { + const s = window.__stores; + s.isVRMode.set(on); + s.editorMode.set(on ? 'interact' : 'edit'); + const api = window.__waves.api; + window.__hand = window.__hand ?? { right: null, left: null }; + if (!api.__realVrHand) api.__realVrHand = api.vrHand; + api.vrHand = on ? (hand) => (window.__hand[hand] ? { ...window.__hand[hand], connected: true } : null) : api.__realVrHand; + }, on); +/** point a hand from `from` at `to`, trigger up or down */ +const aim = (page, hand, from, to, trigger) => + page.evaluate( + ({ hand, from, to, trigger }) => { + const T = window.__stores.THREE; + const dir = new T.Vector3(to[0] - from[0], to[1] - from[1], to[2] - from[2]).normalize(); + const q = new T.Quaternion().setFromUnitVectors(new T.Vector3(0, 0, -1), dir); + window.__hand[hand] = { position: from, quaternion: q.toArray(), trigger, gripped: false }; + }, + { hand, from, to, trigger } + ); +const pull = async (page, hand, from, to) => { + await aim(page, hand, from, to, false); + await page.waitForTimeout(120); + await aim(page, hand, from, to, true); + await page.waitForTimeout(160); + await aim(page, hand, from, to, false); + await page.waitForTimeout(120); +}; +const boardCenter = (page) => page.evaluate(() => window.__waves.start.board.rect().center); +const eye = (page) => page.evaluate(() => window.__waves.api.playerPosition()); +/** the engine's view of one enemy (hits, kills, hp) by uuid */ +const enemyState = (page, uuid) => page.evaluate((u) => (window.__waves.snapshot()[0]?.enemies ?? []).find((e) => e.uuid === u) ?? null, uuid); +/** the engine's targets in the engine's order, except that a walker within 4 m of the goal goes + * LAST (30c): a check that takes "a walking enemy" must not take the one about to breach the + * crystal — it dies (and is stashed) between the pick and the shot (2.14 / 2.15 failed at + * random). Not "farthest first": that picks one still standing at its portal (3.2's shove clamps) */ +const targets = (page) => + page.evaluate(() => { + const w = window.__waves; + const goal = w.engine.all()[0]?.goal ?? [0, 0, 0]; + let g; + window.__stores.objectsGroup.subscribe((v) => (g = v))(); + const left = (u) => { + const p = g.getObjectByProperty('uuid', u)?.position; + return p ? Math.hypot(p.x - goal[0], p.z - goal[2]) : 0; + }; + return w.engine + .targets() + .map((t) => ({ uuid: t.uuid, label: t.label, hp: t.hp, walking: t.walking, left: left(t.uuid) })) + .map((t, i) => ({ t, i, late: t.walking && t.left < 4 ? 1 : 0 })) + .sort((a, b) => a.late - b.late || a.i - b.i) + .map((x) => x.t); + }); +const timedPos = (page, uuid) => + page.evaluate((u) => { + let g; + window.__stores.objectsGroup.subscribe((v) => (g = v))(); + const o = g.getObjectByProperty('uuid', u); + return { p: o.getWorldPosition(new window.__stores.THREE.Vector3()).toArray(), t: performance.now() / 1000 }; + }, uuid); +const worldPos = (page, uuid) => + page.evaluate((u) => { + let g; + window.__stores.objectsGroup.subscribe((v) => (g = v))(); + const o = g.getObjectByProperty('uuid', u); + return o ? o.getWorldPosition(new window.__stores.THREE.Vector3()).toArray() : null; + }, uuid); +/** aim `hand` from `from` at the enemy's LIVE position, hold the trigger `frames` frames (or, + * with `holdMs`, for that long by the clock — a frame count is load-dependent), let go */ +const shootAt = (page, hand, from, uuid, frames = 2, holdMs = 0) => + page.evaluate( + ({ hand, from, uuid, frames, holdMs }) => + new Promise((resolve) => { + const T = window.__stores.THREE; + let g; + window.__stores.objectsGroup.subscribe((v) => (g = v))(); + const aimNow = (trigger) => { + const o = g.getObjectByProperty('uuid', uuid); + const to = o.getWorldPosition(new T.Vector3()); + const dir = to.clone().sub(new T.Vector3(...from)).normalize(); + const q = new T.Quaternion().setFromUnitVectors(new T.Vector3(0, 0, -1), dir); + window.__hand[hand] = { position: from, quaternion: q.toArray(), trigger, gripped: false }; + }; + aimNow(false); + let n = 0; + let pressedAt = 0; + const tick = () => { + n++; + if (n === 2) { + aimNow(true); + pressedAt = performance.now(); + } else if (n > 2 && (holdMs ? performance.now() - pressedAt < holdMs : n < 2 + frames)) aimNow(true); + else if (n > 2) { + aimNow(false); + return setTimeout(resolve, 60); + } else aimNow(false); + requestAnimationFrame(tick); + }; + requestAnimationFrame(tick); + }), + { hand, from, uuid, frames, holdMs } + ); +/** record every sound and buzz the module asks for (the 30b core seams, stubbed on its api) */ +const recordFeel = (page) => + page.evaluate(() => { + const api = window.__waves.api; + window.__feel = { sounds: [], haptics: [] }; + api.music = api.music ?? { play() {}, stop() {} }; + api.playSound = (name, at) => window.__feel.sounds.push(name); + api.hapticPattern = (name, hand) => window.__feel.haptics.push(name + ':' + hand); + }); +const feelLog = (page) => page.evaluate(() => window.__feel); + +/** every walking enemy: its world position and how far it is from its portal (the nearest spawn pad) */ +const progress = (page) => + page.evaluate(() => { + const w = window.__waves; + const s = w.engine.all()[0]; + let g; + window.__stores.objectsGroup.subscribe((v) => (g = v))(); + const T = window.__stores.THREE; + const pads = g.children.filter((c) => /^Spawn/.test(c.name)).map((c) => c.getWorldPosition(new T.Vector3())); + return w.engine + .targets() + .filter((t) => t.walking) + .map((t) => { + const p = g.getObjectByProperty('uuid', t.uuid).getWorldPosition(new T.Vector3()); + const fromPortal = Math.min(...pads.map((q) => Math.hypot(p.x - q.x, p.z - q.z))); + return { uuid: t.uuid, label: t.label, hits: t.hits ?? null, fromPortal, toGoal: Math.hypot(p.x - s.goal[0], p.z - s.goal[2]) }; + }); + }); +const run = (page) => page.evaluate(() => { const s = window.__waves.snapshot()[0]; return s && { wave: s.wave, completed: s.completed, started: s.started, waveStart: s.waveStart }; }); + +h.run(async () => { + const browser = await h.launch({ args: h.GPU_ARGS }); + const A = await h.setupPage(browser, 'A'); + await h.installModule(A, 'health'); + await h.installModule(A, process.env.WAVES_ZIP || 'waves', 'waves'); + await loadTemplate(A.page); + // a Quest session is Interact WITHOUT desktop Play (isLocked stays null); PLAY=1 also presses Play + if (process.env.PLAY) { + await A.page.evaluate(() => window.__stores.playMode.requestPlay()); + await A.page.waitForTimeout(800); + } + await headset(A.page, true); + await A.page.waitForTimeout(400); + // a REAL session starts the scene's simulation here (simOnPlay; Play never runs in a headset) + if (!process.env.PLAY) await A.page.evaluate(() => window.__stores.playMode.noteXRSessionStarted()); + await A.page.waitForTimeout(1500); + const sim0 = await A.page.evaluate(() => { let v; window.__stores.physics.simulating.subscribe((x) => (v = x))(); return v; }); + h.check(sim0 === true, '0.0 (premise) the simulation runs, as in a headset session (simOnPlay)'); + // a headset session takes the governor's entry floor (shadows off, level 1) — as on a Quest + await A.page.evaluate((l) => window.__stores.qualityGovernor.governorForTest.setLevel(l), Number(process.env.QLEVEL || 1)); + const head = await eye(A.page); + const center = await boardCenter(A.page); + const hand = [head[0] + 0.2, head[1] - 0.4, head[2] - 0.2]; + await pull(A.page, 'right', hand, center); + await h.eventually(() => gameState(A.page), (s) => s === 'playing', '0.1 the round runs (started from the headset board)'); + await A.page.evaluate(() => window.__waves.engine.setGuard?.(() => true)); + await aim(A.page, 'right', hand, [hand[0], hand[1] + 5, hand[2] + 1], false); + await h.eventually(() => progress(A.page), (p) => p.filter((x) => x.fromPortal > 1.0).length >= 2, '0.2 wave 1 walks (2+ walkers clear of their portals)', 20000); + const r0 = await run(A.page); + h.check(r0.wave === 1, '0.2b (premise) this is WAVE 1 — the first hit of the round (' + JSON.stringify(r0) + ')'); + const before = await progress(A.page); + const out = before.filter((x) => x.fromPortal > 1.0); + h.check(out.length >= 2, '0.3 (premise) 2+ walkers are well clear of their portals (' + before.map((x) => x.fromPortal.toFixed(2)).join(', ') + ')'); + // ---- ONE real shot at the walker nearest the crystal ------------------------------------ + const victim = [...out].sort((a, b) => a.toGoal - b.toGoal)[0]; + const s0 = await enemyState(A.page, victim.uuid); + await shootAt(A.page, 'right', hand, victim.uuid); + await h.eventually(() => enemyState(A.page, victim.uuid), (e) => e && e.hits === s0.hits + 1, '1.1 (premise) the trigger landed ONE hit on the victim'); + const samples = []; + for (const ms of [150, 400, 900, 1500]) { + await A.page.waitForTimeout(ms - (samples.length ? [150, 400, 900, 1500][samples.length - 1] : 0)); + samples.push(await progress(A.page)); + } + const r1 = await run(A.page); + const others = out.filter((x) => x.uuid !== victim.uuid); + const worst = others.map((x) => { + const lows = samples.map((s) => s.find((y) => y.uuid === x.uuid)?.fromPortal ?? x.fromPortal); + return { label: x.label, before: +x.fromPortal.toFixed(2), min: +Math.min(...lows).toFixed(2) }; + }); + h.check(worst.every((x) => x.min > x.before - 0.4), '1.2 THE REPORT: no other walker goes back toward its portal after the hit (' + JSON.stringify(worst) + ')'); + h.check(worst.every((x) => x.min > 0.8), '1.3 ...and none is back at its start position'); + h.check(r1.waveStart === r0.waveStart && r1.wave === r0.wave, '1.4 the wave clock did not move (' + JSON.stringify(r0) + ' -> ' + JSON.stringify(r1) + ')'); + + // ---- 2. the same in WAVE 2: every enemy of wave 1 walks again, so a hit on any of them used to + // move "the clear" and with it the running wave's start (the 1.17 W1 bug, fixed in 2.2.0) ---- + const hitN = (uuid, n) => A.page.evaluate(({ uuid, n }) => window.__waves.engine.hit(uuid, n), { uuid, n }); + for (let i = 0; i < 6 && (await run(A.page)).wave === 1; i++) { + for (const t of await A.page.evaluate(() => window.__waves.engine.targets().map((x) => ({ uuid: x.uuid, hp: x.hp })))) await hitN(t.uuid, t.hp); + await A.page.waitForTimeout(600); + } + await h.eventually(() => run(A.page), (r) => r && r.wave === 2 && r.started, '2.0 (premise) wave 1 cleared, wave 2 walks', 25000); + await h.eventually(() => progress(A.page), (p) => p.filter((x) => x.fromPortal > 1.0).length >= 2, '2.1 (premise) 2+ wave-2 walkers clear of their portals', 20000); + const w0 = await run(A.page); + const before2 = (await progress(A.page)).filter((x) => x.fromPortal > 1.0); + const victim2 = [...before2].sort((a, b) => a.toGoal - b.toGoal)[0]; + const e0 = await enemyState(A.page, victim2.uuid); + await shootAt(A.page, 'right', hand, victim2.uuid); + await h.eventually(() => enemyState(A.page, victim2.uuid), (e) => e && e.hits === e0.hits + 1, '2.2 (premise) the trigger landed ONE hit in wave 2'); + const samples2 = []; + for (let i = 0; i < 4; i++) { + await A.page.waitForTimeout(350); + samples2.push(await progress(A.page)); + } + const w1 = await run(A.page); + const worst2 = before2.filter((x) => x.uuid !== victim2.uuid).map((x) => { + const lows = samples2.map((s) => s.find((y) => y.uuid === x.uuid)?.fromPortal ?? 0); + return { label: x.label, before: +x.fromPortal.toFixed(2), min: +Math.min(...lows).toFixed(2) }; + }); + h.check(worst2.length > 0 && worst2.every((x) => x.min > x.before - 0.4 && x.min > 0.8), '2.3 THE REPORT in wave 2: every other walker keeps its ground after the hit (' + JSON.stringify(worst2) + ')'); + h.check(w1.waveStart === w0.waveStart, '2.4 the running wave\'s start did not move (' + w0.waveStart + ' -> ' + w1.waveStart + ')'); + await h.finish(browser); +});